Large refactor of files over 400+ of lines of code to make it more managable. Just clean up/splitting of files.

This commit is contained in:
whit3rabbit
2026-07-12 21:14:16 -05:00
parent 6816442f3c
commit 1c7d66cc54
107 changed files with 11290 additions and 10932 deletions
+36
View File
@@ -875,6 +875,30 @@
"output_cost_per_token": 3e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-5.6",
"input_cost_per_token": 5e-06,
"output_cost_per_token": 3e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-5.6-luna",
"input_cost_per_token": 1e-06,
"output_cost_per_token": 6e-06,
"provider": "openai"
},
{
"model_pattern": "gpt-5.6-sol",
"input_cost_per_token": 5e-06,
"output_cost_per_token": 3e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-5.6-terra",
"input_cost_per_token": 2.5e-06,
"output_cost_per_token": 1.5e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-audio",
"input_cost_per_token": 2.5e-06,
@@ -929,6 +953,18 @@
"output_cost_per_token": 1.6e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-realtime-2.1",
"input_cost_per_token": 4e-06,
"output_cost_per_token": 2.4e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-realtime-2.1-mini",
"input_cost_per_token": 6e-07,
"output_cost_per_token": 2.4e-06,
"provider": "openai"
},
{
"model_pattern": "gpt-realtime-2025-08-28",
"input_cost_per_token": 4e-06,
@@ -280,350 +280,5 @@ fn decisions_hash(edits: &[(usize, BufferId, EditScript)]) -> u64 {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::budget::HeuristicBudgetCounter;
use crate::traits::{CacheModel, Pricing, UniformScorer};
use crate::types::{Message, Role};
struct TestStrategy;
impl CacheStrategy for TestStrategy {
fn pricing(&self) -> Pricing {
Pricing {
input: 1.0,
cached_read: 0.5,
cache_write_mult: 1.0,
}
}
fn model(&self) -> CacheModel {
CacheModel::ExplicitBreakpoints
}
fn breakpoint_at(&self, frontier: usize) -> Option<usize> {
Some(frontier)
}
}
fn long_user(text: &str) -> Message {
Message {
role: Role::User,
blocks: vec![ContentBlock::Text(text.into())],
protection: Protection::Mutable,
client_cache_marker: false,
}
}
fn convo(n: usize) -> Conversation {
let long = "The quick brown fox jumps over the lazy dog again and again across \
the wide green field toward the distant blue mountains beyond the river \
and the tall dark trees under a bright and cloudless summer sky at noon."
.to_string();
Conversation::new((0..n).map(|_| long_user(&long)).collect())
}
#[test]
fn shadow_never_renders_but_reports() {
let conv = convo(20);
let policy = Policy {
mode: Mode::Shadow,
..Default::default()
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(out.rendered.is_none());
assert_eq!(out.report.mode, Mode::Shadow);
assert!(out.report.frontier > 0);
assert!(out.report.removed_tokens_est > 0);
}
#[test]
fn live_renders_and_is_deterministic() {
let conv = convo(20);
let policy = Policy {
mode: Mode::Live,
..Default::default()
};
let mut ws = Workspace::new();
let a = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
let b = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(a.rendered.is_some());
assert_eq!(a.report.decisions_hash, b.report.decisions_hash);
assert!(a.report.applied);
}
#[test]
fn cost_delta_is_signed_and_nonzero_for_compression() {
let conv = convo(20);
let policy = Policy {
mode: Mode::Live,
..Default::default()
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(out.report.applied);
assert!(out.report.removed_tokens_est > 0);
let expected = net_cost_delta_usd(
out.report.removed_tokens_est,
out.report.rewrite_suffix_tokens,
policy.horizon,
&TestStrategy.pricing(),
&TestStrategy.model(),
);
assert!(expected > 0.0, "fixture should have a positive delta");
assert_eq!(out.report.est_cost_delta_usd, expected);
}
#[test]
fn cost_delta_is_zero_for_noop() {
// No message reaches `min_len`, so no edits are produced and dt stays 0 — the
// report's delta must be exactly 0.0, not the raw formula's rewrite-cost artifact.
let conv = Conversation::new(vec![long_user("too short to compress")]);
let policy = Policy {
mode: Mode::Live,
..Default::default()
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(!out.report.applied);
assert_eq!(out.report.removed_tokens_est, 0);
assert_eq!(out.report.est_cost_delta_usd, 0.0);
}
#[test]
fn route_override_turns_off_one_route_while_another_still_compresses() {
use crate::policy::{OptimizationPolicy, RouteOverride};
use std::collections::HashMap;
let conv = convo(20);
let mut routes = HashMap::new();
routes.insert(
"batch".to_string(),
RouteOverride {
mode: Some(Mode::Off),
ratios: None,
pricing: None,
},
);
let opt_policy = OptimizationPolicy {
mode: Mode::Live,
routes,
..OptimizationPolicy::default()
};
// Overridden route: rendered stays None, i.e. output ≡ input for that route.
let mut ws = Workspace::new();
let off = optimize_for_route(
&conv,
&opt_policy,
"batch",
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(off.rendered.is_none());
assert!(!off.report.applied);
// Unlisted route: falls back to the top-level Live default and still compresses.
let mut ws2 = Workspace::new();
let on = optimize_for_route(
&conv,
&opt_policy,
"interactive",
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws2,
);
assert!(on.rendered.is_some());
assert!(on.report.applied);
}
/// Scores like `UniformScorer` but sleeps on its very first call, so the deadline
/// check ahead of every later message is guaranteed to observe it expired
/// regardless of scheduler jitter (sleep duration >> deadline budget below).
struct SlowFirstCallScorer {
calls: std::sync::atomic::AtomicUsize,
}
impl TokenScorer for SlowFirstCallScorer {
fn score_words(
&self,
words: &[&str],
_ws: &mut Workspace,
) -> Result<Vec<f32>, crate::error::ScoreError> {
if self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
std::thread::sleep(Duration::from_millis(50));
}
Ok(vec![0.5; words.len()])
}
fn artifact_hash(&self) -> u64 {
0
}
}
#[test]
fn deadline_expiry_leaves_later_messages_byte_identical() {
// 20 messages -> frontier eligible_end=16 (keep_recent=4), all long enough to
// clear `min_len`. A 50ms sleep on the first scorer call vs. a 10ms deadline
// guarantees message 0 is scored before the deadline and every later message
// observes it already expired (elapsed only ever grows).
let conv = convo(20);
let mut policy = Policy {
mode: Mode::Live,
..Default::default()
};
policy.compression.deadline = Duration::from_millis(10);
let scorer = SlowFirstCallScorer {
calls: std::sync::atomic::AtomicUsize::new(0),
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&scorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(
out.report.messages_compressed >= 1,
"message scored before the deadline should be compressed"
);
assert!(
out.report.messages_skipped_deadline >= 1,
"messages after deadline expiry must be counted as skipped"
);
assert_eq!(
out.report.messages_compressed as usize + out.report.messages_skipped_deadline as usize,
out.report.frontier,
"every eligible message is either compressed or explicitly deadline-skipped, never silently dropped"
);
let rendered = out
.rendered
.expect("gate should apply given a nonzero saving");
let compressed = out.report.messages_compressed as usize;
// Deadline-skipped messages must render byte-identical to the source.
for i in compressed..out.report.frontier {
assert_eq!(
rendered.messages[i].blocks[0], conv.messages[i].blocks[0],
"deadline-skipped message {i} must stay byte-identical"
);
}
// The message scored before the deadline actually got edited.
assert_ne!(
rendered.messages[0].blocks[0], conv.messages[0].blocks[0],
"the message scored before the deadline should have been edited"
);
}
/// M4.3: `Policy::pricing_override`, loaded from a config string via
/// `Pricing::from_config_str` (no hardcoded-table constant), must be what the
/// orchestrator actually uses for the cost gate/report — not `TestStrategy`'s own
/// (hardcoded-in-code) `pricing()`. Proven by asserting the report's dollar delta
/// matches `net_cost_delta_usd` computed with the config pricing and differs from
/// the value that would result from `TestStrategy::pricing()` alone.
#[test]
fn pricing_comes_from_config() {
use crate::traits::Pricing;
let conv = convo(20);
// Deliberately far from `TestStrategy::pricing()` (input:1.0, cached_read:0.5,
// cache_write_mult:1.0) and from any of `anyllm_optimize_passes::cost_gate`'s
// hardcoded tables — proves the number really came from this config string.
let config_pricing =
Pricing::from_config_str("input=9.0\ncached_read=0.05\ncache_write_mult=2.0\n")
.expect("well-formed config parses");
assert_ne!(config_pricing, TestStrategy.pricing());
let policy = Policy {
mode: Mode::Live,
pricing_override: Some(config_pricing),
..Default::default()
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(out.report.applied);
assert!(out.report.removed_tokens_est > 0);
let expected_from_config = net_cost_delta_usd(
out.report.removed_tokens_est,
out.report.rewrite_suffix_tokens,
policy.horizon,
&config_pricing,
&TestStrategy.model(),
);
let would_be_from_hardcoded_strategy = net_cost_delta_usd(
out.report.removed_tokens_est,
out.report.rewrite_suffix_tokens,
policy.horizon,
&TestStrategy.pricing(),
&TestStrategy.model(),
);
assert_eq!(out.report.est_cost_delta_usd, expected_from_config);
assert_ne!(
out.report.est_cost_delta_usd, would_be_from_hardcoded_strategy,
"orchestrator must use the config-loaded pricing, not the strategy's hardcoded table"
);
}
#[test]
fn empty_conversation_is_noop() {
let conv = Conversation::default();
let out = optimize(
&conv,
&Policy::default(),
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut Workspace::new(),
);
assert!(out.rendered.is_none());
assert_eq!(out.report.frontier, 0);
}
}
#[path = "orchestrator_tests.rs"]
mod tests;
@@ -0,0 +1,345 @@
use super::*;
use crate::budget::HeuristicBudgetCounter;
use crate::traits::{CacheModel, Pricing, UniformScorer};
use crate::types::{Message, Role};
struct TestStrategy;
impl CacheStrategy for TestStrategy {
fn pricing(&self) -> Pricing {
Pricing {
input: 1.0,
cached_read: 0.5,
cache_write_mult: 1.0,
}
}
fn model(&self) -> CacheModel {
CacheModel::ExplicitBreakpoints
}
fn breakpoint_at(&self, frontier: usize) -> Option<usize> {
Some(frontier)
}
}
fn long_user(text: &str) -> Message {
Message {
role: Role::User,
blocks: vec![ContentBlock::Text(text.into())],
protection: Protection::Mutable,
client_cache_marker: false,
}
}
fn convo(n: usize) -> Conversation {
let long = "The quick brown fox jumps over the lazy dog again and again across \
the wide green field toward the distant blue mountains beyond the river \
and the tall dark trees under a bright and cloudless summer sky at noon."
.to_string();
Conversation::new((0..n).map(|_| long_user(&long)).collect())
}
#[test]
fn shadow_never_renders_but_reports() {
let conv = convo(20);
let policy = Policy {
mode: Mode::Shadow,
..Default::default()
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(out.rendered.is_none());
assert_eq!(out.report.mode, Mode::Shadow);
assert!(out.report.frontier > 0);
assert!(out.report.removed_tokens_est > 0);
}
#[test]
fn live_renders_and_is_deterministic() {
let conv = convo(20);
let policy = Policy {
mode: Mode::Live,
..Default::default()
};
let mut ws = Workspace::new();
let a = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
let b = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(a.rendered.is_some());
assert_eq!(a.report.decisions_hash, b.report.decisions_hash);
assert!(a.report.applied);
}
#[test]
fn cost_delta_is_signed_and_nonzero_for_compression() {
let conv = convo(20);
let policy = Policy {
mode: Mode::Live,
..Default::default()
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(out.report.applied);
assert!(out.report.removed_tokens_est > 0);
let expected = net_cost_delta_usd(
out.report.removed_tokens_est,
out.report.rewrite_suffix_tokens,
policy.horizon,
&TestStrategy.pricing(),
&TestStrategy.model(),
);
assert!(expected > 0.0, "fixture should have a positive delta");
assert_eq!(out.report.est_cost_delta_usd, expected);
}
#[test]
fn cost_delta_is_zero_for_noop() {
// No message reaches `min_len`, so no edits are produced and dt stays 0 — the
// report's delta must be exactly 0.0, not the raw formula's rewrite-cost artifact.
let conv = Conversation::new(vec![long_user("too short to compress")]);
let policy = Policy {
mode: Mode::Live,
..Default::default()
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(!out.report.applied);
assert_eq!(out.report.removed_tokens_est, 0);
assert_eq!(out.report.est_cost_delta_usd, 0.0);
}
#[test]
fn route_override_turns_off_one_route_while_another_still_compresses() {
use crate::policy::{OptimizationPolicy, RouteOverride};
use std::collections::HashMap;
let conv = convo(20);
let mut routes = HashMap::new();
routes.insert(
"batch".to_string(),
RouteOverride {
mode: Some(Mode::Off),
ratios: None,
pricing: None,
},
);
let opt_policy = OptimizationPolicy {
mode: Mode::Live,
routes,
..OptimizationPolicy::default()
};
// Overridden route: rendered stays None, i.e. output ≡ input for that route.
let mut ws = Workspace::new();
let off = optimize_for_route(
&conv,
&opt_policy,
"batch",
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(off.rendered.is_none());
assert!(!off.report.applied);
// Unlisted route: falls back to the top-level Live default and still compresses.
let mut ws2 = Workspace::new();
let on = optimize_for_route(
&conv,
&opt_policy,
"interactive",
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws2,
);
assert!(on.rendered.is_some());
assert!(on.report.applied);
}
/// Scores like `UniformScorer` but sleeps on its very first call, so the deadline
/// check ahead of every later message is guaranteed to observe it expired
/// regardless of scheduler jitter (sleep duration >> deadline budget below).
struct SlowFirstCallScorer {
calls: std::sync::atomic::AtomicUsize,
}
impl TokenScorer for SlowFirstCallScorer {
fn score_words(
&self,
words: &[&str],
_ws: &mut Workspace,
) -> Result<Vec<f32>, crate::error::ScoreError> {
if self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
std::thread::sleep(Duration::from_millis(50));
}
Ok(vec![0.5; words.len()])
}
fn artifact_hash(&self) -> u64 {
0
}
}
#[test]
fn deadline_expiry_leaves_later_messages_byte_identical() {
// 20 messages -> frontier eligible_end=16 (keep_recent=4), all long enough to
// clear `min_len`. A 50ms sleep on the first scorer call vs. a 10ms deadline
// guarantees message 0 is scored before the deadline and every later message
// observes it already expired (elapsed only ever grows).
let conv = convo(20);
let mut policy = Policy {
mode: Mode::Live,
..Default::default()
};
policy.compression.deadline = Duration::from_millis(10);
let scorer = SlowFirstCallScorer {
calls: std::sync::atomic::AtomicUsize::new(0),
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&scorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(
out.report.messages_compressed >= 1,
"message scored before the deadline should be compressed"
);
assert!(
out.report.messages_skipped_deadline >= 1,
"messages after deadline expiry must be counted as skipped"
);
assert_eq!(
out.report.messages_compressed as usize + out.report.messages_skipped_deadline as usize,
out.report.frontier,
"every eligible message is either compressed or explicitly deadline-skipped, never silently dropped"
);
let rendered = out
.rendered
.expect("gate should apply given a nonzero saving");
let compressed = out.report.messages_compressed as usize;
// Deadline-skipped messages must render byte-identical to the source.
for i in compressed..out.report.frontier {
assert_eq!(
rendered.messages[i].blocks[0], conv.messages[i].blocks[0],
"deadline-skipped message {i} must stay byte-identical"
);
}
// The message scored before the deadline actually got edited.
assert_ne!(
rendered.messages[0].blocks[0], conv.messages[0].blocks[0],
"the message scored before the deadline should have been edited"
);
}
/// M4.3: `Policy::pricing_override`, loaded from a config string via
/// `Pricing::from_config_str` (no hardcoded-table constant), must be what the
/// orchestrator actually uses for the cost gate/report — not `TestStrategy`'s own
/// (hardcoded-in-code) `pricing()`. Proven by asserting the report's dollar delta
/// matches `net_cost_delta_usd` computed with the config pricing and differs from
/// the value that would result from `TestStrategy::pricing()` alone.
#[test]
fn pricing_comes_from_config() {
use crate::traits::Pricing;
let conv = convo(20);
// Deliberately far from `TestStrategy::pricing()` (input:1.0, cached_read:0.5,
// cache_write_mult:1.0) and from any of `anyllm_optimize_passes::cost_gate`'s
// hardcoded tables — proves the number really came from this config string.
let config_pricing =
Pricing::from_config_str("input=9.0\ncached_read=0.05\ncache_write_mult=2.0\n")
.expect("well-formed config parses");
assert_ne!(config_pricing, TestStrategy.pricing());
let policy = Policy {
mode: Mode::Live,
pricing_override: Some(config_pricing),
..Default::default()
};
let mut ws = Workspace::new();
let out = optimize(
&conv,
&policy,
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut ws,
);
assert!(out.report.applied);
assert!(out.report.removed_tokens_est > 0);
let expected_from_config = net_cost_delta_usd(
out.report.removed_tokens_est,
out.report.rewrite_suffix_tokens,
policy.horizon,
&config_pricing,
&TestStrategy.model(),
);
let would_be_from_hardcoded_strategy = net_cost_delta_usd(
out.report.removed_tokens_est,
out.report.rewrite_suffix_tokens,
policy.horizon,
&TestStrategy.pricing(),
&TestStrategy.model(),
);
assert_eq!(out.report.est_cost_delta_usd, expected_from_config);
assert_ne!(
out.report.est_cost_delta_usd, would_be_from_hardcoded_strategy,
"orchestrator must use the config-loaded pricing, not the strategy's hardcoded table"
);
}
#[test]
fn empty_conversation_is_noop() {
let conv = Conversation::default();
let out = optimize(
&conv,
&Policy::default(),
&TestStrategy,
&UniformScorer,
&HeuristicBudgetCounter::default(),
&mut Workspace::new(),
);
assert!(out.rendered.is_none());
assert_eq!(out.report.frontier, 0);
}
+195
View File
@@ -0,0 +1,195 @@
use super::{Api, Args};
use anyhow::{Context, Result};
use serde_json::{json, Value};
/// One provider response: token usage + the assistant text.
pub struct Reply {
pub prompt_tokens: u64,
pub text: String,
}
/// Send one request and return usage + assistant text.
pub fn send_request(
client: &reqwest::blocking::Client,
args: &Args,
body: &Value,
base_url: &str,
api: Api,
) -> Result<Reply> {
let mut body = body.clone();
let url = match api {
Api::Openai => format!("{}/chat/completions", base_url.trim_end_matches('/')),
Api::Anthropic => format!("{}/messages", base_url.trim_end_matches('/')),
};
if args.stream {
body["stream"] = json!(true);
if api == Api::Openai {
body["stream_options"] = json!({ "include_usage": true });
}
}
let key = resolve_key(args);
let mut req = client.post(&url).json(&body);
req = match api {
Api::Openai => {
if key.is_empty() {
req
} else {
req.bearer_auth(key)
}
}
Api::Anthropic => req
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01"),
};
let resp = req.send().context("request send failed")?;
let status = resp.status();
let text = resp.text().context("reading response body")?;
if !status.is_success() {
anyhow::bail!(
"HTTP {status}: {}",
text.chars().take(300).collect::<String>()
);
}
Ok(Reply {
prompt_tokens: extract_prompt_tokens(&text, api, args.stream)
.context("could not find prompt/input token usage")?,
text: extract_response_text(&text, api, args.stream),
})
}
pub fn resolve_key(args: &Args) -> String {
args.api_key
.clone()
.or_else(|| std::env::var("OPENROUTER_API_KEY").ok())
.or_else(|| std::env::var("ANYLLM_API_KEY").ok())
.unwrap_or_default()
}
/// Pull prompt tokens from a JSON body or an SSE stream.
pub fn extract_prompt_tokens(text: &str, api: Api, stream: bool) -> Result<u64> {
let want = |v: &Value| -> Option<u64> {
match api {
Api::Openai => v.get("usage")?.get("prompt_tokens")?.as_u64(),
Api::Anthropic => v
.get("usage")
.or_else(|| v.get("message").and_then(|m| m.get("usage")))?
.get("input_tokens")?
.as_u64(),
}
};
if !stream {
let v: Value = serde_json::from_str(text)?;
return want(&v).context("usage field missing");
}
for payload in sse_payloads(text) {
if let Ok(v) = serde_json::from_str::<Value>(&payload) {
if let Some(n) = want(&v) {
return Ok(n);
}
}
}
anyhow::bail!("no usage in stream")
}
/// Pull assistant text from a JSON body or an SSE stream. Best-effort: returns "" if the
/// shape is unrecognized (quality just reads as low, never panics).
pub fn extract_response_text(text: &str, api: Api, stream: bool) -> String {
if !stream {
let Ok(v) = serde_json::from_str::<Value>(text) else {
return String::new();
};
return match api {
Api::Openai => v
.pointer("/choices/0/message/content")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string(),
Api::Anthropic => v
.get("content")
.and_then(|c| c.as_array())
.map(|blocks| {
blocks
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default(),
};
}
// Streaming: concatenate incremental text deltas.
let mut out = String::new();
for payload in sse_payloads(text) {
let Ok(v) = serde_json::from_str::<Value>(&payload) else {
continue;
};
match api {
Api::Openai => {
if let Some(s) = v
.pointer("/choices/0/delta/content")
.and_then(|c| c.as_str())
{
out.push_str(s);
}
}
Api::Anthropic => {
if let Some(s) = v.pointer("/delta/text").and_then(|c| c.as_str()) {
out.push_str(s);
}
}
}
}
out
}
/// Yield the JSON payload of each `data:` SSE line, skipping `[DONE]`.
pub fn sse_payloads(text: &str) -> impl Iterator<Item = String> + '_ {
text.lines().filter_map(|line| {
let payload = line.trim().strip_prefix("data:")?.trim();
(payload != "[DONE]").then(|| payload.to_string())
})
}
/// Ask an OpenAI-compatible judge model to score 1-5 how well `comp` preserves `raw`.
pub fn run_judge(
client: &reqwest::blocking::Client,
args: &Args,
raw: &str,
comp: &str,
) -> Result<u32> {
let model = args.judge_model.as_ref().context("no judge model")?;
let base = args.judge_base_url.as_deref().unwrap_or(&args.base_url);
let prompt = format!(
"You compare two AI assistant responses to the same user request. Response A is \
from the full prompt; Response B is from a compressed prompt. Score 1-5 how well B \
preserves A's meaning and quality (5 = equivalent, 1 = badly degraded). Reply with \
ONLY the integer.\n\n[A]\n{raw}\n\n[B]\n{comp}"
);
let body = json!({
"model": model,
"messages": [{ "role": "user", "content": prompt }],
"max_tokens": 4,
"temperature": 0,
});
let url = format!("{}/chat/completions", base.trim_end_matches('/'));
let key = resolve_key(args);
let mut req = client.post(&url).json(&body);
if !key.is_empty() {
req = req.bearer_auth(key);
}
let resp = req.send().context("judge send failed")?;
let text = resp.text().context("reading judge response")?;
let v: Value = serde_json::from_str(&text).context("judge response not JSON")?;
let content = v
.pointer("/choices/0/message/content")
.and_then(|c| c.as_str())
.context("judge response has no content")?;
let score = content
.chars()
.find(|c| ('1'..='5').contains(c))
.and_then(|c| c.to_digit(10))
.context("no 1-5 score in judge reply")?;
Ok(score)
}
+78
View File
@@ -0,0 +1,78 @@
use super::{build_scorer, compress, ensure_model, load_inputs, Api, Args};
use anyhow::Result;
use anyllm_optimize_core::{net_cost_delta_usd, CacheModel, CacheStrategy};
use anyllm_optimize_passes::{AnthropicStrategy, OpenAiStrategy};
/// EH-0002 bite 2: simulate the frozen-frontier policy over `--input` and print the
/// signed net USD delta (compress vs skip) the cost gate computes per row, plus a class
/// total. Fully offline (no network) — the ΔT/S the gate uses come straight out of the
/// same `optimize()` call the harness already runs, so this is not a re-derivation, it is
/// the actual decision the frontier + cost gate would make.
pub fn run_net_cost(args: &Args) -> Result<()> {
let bodies = load_inputs(args)?;
let scorer = build_scorer(args);
let (pricing, model) = match args.cost_model.unwrap_or(args.api) {
Api::Openai => (
OpenAiStrategy::default().pricing(),
OpenAiStrategy::default().model(),
),
Api::Anthropic => (
AnthropicStrategy::default().pricing(),
AnthropicStrategy::default().model(),
),
};
println!(
"{:<4} {:>9} {:>7} {:>7} {:>7} {:>14}",
"#", "frontier", "dt", "s", "apply", "net_usd"
);
let mut total_dt = 0u64;
let mut total_net = 0.0f64;
for (i, mut raw) in bodies.into_iter().enumerate() {
ensure_model(&mut raw, args);
// dt/s come from `--api`'s adapter (must match the input's wire shape, or tool/JSON
// blocks get mis-segmented — see cost_model doc comment); the apply decision is
// then re-derived under `--cost-model`'s pricing/CacheModel, since `report.applied`
// reflects `--api`'s own strategy and the two can differ.
let (_compressed, report) = compress(&raw, args, scorer.as_ref());
let dt = report.removed_tokens_est;
let s = report.rewrite_suffix_tokens;
let applies = anyllm_optimize_core::should_apply(dt, s, args.horizon, &pricing, &model);
// Only realize the delta when the gate actually applies: `applies == false` means
// the original is forwarded untouched, so the realized cost change is $0, not the
// negative "forced a no-op rewrite" value the raw formula would give for dt=0
// (which `should_apply` exists precisely to avoid ever choosing).
let net = if applies {
net_cost_delta_usd(dt, s, args.horizon, &pricing, &model)
} else {
0.0
};
total_dt += dt;
total_net += net;
println!(
"{:<4} {:>9} {:>7} {:>7} {:>7} {:>14.6}",
i, report.frontier, dt, s, applies, net
);
}
let model_name = match model {
CacheModel::ImplicitPrefix => "implicit-prefix",
CacheModel::ExplicitBreakpoints => "explicit-breakpoints",
};
println!(
"\nNET COST (horizon={}, {model_name}, input=${:.2}/Mtok cached_read=${:.2}/Mtok \
write_mult={:.2}x): total ΔT={total_dt} tokens, net_delta=${total_net:.6}",
args.horizon, pricing.input, pricing.cached_read, pricing.cache_write_mult,
);
Ok(())
}
pub fn opt_u64(v: Option<u64>) -> String {
v.map(|x| x.to_string()).unwrap_or_else(|| "-".into())
}
pub fn pct(raw: u64, comp: u64) -> f64 {
if raw == 0 {
0.0
} else {
(raw.saturating_sub(comp)) as f64 / raw as f64 * 100.0
}
}
+42 -409
View File
@@ -17,12 +17,9 @@
//! Fail-open: a network/API error on one row is reported and skipped; the process exits
//! non-zero if any row failed, but never panics.
use std::collections::HashSet;
use anyhow::{Context, Result};
use anyllm_optimize_core::{
net_cost_delta_usd, BudgetCounter, CacheModel, CacheStrategy, HeuristicBudgetCounter, Mode,
OptimizationReport, Policy, TokenScorer, UniformScorer, Workspace,
Mode, OptimizationReport, Policy, TokenScorer, UniformScorer, Workspace,
};
use anyllm_optimize_passes::adapter::{anthropic, openai};
use anyllm_optimize_passes::{
@@ -32,10 +29,22 @@ use clap::{Parser, ValueEnum};
use serde_json::{json, Value};
#[cfg(feature = "tiktoken")]
mod budget_tiktoken;
pub(crate) mod budget_tiktoken;
mod client;
mod cost;
#[cfg(test)]
mod tests;
mod token_count;
mod utils;
use client::{run_judge, send_request};
use cost::{opt_u64, pct, run_net_cost};
use token_count::{build_counter, count_local};
use utils::{jaccard, wrap_prompt};
#[derive(Clone, Copy, Debug, ValueEnum, PartialEq)]
enum Api {
pub(crate) enum Api {
Openai,
Anthropic,
}
@@ -45,71 +54,65 @@ enum Api {
name = "optimize-eval",
about = "FFEC token-savings + response-quality harness"
)]
struct Args {
pub(crate) struct Args {
/// Wire format of the target endpoint.
#[arg(long, value_enum, default_value_t = Api::Openai)]
api: Api,
pub(crate) api: Api,
/// Base URL. Ollama: http://localhost:11434/v1 ; OpenRouter: https://openrouter.ai/api/v1
#[arg(long, default_value = "http://localhost:11434/v1")]
base_url: String,
pub(crate) base_url: String,
/// Target model id.
#[arg(long)]
model: String,
pub(crate) model: String,
/// API key. Falls back to $OPENROUTER_API_KEY / $ANYLLM_API_KEY; empty for local.
#[arg(long)]
api_key: Option<String>,
pub(crate) api_key: Option<String>,
/// JSONL file: each line a request body (`{"messages":[...]}`) or a bare prompt string.
#[arg(long, conflicts_with = "prompt")]
input: Option<std::path::PathBuf>,
pub(crate) input: Option<std::path::PathBuf>,
/// A single prompt to test instead of --input.
#[arg(long)]
prompt: Option<String>,
pub(crate) prompt: Option<String>,
/// Use streaming requests (adds include_usage for OpenAI).
#[arg(long)]
stream: bool,
pub(crate) stream: bool,
/// max_tokens for the response (Anthropic requires it; also sent to OpenAI).
#[arg(long, default_value_t = 256)]
max_tokens: u64,
pub(crate) max_tokens: u64,
/// Expected remaining turns (cost-gate horizon).
#[arg(long, default_value_t = 8)]
horizon: u64,
pub(crate) horizon: u64,
/// Only compute local estimates; do not call the network (no quality signal).
#[arg(long)]
offline: bool,
pub(crate) offline: bool,
/// Print both full responses per row (raw vs compressed) for eyeballing quality.
#[arg(long)]
show_responses: bool,
pub(crate) show_responses: bool,
/// Optional OpenAI-compatible judge model. If set, scores 1-5 how well the compressed
/// response preserves the raw response's meaning/quality (adds one call per row).
#[arg(long)]
judge_model: Option<String>,
pub(crate) judge_model: Option<String>,
/// Base URL for the judge (defaults to --base-url). Must be OpenAI-compatible.
#[arg(long)]
judge_base_url: Option<String>,
pub(crate) judge_base_url: Option<String>,
/// Simulate the frozen-frontier cost gate over the input and print the signed net
/// USD delta (compress vs skip) per row plus a class total, using the CacheModel and
/// Pricing table implied by `--api` unless `--cost-model` overrides it (EH-0002 M0.3:
/// offline, no network needed).
#[arg(long)]
net_cost: bool,
pub(crate) net_cost: bool,
/// Override which provider's CacheModel/Pricing the `--net-cost` dollar math uses,
/// independent of `--api` (which must still match the input's actual wire shape — the
/// adapter mis-segments tool/content blocks if it doesn't). Defaults to `--api`.
#[arg(long, value_enum)]
cost_model: Option<Api>,
pub(crate) cost_model: Option<Api>,
/// M3.6: directory containing `model.onnx` + `tokenizer.json` for the real
/// LLMLingua2Pass ML scorer (ROADMAP D8), used in place of `UniformScorer` for every
/// message the frontier already selects. Requires building with `--features onnx`;
/// without it (or on a load failure) this falls back to `UniformScorer` with a
/// warning, per the fail-open invariant — never a hard error.
#[arg(long)]
llmlingua2_model_dir: Option<std::path::PathBuf>,
}
/// One provider response: token usage + the assistant text.
struct Reply {
prompt_tokens: u64,
text: String,
pub(crate) llmlingua2_model_dir: Option<std::path::PathBuf>,
}
fn main() -> std::process::ExitCode {
@@ -254,7 +257,11 @@ fn run(args: &Args) -> Result<u32> {
/// `UniformScorer` by default, or the real `LLMLingua2Pass` when `--llmlingua2-model-dir`
/// resolves (see `build_scorer`) — either way it is only ever invoked by `optimize()` for
/// frontier-eligible D8 targets (tool results, old RAG blocks, old assistant messages).
fn compress(raw: &Value, args: &Args, scorer: &dyn TokenScorer) -> (Value, OptimizationReport) {
pub(crate) fn compress(
raw: &Value,
args: &Args,
scorer: &dyn TokenScorer,
) -> (Value, OptimizationReport) {
let policy = Policy {
mode: Mode::Live,
horizon: args.horizon,
@@ -271,7 +278,7 @@ fn compress(raw: &Value, args: &Args, scorer: &dyn TokenScorer) -> (Value, Optim
&policy,
&OpenAiStrategy::default(),
scorer,
&HeuristicBudgetCounter::default(),
&anyllm_optimize_core::HeuristicBudgetCounter::default(),
&mut ws,
);
if let Some(r) = &res.rendered {
@@ -286,7 +293,7 @@ fn compress(raw: &Value, args: &Args, scorer: &dyn TokenScorer) -> (Value, Optim
&policy,
&AnthropicStrategy::default(),
scorer,
&HeuristicBudgetCounter::default(),
&anyllm_optimize_core::HeuristicBudgetCounter::default(),
&mut ws,
);
if let Some(r) = &res.rendered {
@@ -305,7 +312,7 @@ fn compress(raw: &Value, args: &Args, scorer: &dyn TokenScorer) -> (Value, Optim
/// that gating already lives in `optimize()`/`compress_message` (see `LLMLingua2Pass`'s
/// doc comment). Any failure to build/load falls back to `UniformScorer` with a warning
/// rather than aborting the run.
fn build_scorer(args: &Args) -> Box<dyn TokenScorer> {
pub(crate) fn build_scorer(args: &Args) -> Box<dyn TokenScorer> {
let Some(dir) = &args.llmlingua2_model_dir else {
return Box::new(UniformScorer);
};
@@ -364,339 +371,16 @@ fn fnv1a64(bytes: &[u8]) -> u64 {
.fold(OFFSET, |h, &b| (h ^ b as u64).wrapping_mul(PRIME))
}
/// EH-0002 bite 2: simulate the frozen-frontier policy over `--input` and print the
/// signed net USD delta (compress vs skip) the cost gate computes per row, plus a class
/// total. Fully offline (no network) — the ΔT/S the gate uses come straight out of the
/// same `optimize()` call the harness already runs, so this is not a re-derivation, it is
/// the actual decision the frontier + cost gate would make.
fn run_net_cost(args: &Args) -> Result<()> {
let bodies = load_inputs(args)?;
let scorer = build_scorer(args);
let (pricing, model) = match args.cost_model.unwrap_or(args.api) {
Api::Openai => (
OpenAiStrategy::default().pricing(),
OpenAiStrategy::default().model(),
),
Api::Anthropic => (
AnthropicStrategy::default().pricing(),
AnthropicStrategy::default().model(),
),
};
println!(
"{:<4} {:>9} {:>7} {:>7} {:>7} {:>14}",
"#", "frontier", "dt", "s", "apply", "net_usd"
);
let mut total_dt = 0u64;
let mut total_net = 0.0f64;
for (i, mut raw) in bodies.into_iter().enumerate() {
ensure_model(&mut raw, args);
// dt/s come from `--api`'s adapter (must match the input's wire shape, or tool/JSON
// blocks get mis-segmented — see cost_model doc comment); the apply decision is
// then re-derived under `--cost-model`'s pricing/CacheModel, since `report.applied`
// reflects `--api`'s own strategy and the two can differ.
let (_compressed, report) = compress(&raw, args, scorer.as_ref());
let dt = report.removed_tokens_est;
let s = report.rewrite_suffix_tokens;
let applies = anyllm_optimize_core::should_apply(dt, s, args.horizon, &pricing, &model);
// Only realize the delta when the gate actually applies: `applies == false` means
// the original is forwarded untouched, so the realized cost change is $0, not the
// negative "forced a no-op rewrite" value the raw formula would give for dt=0
// (which `should_apply` exists precisely to avoid ever choosing).
let net = if applies {
net_cost_delta_usd(dt, s, args.horizon, &pricing, &model)
} else {
0.0
};
total_dt += dt;
total_net += net;
println!(
"{:<4} {:>9} {:>7} {:>7} {:>7} {:>14.6}",
i, report.frontier, dt, s, applies, net
);
}
let model_name = match model {
CacheModel::ImplicitPrefix => "implicit-prefix",
CacheModel::ExplicitBreakpoints => "explicit-breakpoints",
};
println!(
"\nNET COST (horizon={}, {model_name}, input=${:.2}/Mtok cached_read=${:.2}/Mtok \
write_mult={:.2}x): total ΔT={total_dt} tokens, net_delta=${total_net:.6}",
args.horizon, pricing.input, pricing.cached_read, pricing.cache_write_mult,
);
Ok(())
}
fn ensure_model(body: &mut Value, args: &Args) {
pub(crate) fn ensure_model(body: &mut Value, args: &Args) {
body["model"] = json!(args.model);
if args.api == Api::Anthropic && body.get("max_tokens").is_none() {
body["max_tokens"] = json!(args.max_tokens);
}
}
/// M3.6: selects the counter `count_local` uses to report est_raw/est_comp. Defaults to
/// `HeuristicBudgetCounter` (bytes/3.6, provider-agnostic). When this binary is built with
/// `--features tiktoken` AND the target is OpenAI-shaped, uses the exact `o200k_base`
/// tokenizer instead — Anthropic's tokenizer is unpublished, so the heuristic remains the
/// only option there (ROADMAP risk 3). This only changes what the harness *reports*; the
/// planning counter `compress()` passes into `optimize()` is unchanged.
#[cfg_attr(not(feature = "tiktoken"), allow(unused_variables))]
fn build_counter(args: &Args) -> Box<dyn BudgetCounter> {
#[cfg(feature = "tiktoken")]
if args.api == Api::Openai {
return Box::new(budget_tiktoken::TiktokenBudgetCounter);
}
Box::new(HeuristicBudgetCounter::default())
}
/// Local token estimate: sum the budget counter over all message text content.
fn count_local(body: &Value, api: Api, counter: &dyn BudgetCounter) -> u64 {
let mut total = 0u64;
if api == Api::Anthropic {
if let Some(sys) = body.get("system") {
total += count_content(sys, counter);
}
}
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
for m in msgs {
if let Some(c) = m.get("content") {
total += count_content(c, counter);
}
}
}
total
}
fn count_content(c: &Value, counter: &dyn BudgetCounter) -> u64 {
match c {
Value::String(s) => counter.count(s),
Value::Array(parts) => parts
.iter()
.map(|p| match p.get("text").and_then(|t| t.as_str()) {
Some(t) => counter.count(t),
None => counter.count(&p.to_string()),
})
.sum(),
_ => 0,
}
}
/// Send one request and return usage + assistant text.
fn send_request(
client: &reqwest::blocking::Client,
args: &Args,
body: &Value,
base_url: &str,
api: Api,
) -> Result<Reply> {
let mut body = body.clone();
let url = match api {
Api::Openai => format!("{}/chat/completions", base_url.trim_end_matches('/')),
Api::Anthropic => format!("{}/messages", base_url.trim_end_matches('/')),
};
if args.stream {
body["stream"] = json!(true);
if api == Api::Openai {
body["stream_options"] = json!({ "include_usage": true });
}
}
let key = resolve_key(args);
let mut req = client.post(&url).json(&body);
req = match api {
Api::Openai => {
if key.is_empty() {
req
} else {
req.bearer_auth(key)
}
}
Api::Anthropic => req
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01"),
};
let resp = req.send().context("request send failed")?;
let status = resp.status();
let text = resp.text().context("reading response body")?;
if !status.is_success() {
anyhow::bail!(
"HTTP {status}: {}",
text.chars().take(300).collect::<String>()
);
}
Ok(Reply {
prompt_tokens: extract_prompt_tokens(&text, api, args.stream)
.context("could not find prompt/input token usage")?,
text: extract_response_text(&text, api, args.stream),
})
}
fn resolve_key(args: &Args) -> String {
args.api_key
.clone()
.or_else(|| std::env::var("OPENROUTER_API_KEY").ok())
.or_else(|| std::env::var("ANYLLM_API_KEY").ok())
.unwrap_or_default()
}
/// Pull prompt tokens from a JSON body or an SSE stream.
fn extract_prompt_tokens(text: &str, api: Api, stream: bool) -> Result<u64> {
let want = |v: &Value| -> Option<u64> {
match api {
Api::Openai => v.get("usage")?.get("prompt_tokens")?.as_u64(),
Api::Anthropic => v
.get("usage")
.or_else(|| v.get("message").and_then(|m| m.get("usage")))?
.get("input_tokens")?
.as_u64(),
}
};
if !stream {
let v: Value = serde_json::from_str(text)?;
return want(&v).context("usage field missing");
}
for payload in sse_payloads(text) {
if let Ok(v) = serde_json::from_str::<Value>(&payload) {
if let Some(n) = want(&v) {
return Ok(n);
}
}
}
anyhow::bail!("no usage in stream")
}
/// Pull assistant text from a JSON body or an SSE stream. Best-effort: returns "" if the
/// shape is unrecognized (quality just reads as low, never panics).
fn extract_response_text(text: &str, api: Api, stream: bool) -> String {
if !stream {
let Ok(v) = serde_json::from_str::<Value>(text) else {
return String::new();
};
return match api {
Api::Openai => v
.pointer("/choices/0/message/content")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string(),
Api::Anthropic => v
.get("content")
.and_then(|c| c.as_array())
.map(|blocks| {
blocks
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default(),
};
}
// Streaming: concatenate incremental text deltas.
let mut out = String::new();
for payload in sse_payloads(text) {
let Ok(v) = serde_json::from_str::<Value>(&payload) else {
continue;
};
match api {
Api::Openai => {
if let Some(s) = v
.pointer("/choices/0/delta/content")
.and_then(|c| c.as_str())
{
out.push_str(s);
}
}
Api::Anthropic => {
if let Some(s) = v.pointer("/delta/text").and_then(|c| c.as_str()) {
out.push_str(s);
}
}
}
}
out
}
/// Yield the JSON payload of each `data:` SSE line, skipping `[DONE]`.
fn sse_payloads(text: &str) -> impl Iterator<Item = String> + '_ {
text.lines().filter_map(|line| {
let payload = line.trim().strip_prefix("data:")?.trim();
(payload != "[DONE]").then(|| payload.to_string())
})
}
/// Word-level Jaccard similarity in [0,1]. 1.0 if both empty.
fn jaccard(a: &str, b: &str) -> f64 {
let sa: HashSet<&str> = a.split_whitespace().collect();
let sb: HashSet<&str> = b.split_whitespace().collect();
if sa.is_empty() && sb.is_empty() {
return 1.0;
}
let inter = sa.intersection(&sb).count() as f64;
let uni = sa.union(&sb).count() as f64;
if uni == 0.0 {
1.0
} else {
inter / uni
}
}
/// Ask an OpenAI-compatible judge model to score 1-5 how well `comp` preserves `raw`.
fn run_judge(
client: &reqwest::blocking::Client,
args: &Args,
raw: &str,
comp: &str,
) -> Result<u32> {
let model = args.judge_model.as_ref().context("no judge model")?;
let base = args.judge_base_url.as_deref().unwrap_or(&args.base_url);
let prompt = format!(
"You compare two AI assistant responses to the same user request. Response A is \
from the full prompt; Response B is from a compressed prompt. Score 1-5 how well B \
preserves A's meaning and quality (5 = equivalent, 1 = badly degraded). Reply with \
ONLY the integer.\n\n[A]\n{raw}\n\n[B]\n{comp}"
);
let body = json!({
"model": model,
"messages": [{ "role": "user", "content": prompt }],
"max_tokens": 4,
"temperature": 0,
});
let url = format!("{}/chat/completions", base.trim_end_matches('/'));
let key = resolve_key(args);
let mut req = client.post(&url).json(&body);
if !key.is_empty() {
req = req.bearer_auth(key);
}
let resp = req.send().context("judge send failed")?;
let text = resp.text().context("reading judge response")?;
let v: Value = serde_json::from_str(&text).context("judge response not JSON")?;
let content = v
.pointer("/choices/0/message/content")
.and_then(|c| c.as_str())
.context("judge response has no content")?;
let score = content
.chars()
.find(|c| ('1'..='5').contains(c))
.and_then(|c| c.to_digit(10))
.context("no 1-5 score in judge reply")?;
Ok(score)
}
fn opt_u64(v: Option<u64>) -> String {
v.map(|x| x.to_string()).unwrap_or_else(|| "-".into())
}
fn pct(raw: u64, comp: u64) -> f64 {
if raw == 0 {
0.0
} else {
(raw.saturating_sub(comp)) as f64 / raw as f64 * 100.0
}
}
/// Load input bodies. Each JSONL line is either a full request body (`{"messages":...}`)
/// or a bare JSON string (treated as one user message).
fn load_inputs(args: &Args) -> Result<Vec<Value>> {
pub(crate) fn load_inputs(args: &Args) -> Result<Vec<Value>> {
if let Some(p) = &args.prompt {
return Ok(vec![wrap_prompt(p)]);
}
@@ -721,54 +405,3 @@ fn load_inputs(args: &Args) -> Result<Vec<Value>> {
}
Ok(out)
}
fn wrap_prompt(p: &str) -> Value {
json!({ "messages": [ { "role": "user", "content": p } ] })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jaccard_bounds() {
assert_eq!(jaccard("", ""), 1.0);
assert_eq!(jaccard("a b c", "a b c"), 1.0);
assert!((jaccard("a b", "b c") - 1.0 / 3.0).abs() < 1e-9);
assert_eq!(jaccard("a", "z"), 0.0);
}
#[test]
fn extract_openai_nonstream() {
let body =
r#"{"choices":[{"message":{"content":"hello there"}}],"usage":{"prompt_tokens":42}}"#;
assert_eq!(extract_prompt_tokens(body, Api::Openai, false).unwrap(), 42);
assert_eq!(
extract_response_text(body, Api::Openai, false),
"hello there"
);
}
#[test]
fn extract_anthropic_nonstream() {
let body = r#"{"content":[{"type":"text","text":"hi"},{"type":"text","text":" world"}],"usage":{"input_tokens":7}}"#;
assert_eq!(
extract_prompt_tokens(body, Api::Anthropic, false).unwrap(),
7
);
assert_eq!(
extract_response_text(body, Api::Anthropic, false),
"hi world"
);
}
#[test]
fn extract_openai_stream_text_and_usage() {
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"foo\"}}]}\n\
data: {\"choices\":[{\"delta\":{\"content\":\"bar\"}}]}\n\
data: {\"choices\":[],\"usage\":{\"prompt_tokens\":11}}\n\
data: [DONE]\n";
assert_eq!(extract_prompt_tokens(sse, Api::Openai, true).unwrap(), 11);
assert_eq!(extract_response_text(sse, Api::Openai, true), "foobar");
}
}
@@ -0,0 +1,43 @@
use super::{client::*, utils::*, Api};
#[test]
fn jaccard_bounds() {
assert_eq!(jaccard("", ""), 1.0);
assert_eq!(jaccard("a b c", "a b c"), 1.0);
assert!((jaccard("a b", "b c") - 1.0 / 3.0).abs() < 1e-9);
assert_eq!(jaccard("a", "z"), 0.0);
}
#[test]
fn extract_openai_nonstream() {
let body =
r#"{"choices":[{"message":{"content":"hello there"}}],"usage":{"prompt_tokens":42}}"#;
assert_eq!(extract_prompt_tokens(body, Api::Openai, false).unwrap(), 42);
assert_eq!(
extract_response_text(body, Api::Openai, false),
"hello there"
);
}
#[test]
fn extract_anthropic_nonstream() {
let body = r#"{"content":[{"type":"text","text":"hi"},{"type":"text","text":" world"}],"usage":{"input_tokens":7}}"#;
assert_eq!(
extract_prompt_tokens(body, Api::Anthropic, false).unwrap(),
7
);
assert_eq!(
extract_response_text(body, Api::Anthropic, false),
"hi world"
);
}
#[test]
fn extract_openai_stream_text_and_usage() {
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"foo\"}}]}\n\
data: {\"choices\":[{\"delta\":{\"content\":\"bar\"}}]}\n\
data: {\"choices\":[],\"usage\":{\"prompt_tokens\":11}}\n\
data: [DONE]\n";
assert_eq!(extract_prompt_tokens(sse, Api::Openai, true).unwrap(), 11);
assert_eq!(extract_response_text(sse, Api::Openai, true), "foobar");
}
@@ -0,0 +1,50 @@
use super::{Api, Args};
use anyllm_optimize_core::{BudgetCounter, HeuristicBudgetCounter};
use serde_json::Value;
/// M3.6: selects the counter `count_local` uses to report est_raw/est_comp. Defaults to
/// `HeuristicBudgetCounter` (bytes/3.6, provider-agnostic). When this binary is built with
/// `--features tiktoken` AND the target is OpenAI-shaped, uses the exact `o200k_base`
/// tokenizer instead — Anthropic's tokenizer is unpublished, so the heuristic remains the
/// only option there (ROADMAP risk 3). This only changes what the harness *reports*; the
/// planning counter `compress()` passes into `optimize()` is unchanged.
#[cfg_attr(not(feature = "tiktoken"), allow(unused_variables))]
pub fn build_counter(args: &Args) -> Box<dyn BudgetCounter> {
#[cfg(feature = "tiktoken")]
if args.api == Api::Openai {
return Box::new(super::budget_tiktoken::TiktokenBudgetCounter);
}
Box::new(HeuristicBudgetCounter::default())
}
/// Local token estimate: sum the budget counter over all message text content.
pub fn count_local(body: &Value, api: Api, counter: &dyn BudgetCounter) -> u64 {
let mut total = 0u64;
if api == Api::Anthropic {
if let Some(sys) = body.get("system") {
total += count_content(sys, counter);
}
}
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
for m in msgs {
if let Some(c) = m.get("content") {
total += count_content(c, counter);
}
}
}
total
}
pub fn count_content(c: &Value, counter: &dyn BudgetCounter) -> u64 {
match c {
Value::String(s) => counter.count(s),
Value::Array(parts) => parts
.iter()
.map(|p| match p.get("text").and_then(|t| t.as_str()) {
Some(t) => counter.count(t),
None => counter.count(&p.to_string()),
})
.sum(),
_ => 0,
}
}
@@ -0,0 +1,22 @@
use serde_json::{json, Value};
use std::collections::HashSet;
/// Word-level Jaccard similarity in [0,1]. 1.0 if both empty.
pub fn jaccard(a: &str, b: &str) -> f64 {
let sa: HashSet<&str> = a.split_whitespace().collect();
let sb: HashSet<&str> = b.split_whitespace().collect();
if sa.is_empty() && sb.is_empty() {
return 1.0;
}
let inter = sa.intersection(&sb).count() as f64;
let uni = sa.union(&sb).count() as f64;
if uni == 0.0 {
1.0
} else {
inter / uni
}
}
pub fn wrap_prompt(p: &str) -> Value {
json!({ "messages": [ { "role": "user", "content": p } ] })
}
+1 -1
View File
@@ -1,6 +1,6 @@
# anyllm_providers
Provider and model catalog for the [anyllm-proxy](https://github.com/whit3rabbit/llm-translate-api) workspace.
Provider and model catalog.
## What this crate is
File diff suppressed because one or more lines are too long
+14 -755
View File
@@ -1,758 +1,17 @@
// Mirrors the JSON shapes returned by /admin/api/* endpoints.
// Keep in sync with Rust structs in crates/proxy/src/admin/state.rs and routes/.
/** Represents the status and configuration details of the proxy server. */
export interface ProxyStatus {
/** True if the proxy backend has been configured. */
configured: boolean
/** The port number the proxy listens on for client requests. */
proxy_port: number
/** Whether the proxy is currently running and accepting connections. */
proxy_running: boolean
}
/** Represents real-time usage and performance metrics for the proxy. */
export interface Metrics {
/** The total number of requests received by the proxy. */
total_requests: number
/** The number of successful requests. */
successful_requests: number
/** The number of failed requests. */
failed_requests: number
/** The current request rate per minute. */
requests_per_minute: number
/** The p50 latency in milliseconds, if available. */
p50_latency_ms: number | null
/** The p95 latency in milliseconds, if available. */
p95_latency_ms: number | null
/** The percentage rate of request failures. */
error_rate: number
/** The number of streaming connection requests started. */
streams_started: number
/** The number of streaming connections completed. */
streams_completed: number
/** The number of streaming connections that failed. */
streams_failed: number
/** The number of streaming connections disconnected by the client. */
streams_client_disconnected: number
/** Requests where pxpipe text-to-image compression fired. */
pxpipe_compressed_total: number
/** Total PNG image blocks pxpipe emitted. */
pxpipe_images_total: number
/** Total source chars pxpipe replaced with images. */
pxpipe_imaged_chars_total: number
/** Requests where RTK tool-output compression fired. */
rtk_compressed_total: number
/** Total tool-result payloads RTK rewrote. */
rtk_blocks_total: number
/** Total source chars RTK removed from tool output. */
rtk_saved_chars_total: number
}
/** A single request transaction log entry. */
export interface RequestLogEntry {
/** Unique transaction ID. */
request_id: string
/** ISO 8601 timestamp when the request was made. */
timestamp: string
/** The backend model provider targeted. */
backend: string
/** The model identifier requested by the client. */
model_requested: string | null
/** The actual model identifier routed to on the backend. */
model_mapped: string | null
/** HTTP response status code. */
status_code: number
/** Request duration in milliseconds. */
latency_ms: number
/** Number of input tokens processed. */
input_tokens: number | null
/** Number of output tokens generated. */
output_tokens: number | null
/** Whether the request was streamed. */
is_streaming: boolean
/** The raw error message returned by the backend, if any. */
error_message: string | null
/** Normalized error class/category. */
error_kind: string | null
/** The virtual key ID used for authorization, if any. */
key_id: number | null
/** Calculated transaction cost in USD. */
cost_usd: number | null
}
/** Paginated list response for request log queries. */
export interface RequestsResponse {
/** The page of request log entries. */
requests: RequestLogEntry[]
/** Maximum number of items returned. */
limit: number
/** Pagination offset. */
offset: number
/** True if more records are available. */
has_more: boolean
}
/** Represents a virtual API key configuration and its associated limits. */
export interface VirtualKey {
/** Unique database ID. */
id: number
/** The prefix of the key shown to users. */
key_prefix: string
/** Optional descriptive note. */
description: string | null
/** ISO 8601 creation timestamp. */
created_at: string
/** ISO 8601 expiration timestamp, if set. */
expires_at: string | null
/** ISO 8601 revocation timestamp, if revoked. */
revoked_at: string | null
/** Spend limit in USD. */
spend_limit: number | null
/** Monthly budget limit in USD. */
max_budget_usd: number | null
/** Duration of the budget period (e.g. 'monthly'). */
budget_duration: string | null
/** Requests-per-minute limit. */
rpm_limit: number | null
/** Tokens-per-minute limit. */
tpm_limit: number | null
/** Total spend in USD across the key lifetime. */
total_spend: number
/** Total count of requests made with this key. */
total_requests: number
/** Total count of tokens processed. */
total_tokens: number
/** ISO 8601 reset timestamp for the current budget period. */
period_reset_at: string | null
/** List of model names this key is restricted to, if any. */
allowed_models: string[] | null
/** List of route names this key is restricted to, if any. */
allowed_routes: string[] | null
/** Active status of the key. */
status: 'active' | 'revoked' | 'expired' | 'override'
/** Spend in USD during the current budget period. */
period_spend_usd: number
}
/** Spent token and requests details for a virtual key. */
export interface KeySpend {
/** Unique key database ID. */
id: number
/** Total spend in USD. */
total_spend: number
/** Total request count. */
total_requests: number
/** Total token count. */
total_tokens: number
}
/** Represents a backend endpoint model and its metrics. */
export interface Backend {
/** Name of the backend. */
name: string
/** Model mapped for heavy workloads. */
big_model: string
/** Model mapped for light workloads. */
small_model: string
/** Request outcome counters. */
metrics: {
requests_total: number
requests_success: number
requests_error: number
}
}
/** Represents a single configuration entry key-value pair. */
export interface ConfigEntry {
/** Configuration key. */
key: string
/** Configuration value. */
value: string
/** Timestamp when updated. */
updated_at: string
}
/** Represents the full system configuration and environment variables. */
export interface ConfigResponse {
/** List of config database overrides. */
entries: ConfigEntry[]
/** Server environment variables map. */
env: Record<string, string>
/** System logging level. */
log_level: string
/** Whether requests and responses are logged. */
log_bodies: boolean
/** Whether credentials are redacted from logs. */
redact_secrets: boolean
/** Whether to attempt to repair broken thinking blocks in Anthropic streams. */
anthropic_thinking_repair: boolean
/** True if pxpipe image compression is enabled. */
pxpipe_compress: boolean
/** CSV of model bases in pxpipe compression scope. */
pxpipe_models: string
/** Vision-capable Claude models offered as per-model scope toggles. */
pxpipe_available_models: string[]
/** Whether RTK tool-output compression is active. */
rtk_compress: boolean
/** CSV of model bases in RTK compression scope (empty = all models). */
rtk_models: string
/** Whether to pass client authorization headers to backend. */
forward_client_auth: boolean
/** Tool guardrail mode configured. */
tool_guardrail_mode: string
/** Prompt-compression optimizer mode: 'off' | 'shadow' | 'live'. */
optimizer_mode: string
/** Mappings of backend names to their configured big/small models. */
backends: Record<string, { big_model: string; small_model: string }>
/** List of keys whose overrides are active. */
overridden_keys: string[]
}
/** Status of the optional LLMLingua-2 ONNX model artifact (optimizer scorer tier). */
export interface OptimizerModelStatus {
/** Proxy built with the `optimizer-onnx` feature. When false the tier is inert. */
compiled_in: boolean
/** Verified model artifact is present on disk. */
present: boolean
/** A download+verify is currently in flight. */
downloading: boolean
/** Last download error, if any. */
error: string | null
/** Pinned sha256 the artifact is verified against. */
sha256: string
/** Expected download size in bytes. */
size_bytes: number
}
/** A time-series data point for observability charts. */
export interface ObservabilityPoint {
/** UNIX timestamp representing the bucket start time. */
bucket_start: number
/** Total requests in this bucket. */
requests: number
/** Total errors in this bucket. */
errors: number
/** Total input tokens processed in this bucket. */
input_tokens: number
/** Total output tokens generated in this bucket. */
output_tokens: number
/** Calculated cost in USD in this bucket. */
cost_usd: number
}
/** Summary of error occurrences on a backend. */
export interface ObservabilityFailure {
/** Class or classification of error. */
error_kind: string
/** Occurrence count. */
count: number
/** Last occurrence timestamp. */
last_seen: string
/** Last error message received. */
last_message: string
}
/** Represents a single trace event in the observability timeline. */
export interface ObservabilityTimeline {
/** Transaction request ID. */
request_id: string
/** ISO 8601 request timestamp. */
timestamp: string
/** Backend provider routed to. */
backend: string
/** Model mapped to. */
model: string
/** Latency in milliseconds. */
latency_ms: number
/** Request outcome status. */
status: string
}
/** Observability stats summary response. */
export interface ObservabilityResponse {
/** The time window in hours for metrics. */
window_hours: number
/** The name of the backend. */
backend: string
/** Total requests within the window. */
total_requests: number
/** Total errors within the window. */
total_errors: number
/** Total input tokens processed. */
total_input_tokens: number
/** Total output tokens generated. */
total_output_tokens: number
/** Total cost in USD. */
total_cost_usd: number
/** Historical metrics series points. */
series: ObservabilityPoint[]
/** Error breakdown summary. */
failures: ObservabilityFailure[]
/** Timeline events. */
timeline: ObservabilityTimeline[]
}
/** Represents a single model configuration entry. */
export interface ModelEntry {
/** The unique name/identifier of the model. */
model_name: string
/** The number of active deployments for this model. */
deployments: number
}
/** Response shape for models list requests. */
export interface ModelsResponse {
/** List of model configurations. */
models: ModelEntry[]
/** Router strategy (e.g. priority, failover). */
strategy: string | null
/** Optional descriptive note. */
note?: string
}
/** Represents a model discovered from a backend API. */
export interface DiscoveredModel {
/** The model identifier. */
id: string
/** Optional human-readable name. */
name: string | null
}
/** Response containing discovered models. */
export interface DiscoverResponse {
/** List of discovered models. */
models: DiscoveredModel[]
/** Source or method used for discovery. */
source: string
/** True if authorization was used. */
auth_used: boolean
}
/** A single audit log entry tracking administrative actions. */
export interface AuditEntry {
/** Unique database ID. */
id: number
/** ISO 8601 action timestamp. */
timestamp: string
/** The action performed. */
action: string
/** Type of target resource affected. */
target_type: string
/** ID of target resource affected. */
target_id: string | null
/** Detailed changes/payload. */
detail: string | null
/** Source IP of the requester. */
source_ip: string | null
}
/** Paginated list response for audit log queries. */
export interface AuditResponse {
/** List of audit log entries. */
entries: AuditEntry[]
/** Maximum number of items returned. */
limit: number
/** Pagination offset. */
offset: number
/** True if more records are available. */
has_more: boolean
}
// --- Traffic tab (new) ---
/** Real-time metrics for a specific API route. */
export interface RouteMetrics {
/** The request path/route. */
path: string
/** Number of requests per minute. */
requests_per_min: number
/** Percentage rate of failures. */
error_rate: number
/** Average latency in milliseconds. */
avg_latency_ms: number
/** The p95 latency in milliseconds. */
p95_latency_ms: number
/** Total number of requests. */
total_requests: number
}
/** Time-series requests count point for a route. */
export interface TrafficSeriesPoint {
/** UNIX timestamp representing the bucket start time. */
bucket_start: number
/** API path. */
path: string
/** Request count. */
requests: number
}
/** Response containing traffic analytics. */
export interface TrafficResponse {
/** Time window in hours. */
window_hours: number
/** Metrics per route. */
routes: RouteMetrics[]
/** Time-series data points. */
series: TrafficSeriesPoint[]
}
// --- Uptime tab (new) ---
/** A single day's uptime status. */
export interface HistoryDay {
/** Date in YYYY-MM-DD format. */
date: string
/** Availability status. */
status: 'up' | 'down' | 'degraded' | 'no-data'
}
/** Uptime info for the proxy itself. */
export interface ProxyUptimeInfo {
/** UNIX timestamp when the proxy started. */
started_at: number
/** Uptime percentage over the last 30 days. */
uptime_pct_30d: number
/** Daily uptime history. */
history: HistoryDay[]
}
/** Uptime info for a backend endpoint. */
export interface BackendUptimeInfo {
/** Name of the backend. */
name: string
/** Current connection status. */
status: 'up' | 'down' | 'unknown'
/** UNIX timestamp of the last health check. */
last_checked_at: number | null
/** Last checked latency in milliseconds. */
last_latency_ms: number | null
/** 30-day uptime percentage. */
uptime_pct_30d: number
/** Daily uptime history. */
history: HistoryDay[]
}
/** Full uptime summary response. */
export interface UptimeResponse {
/** Proxy server uptime details. */
proxy: ProxyUptimeInfo
/** Backend endpoints uptime details. */
backends: BackendUptimeInfo[]
}
// --- Env file import / export ---
/** Warning message generated during env import. */
export interface EnvWarning {
/** Affected line number. */
line: number | null
/** Affected environment variable key. */
key: string | null
/** Warning message. */
message: string
}
/** Response detailing the result of importing an env file. */
export interface EnvImportResponse {
/** Count of imported variables applied. */
applied: number
/** Non-fatal warnings generated. */
warnings: EnvWarning[]
}
/** Error payload for env file imports. */
export interface EnvImportError {
/** Hard/blocking validation errors. */
hard_errors: string[]
/** Non-fatal warnings. */
warnings: EnvWarning[]
}
// --- Provider catalog ---
/** Details of a provider available in the LiteLLM catalog. */
export interface CatalogProvider {
/** Unique provider identifier. */
id: string
/** User-facing display name. */
display_name: string
/** Keep for backwards compatibility. */
name?: string
/** Communication protocol. */
protocol: string
/** Auth mechanism. */
auth: string
/** Proxy integration status. */
status: 'implemented' | 'wired' | 'stub'
/** Default base URL. */
default_base_url: string
/** Expected environment variables for API keys. */
env_vars: string[]
/** LiteLLM prefix string. */
litellm_prefix: string
/** Supported capabilities. */
capabilities: {
chat_completions: boolean
streaming: boolean
tool_use: boolean
embeddings: boolean
vision: boolean
batch: boolean
}
/** Total count of models. */
model_count: number
/** Count of models currently cached. */
cached_model_count: number
/** UNIX timestamp of the last cache refresh. */
last_refreshed: number | null
}
// --- Managed backends ---
/** Represents a backend credentials deployment managed by the admin. */
export interface ManagedBackend {
/** Unique database ID. */
id: string
/** Unique backend name. */
name: string
/** Provider ID. */
provider_id: string
/** True if the API key is configured. */
api_key_set: boolean
/** True if AWS credentials are set (for AWS Bedrock, etc.). */
aws_creds_set: boolean
/** Base URL of the API endpoint. */
api_base: string | null
/** Optional deployment name (e.g. Azure deployment name). */
deployment: string | null
/** Optional API version. */
api_version: string | null
/** Optional cloud project ID. */
project: string | null
/** Optional cloud region (e.g. AWS/Azure region). */
region: string | null
/** Rate limit: requests per minute override. */
rpm: number | null
/** Rate limit: tokens per minute override. */
tpm: number | null
/** True if this backend is enabled. */
enabled: boolean
/** ISO 8601 creation timestamp. */
created_at: string
/** ISO 8601 last update timestamp. */
updated_at: string
}
/** Response containing managed backends. */
export interface ManagedBackendsResponse {
/** List of managed backends. */
backends: ManagedBackend[]
}
/** Request payload for creating a new managed backend. */
export interface CreateManagedBackendRequest {
/** Unique name for the backend. */
name: string
/** Catalog provider ID. */
provider_id: string
/** API key credential string. */
api_key?: string
/** API base URL. */
api_base?: string
/** API deployment name. */
deployment?: string
/** API version. */
api_version?: string
/** Cloud project ID. */
project?: string
/** Cloud region. */
region?: string
/** AWS access key ID. */
aws_access_key_id?: string
/** AWS secret access key. */
aws_secret_access_key?: string
/** AWS session token. */
aws_session_token?: string
/** Requests per minute limit. */
rpm?: number
/** Tokens per minute limit. */
tpm?: number
/** Active status toggle. */
enabled?: boolean
}
/** Request payload for updating an existing managed backend. */
export type UpdateManagedBackendRequest = Partial<Omit<CreateManagedBackendRequest, 'name'>>
// --- Routes ---
/** Represents a configured API routing definition. */
export interface Route {
/** Unique route database ID. */
id: string
/** Route name. */
name: string
/** Optional description. */
description: string | null
/** Load balancing strategy. */
strategy: string
/** Requests per minute limit. */
rpm: number | null
/** Tokens per minute limit. */
tpm: number | null
/** Cost budget in USD. */
budget_usd: number | null
/** Active status toggle. */
enabled: boolean
/** Tool guardrail mode override. */
guardrail_mode: string | null
/** pxpipe image compression toggle override. */
pxpipe_compress: boolean | null
/** pxpipe models CSV override. */
pxpipe_models: string | null
/** Secret redaction toggle override. */
redact_secrets: boolean | null
/** Position order in the router. */
position: number
/** Count of providers assigned to this route. */
provider_count: number
/** ISO 8601 creation timestamp. */
created_at: string
/** ISO 8601 last update timestamp. */
updated_at: string
}
/** Response containing routing configs. */
export interface RoutesResponse {
/** List of routes. */
routes: Route[]
}
/** Request payload to create a new route. */
export interface CreateRouteRequest {
/** Unique route name. */
name: string
/** Route description. */
description?: string
/** Load balancing strategy. */
strategy?: string
/** Requests per minute limit. */
rpm?: number
/** Tokens per minute limit. */
tpm?: number
/** Cost budget in USD. */
budget_usd?: number
/** Active status toggle. */
enabled?: boolean
/** Tool guardrail mode override. */
guardrail_mode?: string | null
/** pxpipe image compression toggle override. */
pxpipe_compress?: boolean | null
/** pxpipe models CSV override. */
pxpipe_models?: string | null
/** Secret redaction toggle override. */
redact_secrets?: boolean | null
/** Position order. */
position?: number
}
/** Request payload to update an existing route. */
export type UpdateRouteRequest = Partial<CreateRouteRequest>
/** Represents a provider mapped to a route. */
export interface RouteProvider {
/** Unique assignment ID. */
id: string
/** Target route ID. */
route_id: string
/** Backend ID. */
backend_id: string
/** Name of the backend. */
backend_name: string
/** Catalog provider ID. */
provider_id: string
/** Supported models list. */
models: string[]
/** Evaluation priority. */
priority: number
/** Active status. */
enabled: boolean
}
/** Response containing route provider mappings. */
export interface RouteProvidersResponse {
/** List of mappings. */
providers: RouteProvider[]
}
/** Request payload to add a provider assignment to a route. */
export interface AddRouteProviderRequest {
/** Target backend ID. */
backend_id: string
/** Supported models list. */
models?: string[]
/** Priority position. */
priority?: number
/** Active status. */
enabled?: boolean
}
/** Request payload to update a provider assignment configuration. */
export interface UpdateRouteProviderRequest {
/** Supported models list. */
models?: string[]
/** Priority position. */
priority?: number
/** Active status. */
enabled?: boolean
}
/** Request payload to reorder provider mappings. */
export interface ReorderRouteProvidersRequest {
/** Ordered list of provider assignment IDs. */
provider_ids: string[]
}
// --- WebSocket events ---
/** Types of events sent over the admin WebSocket channel. */
export type WSEvent =
| { type: 'request_completed'; data: RequestLogEntry }
| { type: 'metrics_snapshot'; data: Metrics }
| { type: 'config_changed'; data: { key: string; value: string } }
| { type: 'backend_health_changed'; data: { backend: string; status: 'up' | 'down'; latency_ms: number | null } }
/** Details of a model available in the LiteLLM catalog. */
export interface CatalogModel {
/** Unique model identifier. */
id: string
/** Maximum context window size in tokens. */
context_window: number
/** Maximum output tokens. */
max_output_tokens: number
/** Availability status of the model. */
status: 'available' | 'deprecated' | 'stub'
/** Capabilities flags. */
capabilities: {
streaming: boolean
tool_use: boolean
vision: boolean
extended_thinking: boolean
}
/** Pricing per million tokens. */
pricing: {
input_per_million_tokens: number
output_per_million_tokens: number
} | null
}
/** Response containing model lists. */
export interface CatalogModelsResponse {
/** Catalog provider ID. */
provider_id: string
/** True if this provider has models. */
has_models: boolean
/** List of models. */
models: CatalogModel[]
}
export * from './types/proxy'
export * from './types/metrics'
export * from './types/requests'
export * from './types/keys'
export * from './types/backends'
export * from './types/config'
export * from './types/optimizer'
export * from './types/models'
export * from './types/audit'
export * from './types/uptime'
export * from './types/env'
export * from './types/catalog'
export * from './types/routes'
export * from './types/ws'
@@ -0,0 +1,29 @@
/** A single audit log entry tracking administrative actions. */
export interface AuditEntry {
/** Unique database ID. */
id: number
/** ISO 8601 action timestamp. */
timestamp: string
/** The action performed. */
action: string
/** Type of target resource affected. */
target_type: string
/** ID of target resource affected. */
target_id: string | null
/** Detailed changes/payload. */
detail: string | null
/** Source IP of the requester. */
source_ip: string | null
}
/** Paginated list response for audit log queries. */
export interface AuditResponse {
/** List of audit log entries. */
entries: AuditEntry[]
/** Maximum number of items returned. */
limit: number
/** Pagination offset. */
offset: number
/** True if more records are available. */
has_more: boolean
}
@@ -0,0 +1,90 @@
/** Represents a backend endpoint model and its metrics. */
export interface Backend {
/** Name of the backend. */
name: string
/** Model mapped for heavy workloads. */
big_model: string
/** Model mapped for light workloads. */
small_model: string
/** Request outcome counters. */
metrics: {
requests_total: number
requests_success: number
requests_error: number
}
}
/** Represents a backend credentials deployment managed by the admin. */
export interface ManagedBackend {
/** Unique database ID. */
id: string
/** Unique backend name. */
name: string
/** Provider ID. */
provider_id: string
/** True if the API key is configured. */
api_key_set: boolean
/** True if AWS credentials are set (for AWS Bedrock, etc.). */
aws_creds_set: boolean
/** Base URL of the API endpoint. */
api_base: string | null
/** Optional deployment name (e.g. Azure deployment name). */
deployment: string | null
/** Optional API version. */
api_version: string | null
/** Optional cloud project ID. */
project: string | null
/** Optional cloud region (e.g. AWS/Azure region). */
region: string | null
/** Rate limit: requests per minute override. */
rpm: number | null
/** Rate limit: tokens per minute override. */
tpm: number | null
/** True if this backend is enabled. */
enabled: boolean
/** ISO 8601 creation timestamp. */
created_at: string
/** ISO 8601 last update timestamp. */
updated_at: string
}
/** Response containing managed backends. */
export interface ManagedBackendsResponse {
/** List of managed backends. */
backends: ManagedBackend[]
}
/** Request payload for creating a new managed backend. */
export interface CreateManagedBackendRequest {
/** Unique name for the backend. */
name: string
/** Catalog provider ID. */
provider_id: string
/** API key credential string. */
api_key?: string
/** API base URL. */
api_base?: string
/** API deployment name. */
deployment?: string
/** API version. */
api_version?: string
/** Cloud project ID. */
project?: string
/** Cloud region. */
region?: string
/** AWS access key ID. */
aws_access_key_id?: string
/** AWS secret access key. */
aws_secret_access_key?: string
/** AWS session token. */
aws_session_token?: string
/** Requests per minute limit. */
rpm?: number
/** Tokens per minute limit. */
tpm?: number
/** Active status toggle. */
enabled?: boolean
}
/** Request payload for updating an existing managed backend. */
export type UpdateManagedBackendRequest = Partial<Omit<CreateManagedBackendRequest, 'name'>>
@@ -0,0 +1,36 @@
/** Details of a provider available in the LiteLLM catalog. */
export interface CatalogProvider {
/** Unique provider identifier. */
id: string
/** User-facing display name. */
display_name: string
/** Keep for backwards compatibility. */
name?: string
/** Communication protocol. */
protocol: string
/** Auth mechanism. */
auth: string
/** Proxy integration status. */
status: 'implemented' | 'wired' | 'stub'
/** Default base URL. */
default_base_url: string
/** Expected environment variables for API keys. */
env_vars: string[]
/** LiteLLM prefix string. */
litellm_prefix: string
/** Supported capabilities. */
capabilities: {
chat_completions: boolean
streaming: boolean
tool_use: boolean
embeddings: boolean
vision: boolean
batch: boolean
}
/** Total count of models. */
model_count: number
/** Count of models currently cached. */
cached_model_count: number
/** UNIX timestamp of the last cache refresh. */
last_refreshed: number | null
}
@@ -0,0 +1,45 @@
/** Represents a single configuration entry key-value pair. */
export interface ConfigEntry {
/** Configuration key. */
key: string
/** Configuration value. */
value: string
/** Timestamp when updated. */
updated_at: string
}
/** Represents the full system configuration and environment variables. */
export interface ConfigResponse {
/** List of config database overrides. */
entries: ConfigEntry[]
/** Server environment variables map. */
env: Record<string, string>
/** System logging level. */
log_level: string
/** Whether requests and responses are logged. */
log_bodies: boolean
/** Whether credentials are redacted from logs. */
redact_secrets: boolean
/** Whether to attempt to repair broken thinking blocks in Anthropic streams. */
anthropic_thinking_repair: boolean
/** True if pxpipe image compression is enabled. */
pxpipe_compress: boolean
/** CSV of model bases in pxpipe compression scope. */
pxpipe_models: string
/** Vision-capable Claude models offered as per-model scope toggles. */
pxpipe_available_models: string[]
/** Whether RTK tool-output compression is active. */
rtk_compress: boolean
/** CSV of model bases in RTK compression scope (empty = all models). */
rtk_models: string
/** Whether to pass client authorization headers to backend. */
forward_client_auth: boolean
/** Tool guardrail mode configured. */
tool_guardrail_mode: string
/** Prompt-compression optimizer mode: 'off' | 'shadow' | 'live'. */
optimizer_mode: string
/** Mappings of backend names to their configured big/small models. */
backends: Record<string, { big_model: string; small_model: string }>
/** List of keys whose overrides are active. */
overridden_keys: string[]
}
@@ -0,0 +1,25 @@
/** Warning message generated during env import. */
export interface EnvWarning {
/** Affected line number. */
line: number | null
/** Affected environment variable key. */
key: string | null
/** Warning message. */
message: string
}
/** Response detailing the result of importing an env file. */
export interface EnvImportResponse {
/** Count of imported variables applied. */
applied: number
/** Non-fatal warnings generated. */
warnings: EnvWarning[]
}
/** Error payload for env file imports. */
export interface EnvImportError {
/** Hard/blocking validation errors. */
hard_errors: string[]
/** Non-fatal warnings. */
warnings: EnvWarning[]
}
@@ -0,0 +1,53 @@
/** Represents a virtual API key configuration and its associated limits. */
export interface VirtualKey {
/** Unique database ID. */
id: number
/** The prefix of the key shown to users. */
key_prefix: string
/** Optional descriptive note. */
description: string | null
/** ISO 8601 creation timestamp. */
created_at: string
/** ISO 8601 expiration timestamp, if set. */
expires_at: string | null
/** ISO 8601 revocation timestamp, if revoked. */
revoked_at: string | null
/** Spend limit in USD. */
spend_limit: number | null
/** Monthly budget limit in USD. */
max_budget_usd: number | null
/** Duration of the budget period (e.g. 'monthly'). */
budget_duration: string | null
/** Requests-per-minute limit. */
rpm_limit: number | null
/** Tokens-per-minute limit. */
tpm_limit: number | null
/** Total spend in USD across the key lifetime. */
total_spend: number
/** Total count of requests made with this key. */
total_requests: number
/** Total count of tokens processed. */
total_tokens: number
/** ISO 8601 reset timestamp for the current budget period. */
period_reset_at: string | null
/** List of model names this key is restricted to, if any. */
allowed_models: string[] | null
/** List of route names this key is restricted to, if any. */
allowed_routes: string[] | null
/** Active status of the key. */
status: 'active' | 'revoked' | 'expired' | 'override'
/** Spend in USD during the current budget period. */
period_spend_usd: number
}
/** Spent token and requests details for a virtual key. */
export interface KeySpend {
/** Unique key database ID. */
id: number
/** Total spend in USD. */
total_spend: number
/** Total request count. */
total_requests: number
/** Total token count. */
total_tokens: number
}
@@ -0,0 +1,141 @@
/** Represents real-time usage and performance metrics for the proxy. */
export interface Metrics {
/** The total number of requests received by the proxy. */
total_requests: number
/** The number of successful requests. */
successful_requests: number
/** The number of failed requests. */
failed_requests: number
/** The current request rate per minute. */
requests_per_minute: number
/** The p50 latency in milliseconds, if available. */
p50_latency_ms: number | null
/** The p95 latency in milliseconds, if available. */
p95_latency_ms: number | null
/** The percentage rate of request failures. */
error_rate: number
/** The number of streaming connection requests started. */
streams_started: number
/** The number of streaming connections completed. */
streams_completed: number
/** The number of streaming connections that failed. */
streams_failed: number
/** The number of streaming connections disconnected by the client. */
streams_client_disconnected: number
/** Requests where pxpipe text-to-image compression fired. */
pxpipe_compressed_total: number
/** Total PNG image blocks pxpipe emitted. */
pxpipe_images_total: number
/** Total source chars pxpipe replaced with images. */
pxpipe_imaged_chars_total: number
/** Requests where RTK tool-output compression fired. */
rtk_compressed_total: number
/** Total tool-result payloads RTK rewrote. */
rtk_blocks_total: number
/** Total source chars RTK removed from tool output. */
rtk_saved_chars_total: number
}
/** A time-series data point for observability charts. */
export interface ObservabilityPoint {
/** UNIX timestamp representing the bucket start time. */
bucket_start: number
/** Total requests in this bucket. */
requests: number
/** Total errors in this bucket. */
errors: number
/** Total input tokens processed in this bucket. */
input_tokens: number
/** Total output tokens generated in this bucket. */
output_tokens: number
/** Calculated cost in USD in this bucket. */
cost_usd: number
}
/** Summary of error occurrences on a backend. */
export interface ObservabilityFailure {
/** Class or classification of error. */
error_kind: string
/** Occurrence count. */
count: number
/** Last occurrence timestamp. */
last_seen: string
/** Last error message received. */
last_message: string
}
/** Represents a single trace event in the observability timeline. */
export interface ObservabilityTimeline {
/** Transaction request ID. */
request_id: string
/** ISO 8601 request timestamp. */
timestamp: string
/** Backend provider routed to. */
backend: string
/** Model mapped to. */
model: string
/** Latency in milliseconds. */
latency_ms: number
/** Request outcome status. */
status: string
}
/** Observability stats summary response. */
export interface ObservabilityResponse {
/** The time window in hours for metrics. */
window_hours: number
/** The name of the backend. */
backend: string
/** Total requests within the window. */
total_requests: number
/** Total errors within the window. */
total_errors: number
/** Total input tokens processed. */
total_input_tokens: number
/** Total output tokens generated. */
total_output_tokens: number
/** Total cost in USD. */
total_cost_usd: number
/** Historical metrics series points. */
series: ObservabilityPoint[]
/** Error breakdown summary. */
failures: ObservabilityFailure[]
/** Timeline events. */
timeline: ObservabilityTimeline[]
}
/** Real-time metrics for a specific API route. */
export interface RouteMetrics {
/** The request path/route. */
path: string
/** Number of requests per minute. */
requests_per_min: number
/** Percentage rate of failures. */
error_rate: number
/** Average latency in milliseconds. */
avg_latency_ms: number
/** The p95 latency in milliseconds. */
p95_latency_ms: number
/** Total number of requests. */
total_requests: number
}
/** Time-series requests count point for a route. */
export interface TrafficSeriesPoint {
/** UNIX timestamp representing the bucket start time. */
bucket_start: number
/** API path. */
path: string
/** Request count. */
requests: number
}
/** Response containing traffic analytics. */
export interface TrafficResponse {
/** Time window in hours. */
window_hours: number
/** Metrics per route. */
routes: RouteMetrics[]
/** Time-series data points. */
series: TrafficSeriesPoint[]
}
@@ -0,0 +1,69 @@
/** Represents a single model configuration entry. */
export interface ModelEntry {
/** The unique name/identifier of the model. */
model_name: string
/** The number of active deployments for this model. */
deployments: number
}
/** Response shape for models list requests. */
export interface ModelsResponse {
/** List of model configurations. */
models: ModelEntry[]
/** Router strategy (e.g. priority, failover). */
strategy: string | null
/** Optional descriptive note. */
note?: string
}
/** Represents a model discovered from a backend API. */
export interface DiscoveredModel {
/** The model identifier. */
id: string
/** Optional human-readable name. */
name: string | null
}
/** Response containing discovered models. */
export interface DiscoverResponse {
/** List of discovered models. */
models: DiscoveredModel[]
/** Source or method used for discovery. */
source: string
/** True if authorization was used. */
auth_used: boolean
}
/** Details of a model available in the LiteLLM catalog. */
export interface CatalogModel {
/** Unique model identifier. */
id: string
/** Maximum context window size in tokens. */
context_window: number
/** Maximum output tokens. */
max_output_tokens: number
/** Availability status of the model. */
status: 'available' | 'deprecated' | 'stub'
/** Capabilities flags. */
capabilities: {
streaming: boolean
tool_use: boolean
vision: boolean
extended_thinking: boolean
}
/** Pricing per million tokens. */
pricing: {
input_per_million_tokens: number
output_per_million_tokens: number
} | null
}
/** Response containing model lists. */
export interface CatalogModelsResponse {
/** Catalog provider ID. */
provider_id: string
/** True if this provider has models. */
has_models: boolean
/** List of models. */
models: CatalogModel[]
}
@@ -0,0 +1,15 @@
/** Status of the optional LLMLingua-2 ONNX model artifact (optimizer scorer tier). */
export interface OptimizerModelStatus {
/** Proxy built with the `optimizer-onnx` feature. When false the tier is inert. */
compiled_in: boolean
/** Verified model artifact is present on disk. */
present: boolean
/** A download+verify is currently in flight. */
downloading: boolean
/** Last download error, if any. */
error: string | null
/** Pinned sha256 the artifact is verified against. */
sha256: string
/** Expected download size in bytes. */
size_bytes: number
}
@@ -0,0 +1,9 @@
/** Represents the status and configuration details of the proxy server. */
export interface ProxyStatus {
/** True if the proxy backend has been configured. */
configured: boolean
/** The port number the proxy listens on for client requests. */
proxy_port: number
/** Whether the proxy is currently running and accepting connections. */
proxy_running: boolean
}
@@ -0,0 +1,43 @@
/** A single request transaction log entry. */
export interface RequestLogEntry {
/** Unique transaction ID. */
request_id: string
/** ISO 8601 timestamp when the request was made. */
timestamp: string
/** The backend model provider targeted. */
backend: string
/** The model identifier requested by the client. */
model_requested: string | null
/** The actual model identifier routed to on the backend. */
model_mapped: string | null
/** HTTP response status code. */
status_code: number
/** Request duration in milliseconds. */
latency_ms: number
/** Number of input tokens processed. */
input_tokens: number | null
/** Number of output tokens generated. */
output_tokens: number | null
/** Whether the request was streamed. */
is_streaming: boolean
/** The raw error message returned by the backend, if any. */
error_message: string | null
/** Normalized error class/category. */
error_kind: string | null
/** The virtual key ID used for authorization, if any. */
key_id: number | null
/** Calculated transaction cost in USD. */
cost_usd: number | null
}
/** Paginated list response for request log queries. */
export interface RequestsResponse {
/** The page of request log entries. */
requests: RequestLogEntry[]
/** Maximum number of items returned. */
limit: number
/** Pagination offset. */
offset: number
/** True if more records are available. */
has_more: boolean
}
@@ -0,0 +1,126 @@
/** Represents a configured API routing definition. */
export interface Route {
/** Unique route database ID. */
id: string
/** Route name. */
name: string
/** Optional description. */
description: string | null
/** Load balancing strategy. */
strategy: string
/** Requests per minute limit. */
rpm: number | null
/** Tokens per minute limit. */
tpm: number | null
/** Cost budget in USD. */
budget_usd: number | null
/** Active status toggle. */
enabled: boolean
/** Tool guardrail mode override. */
guardrail_mode: string | null
/** pxpipe image compression toggle override. */
pxpipe_compress: boolean | null
/** pxpipe models CSV override. */
pxpipe_models: string | null
/** Secret redaction toggle override. */
redact_secrets: boolean | null
/** Position order in the router. */
position: number
/** Count of providers assigned to this route. */
provider_count: number
/** ISO 8601 creation timestamp. */
created_at: string
/** ISO 8601 last update timestamp. */
updated_at: string
}
/** Response containing routing configs. */
export interface RoutesResponse {
/** List of routes. */
routes: Route[]
}
/** Request payload to create a new route. */
export interface CreateRouteRequest {
/** Unique route name. */
name: string
/** Route description. */
description?: string
/** Load balancing strategy. */
strategy?: string
/** Requests per minute limit. */
rpm?: number
/** Tokens per minute limit. */
tpm?: number
/** Cost budget in USD. */
budget_usd?: number
/** Active status toggle. */
enabled?: boolean
/** Tool guardrail mode override. */
guardrail_mode?: string | null
/** pxpipe image compression toggle override. */
pxpipe_compress?: boolean | null
/** pxpipe models CSV override. */
pxpipe_models?: string | null
/** Secret redaction toggle override. */
redact_secrets?: boolean | null
/** Position order. */
position?: number
}
/** Request payload to update an existing route. */
export type UpdateRouteRequest = Partial<CreateRouteRequest>
/** Represents a provider mapped to a route. */
export interface RouteProvider {
/** Unique assignment ID. */
id: string
/** Target route ID. */
route_id: string
/** Backend ID. */
backend_id: string
/** Name of the backend. */
backend_name: string
/** Catalog provider ID. */
provider_id: string
/** Supported models list. */
models: string[]
/** Evaluation priority. */
priority: number
/** Active status. */
enabled: boolean
}
/** Response containing route provider mappings. */
export interface RouteProvidersResponse {
/** List of mappings. */
providers: RouteProvider[]
}
/** Request payload to add a provider assignment to a route. */
export interface AddRouteProviderRequest {
/** Target backend ID. */
backend_id: string
/** Supported models list. */
models?: string[]
/** Priority position. */
priority?: number
/** Active status. */
enabled?: boolean
}
/** Request payload to update a provider assignment configuration. */
export interface UpdateRouteProviderRequest {
/** Supported models list. */
models?: string[]
/** Priority position. */
priority?: number
/** Active status. */
enabled?: boolean
}
/** Request payload to reorder provider mappings. */
export interface ReorderRouteProvidersRequest {
/** Ordered list of provider assignment IDs. */
provider_ids: string[]
}
@@ -0,0 +1,41 @@
/** A single day's uptime status. */
export interface HistoryDay {
/** Date in YYYY-MM-DD format. */
date: string
/** Availability status. */
status: 'up' | 'down' | 'degraded' | 'no-data'
}
/** Uptime info for the proxy itself. */
export interface ProxyUptimeInfo {
/** UNIX timestamp when the proxy started. */
started_at: number
/** Uptime percentage over the last 30 days. */
uptime_pct_30d: number
/** Daily uptime history. */
history: HistoryDay[]
}
/** Uptime info for a backend endpoint. */
export interface BackendUptimeInfo {
/** Name of the backend. */
name: string
/** Current connection status. */
status: 'up' | 'down' | 'unknown'
/** UNIX timestamp of the last health check. */
last_checked_at: number | null
/** Last checked latency in milliseconds. */
last_latency_ms: number | null
/** 30-day uptime percentage. */
uptime_pct_30d: number
/** Daily uptime history. */
history: HistoryDay[]
}
/** Full uptime summary response. */
export interface UptimeResponse {
/** Proxy server uptime details. */
proxy: ProxyUptimeInfo
/** Backend endpoints uptime details. */
backends: BackendUptimeInfo[]
}
@@ -0,0 +1,9 @@
import type { RequestLogEntry } from './requests'
import type { Metrics } from './metrics'
/** Types of events sent over the admin WebSocket channel. */
export type WSEvent =
| { type: 'request_completed'; data: RequestLogEntry }
| { type: 'metrics_snapshot'; data: Metrics }
| { type: 'config_changed'; data: { key: string; value: string } }
| { type: 'backend_health_changed'; data: { backend: string; status: 'up' | 'down'; latency_ms: number | null } }
@@ -0,0 +1,149 @@
import { useRef, useState } from 'react'
import { useImportEnv, downloadEnvExport } from '../../api/queries'
import { AdminButton, AdminSurface } from '../../components/shared/Performative'
import type { EnvImportResponse, EnvImportError } from '../../api/types'
const RESTART_KEY = 'env_import_pending_restart'
function restartPending() {
return sessionStorage.getItem(RESTART_KEY) === '1'
}
export default function EnvFileSection() {
const importEnv = useImportEnv()
const fileRef = useRef<HTMLInputElement>(null)
const [importResult, setImportResult] = useState<EnvImportResponse | null>(null)
const [importError, setImportError] = useState<EnvImportError | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
const [showRestartBanner, setShowRestartBanner] = useState(restartPending)
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setImportResult(null)
setImportError(null)
importEnv.mutate(file, {
onSuccess(data) {
setImportResult(data)
sessionStorage.setItem(RESTART_KEY, '1')
setShowRestartBanner(true)
},
onError(err) {
// Try to parse hard_errors from the response body
try {
const parsed = JSON.parse(err.message) as EnvImportError
if (parsed.hard_errors) {
setImportError(parsed)
return
}
} catch {
// fall through to generic error
}
setImportError({ hard_errors: [err.message], warnings: [] })
},
})
// Reset file input so the same file can be re-selected after fixing issues
if (fileRef.current) fileRef.current.value = ''
}
async function handleExport() {
setExportError(null)
try {
await downloadEnvExport()
} catch (err) {
setExportError(err instanceof Error ? err.message : String(err))
}
}
function dismissRestartBanner() {
sessionStorage.removeItem(RESTART_KEY)
setShowRestartBanner(false)
}
return (
<div style={{ marginBottom: 24 }}>
{/* Restart-required banner — shown after a successful import */}
{showRestartBanner && (
<AdminSurface className="settings-restart-banner" style={{ marginBottom: 16 }}>
<span>Restart the proxy for imported env vars to take effect.</span>
<AdminButton size="sm" onClick={dismissRestartBanner}>Dismiss</AdminButton>
</AdminSurface>
)}
<div className="section-label" style={{ marginBottom: 8 }}>Env File</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
ref={fileRef}
type="file"
accept=".env,.anyllm.env,text/plain"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<AdminButton
size="sm"
onClick={() => fileRef.current?.click()}
disabled={importEnv.isPending}
loading={importEnv.isPending}
>
Import .anyllm.env
</AdminButton>
<AdminButton size="sm" onClick={handleExport}>
Export .anyllm.env
</AdminButton>
</div>
{/* Import success */}
{importResult && (
<div style={{ marginTop: 10 }}>
<div className="dim" style={{ marginBottom: 4 }}>
{importResult.applied} variable{importResult.applied !== 1 ? 's' : ''} imported.
{importResult.warnings.length === 0 && ' No issues.'}
</div>
{importResult.warnings.length > 0 && (
<div style={{ marginTop: 8, padding: '8px 12px', background: 'var(--warn-dim)', borderLeft: '3px solid var(--warn)', borderRadius: 'var(--r)', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Warnings</div>
{importResult.warnings.map((w, i) => (
<div key={i} className="mono" style={{ fontSize: 12 }}>
{w.line != null && <span className="dim">[line {w.line}] </span>}
{w.key && <span>{w.key}: </span>}
{w.message}
</div>
))}
</div>
)}
</div>
)}
{/* Import hard error */}
{importError && (
<div style={{ marginTop: 10, padding: '8px 12px', background: 'var(--err-dim)', borderLeft: '3px solid var(--err)', borderRadius: 'var(--r)', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Import rejected</div>
{importError.hard_errors.map((e, i) => (
<div key={i} className="mono" style={{ fontSize: 12 }}>{e}</div>
))}
{importError.warnings.length > 0 && (
<>
<div style={{ fontWeight: 600, marginTop: 8, marginBottom: 4 }}>Warnings (from partial parse)</div>
{importError.warnings.map((w, i) => (
<div key={i} className="mono" style={{ fontSize: 12 }}>
{w.line != null && <span className="dim">[line {w.line}] </span>}
{w.message}
</div>
))}
</>
)}
</div>
)}
{/* Export error */}
{exportError && (
<div style={{ marginTop: 10, padding: '8px 12px', background: 'var(--err-dim)', borderLeft: '3px solid var(--err)', borderRadius: 'var(--r)', fontSize: 12 }}>
Export failed: {exportError}
</div>
)}
</div>
)
}
@@ -0,0 +1,23 @@
import { Fragment } from 'react'
interface EnvVariablesSectionProps {
envData: Record<string, string> | undefined
}
export default function EnvVariablesSection({ envData }: EnvVariablesSectionProps) {
if (!envData) return null
return (
<div className="readonly-section" style={{ marginTop: 16 }}>
<div className="section-label">Environment</div>
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: '4px 12px', marginTop: 8, fontSize: 12 }}>
{Object.entries(envData).map(([k, v]) => (
<Fragment key={k}>
<span className="dim">{k}</span>
<span className="mono">{v}</span>
</Fragment>
))}
</div>
</div>
)
}
@@ -0,0 +1,43 @@
interface GettingStartedNoticeProps {
configured: boolean
}
export default function GettingStartedNotice({ configured }: GettingStartedNoticeProps) {
if (configured) return null
return (
<div style={{ marginBottom: 20, padding: '12px 16px', border: '1px solid var(--border)', borderLeft: '3px solid var(--warn)', borderRadius: 'var(--r)', fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>No backend configured nothing to forward requests to.</div>
<div style={{ marginBottom: 10 }}>
Add a backend on the <span className="mono">Backends</span> tab, or configure one via env.
The proxy needs a backend endpoint (where to forward) and a listen port (where to accept).
LISTEN_PORT defaults to 3000. Create a <span className="mono">.anyllm.env</span> and import it below,
or pass it at startup: <span className="mono">anyllm-proxy --webui --env-file .anyllm.env</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
<div>
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 12 }}>OpenAI</div>
<pre style={{ margin: 0, padding: '6px 10px', background: 'var(--surface-2)', borderRadius: 'var(--r)', fontSize: 11, overflowX: 'auto' }}>
{`OPENAI_API_KEY=sk-...
PROXY_API_KEYS=my-key`}
</pre>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 12 }}>Ollama / local LLM</div>
<pre style={{ margin: 0, padding: '6px 10px', background: 'var(--surface-2)', borderRadius: 'var(--r)', fontSize: 11, overflowX: 'auto' }}>
{`OPENAI_BASE_URL=http://localhost:11434/v1
PROXY_OPEN_RELAY=true`}
</pre>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 12 }}>OpenRouter / custom</div>
<pre style={{ margin: 0, padding: '6px 10px', background: 'var(--surface-2)', borderRadius: 'var(--r)', fontSize: 11, overflowX: 'auto' }}>
{`OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_API_KEY=sk-or-...
PROXY_API_KEYS=my-key`}
</pre>
</div>
</div>
</div>
)
}
@@ -0,0 +1,376 @@
import { useState } from 'react'
import {
useSaveConfig, useDeleteConfigOverride,
useOptimizerModel, useDownloadOptimizerModel,
} from '../../api/queries'
import ConfirmDialog from '../../components/shared/ConfirmDialog'
import { AdminButton } from '../../components/shared/Performative'
import type { ConfigResponse } from '../../api/types'
function fmtMB(bytes: number): string {
return `${Math.round(bytes / 1_000_000)} MB`
}
interface RuntimeSettingsSectionProps {
cfg: ConfigResponse
}
export default function RuntimeSettingsSection({ cfg }: RuntimeSettingsSectionProps) {
const save = useSaveConfig()
const del = useDeleteConfigOverride()
const { data: model } = useOptimizerModel()
const downloadModel = useDownloadOptimizerModel()
const [form, setForm] = useState<Record<string, string>>({})
const [pendingReset, setPendingReset] = useState<string | null>(null)
/** Resets a config override key back to its default value. */
function doReset() {
if (!pendingReset) return Promise.resolve()
const key = pendingReset
return del.mutateAsync(key).then(() => undefined)
}
/** Saves a text configuration setting value. */
function handleSave(key: string, currentValue: string) {
save.mutate({ [key]: form[key] ?? currentValue })
}
/** Saves a boolean configuration setting value. */
function handleBooleanSave(key: string, value: boolean) {
save.mutate({ [key]: value })
}
// pxpipe model scope is a CSV of model bases; a model is "in scope" when any
// base is a substring of its id (mirrors the backend's model_in_scope).
function pxpipeScope(): string[] {
return (cfg.pxpipe_models ?? '').split(',').map((s) => s.trim()).filter(Boolean)
}
function pxpipeModelChecked(model: string): boolean {
const m = model.toLowerCase()
return pxpipeScope().some((base) => m.includes(base.toLowerCase()))
}
function togglePxpipeModel(model: string, on: boolean) {
const cur = pxpipeScope()
const next = on
? (pxpipeModelChecked(model) ? cur : [...cur, model])
: cur.filter((base) => !model.toLowerCase().includes(base.toLowerCase()))
save.mutate({ pxpipe_models: next.join(',') })
}
return (
<div>
<div className="section-label" style={{ marginBottom: 8 }}>Runtime</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-redact-secrets" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-redact-secrets"
type="checkbox"
checked={cfg.redact_secrets}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('redact_secrets', e.target.checked)}
/>
Redact secrets
</label>
{cfg.overridden_keys.includes('redact_secrets') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('redact_secrets')}>
Reset
</AdminButton>
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-log-bodies" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-log-bodies"
type="checkbox"
checked={cfg.log_bodies}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('log_bodies', e.target.checked)}
/>
Log bodies
</label>
{cfg.overridden_keys.includes('log_bodies') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('log_bodies')}>
Reset
</AdminButton>
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-thinking-repair" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-thinking-repair"
type="checkbox"
checked={cfg.anthropic_thinking_repair}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('anthropic_thinking_repair', e.target.checked)}
/>
Anthropic thinking-block repair
</label>
<div className="dim" style={{ fontSize: 12 }}>
Repairs corrupted thinking/redacted_thinking blocks in Anthropic passthrough
requests (applies to any backend running in BACKEND=anthropic passthrough mode,
including a named backend in a multi-backend config). Off by default.
</div>
{cfg.overridden_keys.includes('anthropic_thinking_repair') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('anthropic_thinking_repair')}>
Reset
</AdminButton>
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-pxpipe-compress" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-pxpipe-compress"
type="checkbox"
checked={cfg.pxpipe_compress}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('pxpipe_compress', e.target.checked)}
/>
Image context compression (pxpipe)
</label>
<div className="dim" style={{ fontSize: 12 }}>
Renders the stable system + tool-definition slab of Anthropic passthrough requests to a
PNG image block to save input tokens on vision models. Off by default. Enable per-model
below only models that read imaged text reliably are offered.
</div>
{cfg.overridden_keys.includes('pxpipe_compress') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('pxpipe_compress')}>
Reset
</AdminButton>
</div>
)}
{cfg.pxpipe_compress && (
<div style={{ marginTop: 8 }}>
<div className="form-label" style={{ fontSize: 13 }}>Models in scope (vision-capable)</div>
{cfg.pxpipe_available_models.length === 0 ? (
<div className="dim" style={{ fontSize: 12 }}>No vision-capable models in the catalog.</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
{cfg.pxpipe_available_models.map((model) => (
<label key={model} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
<input
type="checkbox"
checked={pxpipeModelChecked(model)}
disabled={save.isPending}
onChange={(e) => togglePxpipeModel(model, e.target.checked)}
/>
{model}
</label>
))}
</div>
)}
{cfg.overridden_keys.includes('pxpipe_models') && (
<div className="form-row" style={{ marginTop: 6 }}>
<AdminButton size="sm" onClick={() => setPendingReset('pxpipe_models')}>
Reset scope
</AdminButton>
</div>
)}
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-rtk-compress" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-rtk-compress"
type="checkbox"
checked={cfg.rtk_compress}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('rtk_compress', e.target.checked)}
/>
Tool-output compression (RTK)
</label>
<div className="dim" style={{ fontSize: 12 }}>
Command-aware filtering of tool-result text (test/build/git/log output) using the RTK
filter catalog. Shrinks noisy machine output before it reaches the backend; deterministic
and cache-safe. Off by default. Applies to Anthropic passthrough and translate paths.
</div>
{cfg.overridden_keys.includes('rtk_compress') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('rtk_compress')}>
Reset
</AdminButton>
</div>
)}
{cfg.rtk_compress && (
<div style={{ marginTop: 8 }}>
<div className="form-label" style={{ fontSize: 13 }}>Models in scope (CSV, empty = all)</div>
<input
type="text"
className="form-input"
key={cfg.rtk_models}
defaultValue={cfg.rtk_models}
placeholder="empty = all models; e.g. claude, gpt-5"
disabled={save.isPending}
onBlur={(e) => {
const v = e.target.value.trim()
if (v === (cfg.rtk_models ?? '')) return
save.mutate({ rtk_models: v })
}}
/>
{cfg.overridden_keys.includes('rtk_models') && (
<div className="form-row" style={{ marginTop: 6 }}>
<AdminButton size="sm" onClick={() => setPendingReset('rtk_models')}>
Reset scope
</AdminButton>
</div>
)}
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-forward-client-auth" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-forward-client-auth"
type="checkbox"
checked={cfg.forward_client_auth}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('forward_client_auth', e.target.checked)}
/>
Forward client credential (Anthropic passthrough)
</label>
<div className="dim" style={{ fontSize: 12 }}>
Forwards the client's own x-api-key/Authorization header upstream instead of the
operator's configured credential (BACKEND=anthropic passthrough only, single-key/BYOK
deployments). The proxy refuses to enable this with 2+ PROXY_API_KEYS entries and no
PROXY_OPEN_RELAY. Off by default.
</div>
{cfg.overridden_keys.includes('forward_client_auth') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('forward_client_auth')}>
Reset
</AdminButton>
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-tool-guardrail-mode">Tool guardrail mode</label>
<div className="form-row">
<select
id="cfg-tool-guardrail-mode"
value={cfg.tool_guardrail_mode}
disabled={save.isPending}
onChange={(e) => save.mutate({ tool_guardrail_mode: e.target.value })}
>
<option value="disabled">Disabled</option>
<option value="standard">Standard</option>
</select>
{cfg.overridden_keys.includes('tool_guardrail_mode') && (
<AdminButton size="sm" onClick={() => setPendingReset('tool_guardrail_mode')}>
Reset
</AdminButton>
)}
</div>
<div className="dim" style={{ fontSize: 12 }}>
Applies advisory guardrails to tool calls the proxy auto-executes. Disabled by default.
</div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-optimizer-mode">Prompt compression (optimizer)</label>
<div className="form-row">
<select
id="cfg-optimizer-mode"
value={cfg.optimizer_mode}
disabled={save.isPending || (!!model?.compiled_in && !model?.present)}
onChange={(e) => save.mutate({ optimizer_mode: e.target.value })}
>
<option value="off">Off</option>
<option value="shadow">Shadow (report only)</option>
<option value="live">Live (compress)</option>
</select>
{cfg.overridden_keys.includes('optimizer_mode') && (
<AdminButton size="sm" onClick={() => setPendingReset('optimizer_mode')}>
Reset
</AdminButton>
)}
</div>
<div className="dim" style={{ fontSize: 12 }}>
Frozen-Frontier compression of long conversation history (latest turn untouched). Off by default.
</div>
{model && !model.compiled_in && (
<div className="dim" style={{ fontSize: 12, marginTop: 8 }}>
Heuristic scorer only. Rebuild the proxy with <code>--features optimizer-onnx</code> to enable the LLMLingua-2 ONNX scorer.
</div>
)}
{model?.compiled_in && !model.present && !model.downloading && (
<div className="form-row" style={{ marginTop: 8 }}>
<AdminButton
size="sm"
disabled={downloadModel.isPending}
onClick={() => downloadModel.mutate()}
>
Download model ({fmtMB(model.size_bytes)})
</AdminButton>
<span className="dim" style={{ fontSize: 12 }}>
Required before enabling. Verified against a pinned sha256.
</span>
</div>
)}
{model?.downloading && (
<div className="dim" style={{ fontSize: 12, marginTop: 8 }}>
Downloading and verifying model ({fmtMB(model.size_bytes)})
</div>
)}
{model?.error && !model.downloading && (
<div style={{ fontSize: 12, marginTop: 8, color: 'var(--danger, #c0392b)' }}>
Download failed: {model.error}
</div>
)}
{model?.compiled_in && model.present && (
<div className="dim" style={{ fontSize: 12, marginTop: 8 }}>
ONNX scorer ready live mode uses LLMLingua-2 (loaded on the next request).
</div>
)}
</div>
{cfg.entries.filter((entry) => !['redact_secrets', 'log_bodies', 'anthropic_thinking_repair', 'pxpipe_compress', 'pxpipe_models', 'rtk_compress', 'rtk_models', 'forward_client_auth', 'tool_guardrail_mode', 'optimizer_mode'].includes(entry.key)).map((entry) => {
const inputId = `cfg-${entry.key}`
return (
<div className="form-group" key={entry.key}>
<label className="form-label" htmlFor={inputId}>{entry.key}</label>
<div className="form-row">
<input
id={inputId}
name={entry.key}
value={form[entry.key] ?? entry.value}
onChange={(e) => setForm((f) => ({ ...f, [entry.key]: e.target.value }))}
/>
<AdminButton tone="primary" size="sm" onClick={() => handleSave(entry.key, entry.value)}>Save</AdminButton>
<AdminButton size="sm" onClick={() => setPendingReset(entry.key)}>Reset</AdminButton>
</div>
</div>
)
})}
<ConfirmDialog
open={pendingReset !== null}
onClose={() => setPendingReset(null)}
onConfirm={doReset}
title="Reset override?"
message={
<>
Reset override for <span className="mono">{pendingReset}</span>? The runtime value will revert
to the env-file or default. Active connections are not affected.
</>
}
confirmLabel="Reset"
variant="primary"
/>
</div>
)
}
@@ -1,126 +1,19 @@
import { Fragment, useRef, useState } from 'react'
import {
useConfig, useSaveConfig, useDeleteConfigOverride, useEnv,
useImportEnv, downloadEnvExport, useStatus,
useOptimizerModel, useDownloadOptimizerModel,
} from '../../api/queries'
import { useConfig, useEnv, useStatus } from '../../api/queries'
import EmptyState from '../../components/shared/EmptyState'
import ConfirmDialog from '../../components/shared/ConfirmDialog'
import { AdminButton, AdminSurface } from '../../components/shared/Performative'
import type { EnvImportResponse, EnvImportError } from '../../api/types'
const RESTART_KEY = 'env_import_pending_restart'
function restartPending() {
return sessionStorage.getItem(RESTART_KEY) === '1'
}
function fmtMB(bytes: number): string {
return `${Math.round(bytes / 1_000_000)} MB`
}
import GettingStartedNotice from './GettingStartedNotice'
import EnvFileSection from './EnvFileSection'
import RuntimeSettingsSection from './RuntimeSettingsSection'
import EnvVariablesSection from './EnvVariablesSection'
/**
* Settings Component.
* Provides controls for configuring system settings, environment variables export/import,
* and proxy properties.
* Coordinates and provides controls for configuring system settings,
* environment variables export/import, and proxy properties.
*/
export default function Settings({ configured = true }: { configured?: boolean }) {
const { data: cfg, isLoading, error } = useConfig()
const { data: model } = useOptimizerModel()
const downloadModel = useDownloadOptimizerModel()
const { data: envData } = useEnv()
const { data: status } = useStatus()
const save = useSaveConfig()
const del = useDeleteConfigOverride()
const importEnv = useImportEnv()
const fileRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState<Record<string, string>>({})
const [importResult, setImportResult] = useState<EnvImportResponse | null>(null)
const [importError, setImportError] = useState<EnvImportError | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
const [showRestartBanner, setShowRestartBanner] = useState(restartPending)
const [pendingReset, setPendingReset] = useState<string | null>(null)
/** Resets a config override key back to its default value. */
function doReset() {
if (!pendingReset) return Promise.resolve()
const key = pendingReset
return del.mutateAsync(key).then(() => undefined)
}
/** Saves a text configuration setting value. */
function handleSave(key: string, currentValue: string) {
save.mutate({ [key]: form[key] ?? currentValue })
}
/** Saves a boolean configuration setting value. */
function handleBooleanSave(key: string, value: boolean) {
save.mutate({ [key]: value })
}
// pxpipe model scope is a CSV of model bases; a model is "in scope" when any
// base is a substring of its id (mirrors the backend's model_in_scope).
function pxpipeScope(): string[] {
return (cfg?.pxpipe_models ?? '').split(',').map((s) => s.trim()).filter(Boolean)
}
function pxpipeModelChecked(model: string): boolean {
const m = model.toLowerCase()
return pxpipeScope().some((base) => m.includes(base.toLowerCase()))
}
function togglePxpipeModel(model: string, on: boolean) {
const cur = pxpipeScope()
const next = on
? (pxpipeModelChecked(model) ? cur : [...cur, model])
: cur.filter((base) => !model.toLowerCase().includes(base.toLowerCase()))
save.mutate({ pxpipe_models: next.join(',') })
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setImportResult(null)
setImportError(null)
importEnv.mutate(file, {
onSuccess(data) {
setImportResult(data)
sessionStorage.setItem(RESTART_KEY, '1')
setShowRestartBanner(true)
},
onError(err) {
// Try to parse hard_errors from the response body
try {
const parsed = JSON.parse(err.message) as EnvImportError
if (parsed.hard_errors) {
setImportError(parsed)
return
}
} catch {
// fall through to generic error
}
setImportError({ hard_errors: [err.message], warnings: [] })
},
})
// Reset file input so the same file can be re-selected after fixing issues
if (fileRef.current) fileRef.current.value = ''
}
async function handleExport() {
setExportError(null)
try {
await downloadEnvExport()
} catch (err) {
setExportError(err instanceof Error ? err.message : String(err))
}
}
/** Dismisses the restart banner reminding the operator that imports require a restart. */
function dismissRestartBanner() {
sessionStorage.removeItem(RESTART_KEY)
setShowRestartBanner(false)
}
const proxyUrl = status ? `http://${window.location.hostname}:${status.proxy_port}` : ''
@@ -142,458 +35,19 @@ export default function Settings({ configured = true }: { configured?: boolean }
</div>
)}
{/* Getting-started notice — shown when no backend is configured (no env/config-file
signal and no managed backend). Manage backends on the Backends tab. */}
{!configured && (
<div style={{ marginBottom: 20, padding: '12px 16px', border: '1px solid var(--border)', borderLeft: '3px solid var(--warn)', borderRadius: 'var(--r)', fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>No backend configured nothing to forward requests to.</div>
<div style={{ marginBottom: 10 }}>
Add a backend on the <span className="mono">Backends</span> tab, or configure one via env.
The proxy needs a backend endpoint (where to forward) and a listen port (where to accept).
LISTEN_PORT defaults to 3000. Create a <span className="mono">.anyllm.env</span> and import it below,
or pass it at startup: <span className="mono">anyllm-proxy --webui --env-file .anyllm.env</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
<div>
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 12 }}>OpenAI</div>
<pre style={{ margin: 0, padding: '6px 10px', background: 'var(--surface-2)', borderRadius: 'var(--r)', fontSize: 11, overflowX: 'auto' }}>
{`OPENAI_API_KEY=sk-...
PROXY_API_KEYS=my-key`}
</pre>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 12 }}>Ollama / local LLM</div>
<pre style={{ margin: 0, padding: '6px 10px', background: 'var(--surface-2)', borderRadius: 'var(--r)', fontSize: 11, overflowX: 'auto' }}>
{`OPENAI_BASE_URL=http://localhost:11434/v1
PROXY_OPEN_RELAY=true`}
</pre>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 12 }}>OpenRouter / custom</div>
<pre style={{ margin: 0, padding: '6px 10px', background: 'var(--surface-2)', borderRadius: 'var(--r)', fontSize: 11, overflowX: 'auto' }}>
{`OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_API_KEY=sk-or-...
PROXY_API_KEYS=my-key`}
</pre>
</div>
</div>
</div>
)}
{/* Restart-required banner — shown after a successful import */}
{showRestartBanner && (
<AdminSurface className="settings-restart-banner">
<span>Restart the proxy for imported env vars to take effect.</span>
<AdminButton size="sm" onClick={dismissRestartBanner}>Dismiss</AdminButton>
</AdminSurface>
)}
{/* Getting-started notice — shown when no backend is configured */}
<GettingStartedNotice configured={configured} />
{/* Env file import / export */}
<div style={{ marginBottom: 24 }}>
<div className="section-label" style={{ marginBottom: 8 }}>Env File</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
ref={fileRef}
type="file"
accept=".env,.anyllm.env,text/plain"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<AdminButton
size="sm"
onClick={() => fileRef.current?.click()}
disabled={importEnv.isPending}
loading={importEnv.isPending}
>
Import .anyllm.env
</AdminButton>
<AdminButton size="sm" onClick={handleExport}>
Export .anyllm.env
</AdminButton>
</div>
{/* Import success */}
{importResult && (
<div style={{ marginTop: 10 }}>
<div className="dim" style={{ marginBottom: 4 }}>
{importResult.applied} variable{importResult.applied !== 1 ? 's' : ''} imported.
{importResult.warnings.length === 0 && ' No issues.'}
</div>
{importResult.warnings.length > 0 && (
<div style={{ marginTop: 8, padding: '8px 12px', background: 'var(--warn-dim)', borderLeft: '3px solid var(--warn)', borderRadius: 'var(--r)', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Warnings</div>
{importResult.warnings.map((w, i) => (
<div key={i} className="mono" style={{ fontSize: 12 }}>
{w.line != null && <span className="dim">[line {w.line}] </span>}
{w.key && <span>{w.key}: </span>}
{w.message}
</div>
))}
</div>
)}
</div>
)}
{/* Import hard error */}
{importError && (
<div style={{ marginTop: 10, padding: '8px 12px', background: 'var(--err-dim)', borderLeft: '3px solid var(--err)', borderRadius: 'var(--r)', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Import rejected</div>
{importError.hard_errors.map((e, i) => (
<div key={i} className="mono" style={{ fontSize: 12 }}>{e}</div>
))}
{importError.warnings.length > 0 && (
<>
<div style={{ fontWeight: 600, marginTop: 8, marginBottom: 4 }}>Warnings (from partial parse)</div>
{importError.warnings.map((w, i) => (
<div key={i} className="mono" style={{ fontSize: 12 }}>
{w.line != null && <span className="dim">[line {w.line}] </span>}
{w.message}
</div>
))}
</>
)}
</div>
)}
{/* Export error */}
{exportError && (
<div style={{ marginTop: 10, padding: '8px 12px', background: 'var(--err-dim)', borderLeft: '3px solid var(--err)', borderRadius: 'var(--r)', fontSize: 12 }}>
Export failed: {exportError}
</div>
)}
</div>
<EnvFileSection />
<EmptyState loading={isLoading} error={error?.message} />
{cfg && (
<div>
<div className="section-label" style={{ marginBottom: 8 }}>Runtime</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-redact-secrets" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-redact-secrets"
type="checkbox"
checked={cfg.redact_secrets}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('redact_secrets', e.target.checked)}
/>
Redact secrets
</label>
{cfg.overridden_keys.includes('redact_secrets') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('redact_secrets')}>
Reset
</AdminButton>
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-log-bodies" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-log-bodies"
type="checkbox"
checked={cfg.log_bodies}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('log_bodies', e.target.checked)}
/>
Log bodies
</label>
{cfg.overridden_keys.includes('log_bodies') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('log_bodies')}>
Reset
</AdminButton>
</div>
)}
</div>
{/* Runtime settings override form */}
{cfg && <RuntimeSettingsSection cfg={cfg} />}
<div className="form-group">
<label className="form-label" htmlFor="cfg-thinking-repair" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-thinking-repair"
type="checkbox"
checked={cfg.anthropic_thinking_repair}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('anthropic_thinking_repair', e.target.checked)}
/>
Anthropic thinking-block repair
</label>
<div className="dim" style={{ fontSize: 12 }}>
Repairs corrupted thinking/redacted_thinking blocks in Anthropic passthrough
requests (applies to any backend running in BACKEND=anthropic passthrough mode,
including a named backend in a multi-backend config). Off by default.
</div>
{cfg.overridden_keys.includes('anthropic_thinking_repair') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('anthropic_thinking_repair')}>
Reset
</AdminButton>
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-pxpipe-compress" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-pxpipe-compress"
type="checkbox"
checked={cfg.pxpipe_compress}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('pxpipe_compress', e.target.checked)}
/>
Image context compression (pxpipe)
</label>
<div className="dim" style={{ fontSize: 12 }}>
Renders the stable system + tool-definition slab of Anthropic passthrough requests to a
PNG image block to save input tokens on vision models. Off by default. Enable per-model
below only models that read imaged text reliably are offered.
</div>
{cfg.overridden_keys.includes('pxpipe_compress') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('pxpipe_compress')}>
Reset
</AdminButton>
</div>
)}
{cfg.pxpipe_compress && (
<div style={{ marginTop: 8 }}>
<div className="form-label" style={{ fontSize: 13 }}>Models in scope (vision-capable)</div>
{cfg.pxpipe_available_models.length === 0 ? (
<div className="dim" style={{ fontSize: 12 }}>No vision-capable models in the catalog.</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
{cfg.pxpipe_available_models.map((model) => (
<label key={model} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
<input
type="checkbox"
checked={pxpipeModelChecked(model)}
disabled={save.isPending}
onChange={(e) => togglePxpipeModel(model, e.target.checked)}
/>
{model}
</label>
))}
</div>
)}
{cfg.overridden_keys.includes('pxpipe_models') && (
<div className="form-row" style={{ marginTop: 6 }}>
<AdminButton size="sm" onClick={() => setPendingReset('pxpipe_models')}>
Reset scope
</AdminButton>
</div>
)}
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-rtk-compress" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-rtk-compress"
type="checkbox"
checked={cfg.rtk_compress}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('rtk_compress', e.target.checked)}
/>
Tool-output compression (RTK)
</label>
<div className="dim" style={{ fontSize: 12 }}>
Command-aware filtering of tool-result text (test/build/git/log output) using the RTK
filter catalog. Shrinks noisy machine output before it reaches the backend; deterministic
and cache-safe. Off by default. Applies to Anthropic passthrough and translate paths.
</div>
{cfg.overridden_keys.includes('rtk_compress') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('rtk_compress')}>
Reset
</AdminButton>
</div>
)}
{cfg.rtk_compress && (
<div style={{ marginTop: 8 }}>
<div className="form-label" style={{ fontSize: 13 }}>Models in scope (CSV, empty = all)</div>
<input
type="text"
className="form-input"
key={cfg.rtk_models}
defaultValue={cfg.rtk_models}
placeholder="empty = all models; e.g. claude, gpt-5"
disabled={save.isPending}
onBlur={(e) => {
const v = e.target.value.trim()
if (v === (cfg.rtk_models ?? '')) return
save.mutate({ rtk_models: v })
}}
/>
{cfg.overridden_keys.includes('rtk_models') && (
<div className="form-row" style={{ marginTop: 6 }}>
<AdminButton size="sm" onClick={() => setPendingReset('rtk_models')}>
Reset scope
</AdminButton>
</div>
)}
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-forward-client-auth" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-forward-client-auth"
type="checkbox"
checked={cfg.forward_client_auth}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('forward_client_auth', e.target.checked)}
/>
Forward client credential (Anthropic passthrough)
</label>
<div className="dim" style={{ fontSize: 12 }}>
Forwards the client's own x-api-key/Authorization header upstream instead of the
operator's configured credential (BACKEND=anthropic passthrough only, single-key/BYOK
deployments). The proxy refuses to enable this with 2+ PROXY_API_KEYS entries and no
PROXY_OPEN_RELAY. Off by default.
</div>
{cfg.overridden_keys.includes('forward_client_auth') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('forward_client_auth')}>
Reset
</AdminButton>
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-tool-guardrail-mode">Tool guardrail mode</label>
<div className="form-row">
<select
id="cfg-tool-guardrail-mode"
value={cfg.tool_guardrail_mode}
disabled={save.isPending}
onChange={(e) => save.mutate({ tool_guardrail_mode: e.target.value })}
>
<option value="disabled">Disabled</option>
<option value="standard">Standard</option>
</select>
{cfg.overridden_keys.includes('tool_guardrail_mode') && (
<AdminButton size="sm" onClick={() => setPendingReset('tool_guardrail_mode')}>
Reset
</AdminButton>
)}
</div>
<div className="dim" style={{ fontSize: 12 }}>
Applies advisory guardrails to tool calls the proxy auto-executes. Disabled by default.
</div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-optimizer-mode">Prompt compression (optimizer)</label>
<div className="form-row">
<select
id="cfg-optimizer-mode"
value={cfg.optimizer_mode}
// Gate enabling on the ONNX model when the tier is compiled in but the
// model isn't downloaded yet (point 2: "not toggle if not detected").
disabled={save.isPending || (!!model?.compiled_in && !model?.present)}
onChange={(e) => save.mutate({ optimizer_mode: e.target.value })}
>
<option value="off">Off</option>
<option value="shadow">Shadow (report only)</option>
<option value="live">Live (compress)</option>
</select>
{cfg.overridden_keys.includes('optimizer_mode') && (
<AdminButton size="sm" onClick={() => setPendingReset('optimizer_mode')}>
Reset
</AdminButton>
)}
</div>
<div className="dim" style={{ fontSize: 12 }}>
Frozen-Frontier compression of long conversation history (latest turn untouched). Off by default.
</div>
{model && !model.compiled_in && (
<div className="dim" style={{ fontSize: 12, marginTop: 8 }}>
Heuristic scorer only. Rebuild the proxy with <code>--features optimizer-onnx</code> to enable the LLMLingua-2 ONNX scorer.
</div>
)}
{model?.compiled_in && !model.present && !model.downloading && (
<div className="form-row" style={{ marginTop: 8 }}>
<AdminButton
size="sm"
disabled={downloadModel.isPending}
onClick={() => downloadModel.mutate()}
>
Download model ({fmtMB(model.size_bytes)})
</AdminButton>
<span className="dim" style={{ fontSize: 12 }}>
Required before enabling. Verified against a pinned sha256.
</span>
</div>
)}
{model?.downloading && (
<div className="dim" style={{ fontSize: 12, marginTop: 8 }}>
Downloading and verifying model ({fmtMB(model.size_bytes)})
</div>
)}
{model?.error && !model.downloading && (
<div style={{ fontSize: 12, marginTop: 8, color: 'var(--danger, #c0392b)' }}>
Download failed: {model.error}
</div>
)}
{model?.compiled_in && model.present && (
<div className="dim" style={{ fontSize: 12, marginTop: 8 }}>
ONNX scorer ready live mode uses LLMLingua-2 (loaded on the next request).
</div>
)}
</div>
{cfg.entries.filter((entry) => !['redact_secrets', 'log_bodies', 'anthropic_thinking_repair', 'pxpipe_compress', 'pxpipe_models', 'rtk_compress', 'rtk_models', 'forward_client_auth', 'tool_guardrail_mode', 'optimizer_mode'].includes(entry.key)).map((entry) => {
const inputId = `cfg-${entry.key}`
return (
<div className="form-group" key={entry.key}>
<label className="form-label" htmlFor={inputId}>{entry.key}</label>
<div className="form-row">
<input
id={inputId}
name={entry.key}
value={form[entry.key] ?? entry.value}
onChange={(e) => setForm((f) => ({ ...f, [entry.key]: e.target.value }))}
/>
<AdminButton tone="primary" size="sm" onClick={() => handleSave(entry.key, entry.value)}>Save</AdminButton>
<AdminButton size="sm" onClick={() => setPendingReset(entry.key)}>Reset</AdminButton>
</div>
</div>
)
})}
</div>
)}
{envData && (
<div className="readonly-section" style={{ marginTop: 16 }}>
<div className="section-label">Environment</div>
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: '4px 12px', marginTop: 8, fontSize: 12 }}>
{Object.entries(envData).map(([k, v]) => (
<Fragment key={k}>
<span className="dim">{k}</span>
<span className="mono">{v}</span>
</Fragment>
))}
</div>
</div>
)}
<ConfirmDialog
open={pendingReset !== null}
onClose={() => setPendingReset(null)}
onConfirm={doReset}
title="Reset override?"
message={
<>
Reset override for <span className="mono">{pendingReset}</span>? The runtime value will revert
to the env-file or default. Active connections are not affected.
</>
}
confirmLabel="Reset"
variant="primary"
/>
{/* Environment grid */}
<EnvVariablesSection envData={envData} />
</div>
)
}
+36
View File
@@ -875,6 +875,30 @@
"output_cost_per_token": 3e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-5.6",
"input_cost_per_token": 5e-06,
"output_cost_per_token": 3e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-5.6-luna",
"input_cost_per_token": 1e-06,
"output_cost_per_token": 6e-06,
"provider": "openai"
},
{
"model_pattern": "gpt-5.6-sol",
"input_cost_per_token": 5e-06,
"output_cost_per_token": 3e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-5.6-terra",
"input_cost_per_token": 2.5e-06,
"output_cost_per_token": 1.5e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-audio",
"input_cost_per_token": 2.5e-06,
@@ -929,6 +953,18 @@
"output_cost_per_token": 1.6e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-realtime-2.1",
"input_cost_per_token": 4e-06,
"output_cost_per_token": 2.4e-05,
"provider": "openai"
},
{
"model_pattern": "gpt-realtime-2.1-mini",
"input_cost_per_token": 6e-07,
"output_cost_per_token": 2.4e-06,
"provider": "openai"
},
{
"model_pattern": "gpt-realtime-2025-08-28",
"input_cost_per_token": 4e-06,
+2 -209
View File
@@ -458,212 +458,5 @@ pub fn reorder_route_providers(
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::db::backends::ManagedBackendRow;
fn in_memory_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
super::super::init_db(&conn).unwrap();
conn
}
fn test_row(name: &str) -> ManagedBackendRow {
ManagedBackendRow {
id: format!("id-{name}"),
name: name.to_string(),
provider_id: "openai".to_string(),
api_key: Some("sk-test".to_string()),
api_base: None,
deployment: None,
api_version: None,
project: None,
region: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_session_token: None,
rpm: Some(100),
tpm: Some(10_000),
enabled: true,
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
}
}
fn seed_route_with_providers(conn: &Connection, count: usize) -> (String, Vec<String>) {
let route = RouteRow {
id: uuid::Uuid::new_v4().to_string(),
name: "r".into(),
description: None,
strategy: "failover".into(),
rpm: None,
tpm: None,
budget_usd: None,
enabled: true,
guardrail_mode: None,
pxpipe_compress: None,
pxpipe_models: None,
redact_secrets: None,
position: 0,
created_at: now_iso8601(),
updated_at: now_iso8601(),
};
insert_route(conn, &route).unwrap();
for i in 0..count {
let mut b = test_row(&format!("b{i}"));
b.id = format!("backend-{i}");
crate::admin::db::backends::insert_managed_backend(conn, &b).unwrap();
add_route_provider(conn, &route.id, &b.id, &["*".to_string()], i as i32, true).unwrap();
}
let ids: Vec<String> = list_route_providers(conn, &route.id)
.unwrap()
.into_iter()
.map(|p| p.id)
.collect();
(route.id, ids)
}
#[test]
fn update_route_sets_and_clears_option_override() {
let conn = in_memory_db();
let (route_id, _) = seed_route_with_providers(&conn, 1);
// Set an override.
let set = RoutePatch {
name: None,
description: None,
strategy: None,
rpm: None,
tpm: None,
budget_usd: None,
enabled: Some(false),
guardrail_mode: Some(Some("standard".into())),
pxpipe_compress: Some(Some(true)),
pxpipe_models: None,
redact_secrets: None,
position: None,
};
assert!(update_route(&conn, &route_id, &set).unwrap());
let r = get_route(&conn, &route_id).unwrap().unwrap();
assert!(!r.enabled);
assert_eq!(r.guardrail_mode.as_deref(), Some("standard"));
assert_eq!(r.pxpipe_compress, Some(true));
// Clear the override back to NULL (inherit).
let clear = RoutePatch {
name: None,
description: None,
strategy: None,
rpm: None,
tpm: None,
budget_usd: None,
enabled: None,
guardrail_mode: Some(None),
pxpipe_compress: Some(None),
pxpipe_models: None,
redact_secrets: None,
position: None,
};
assert!(update_route(&conn, &route_id, &clear).unwrap());
let r = get_route(&conn, &route_id).unwrap().unwrap();
assert_eq!(r.guardrail_mode, None);
assert_eq!(r.pxpipe_compress, None);
// enabled was left unchanged (None) — still false.
assert!(!r.enabled);
}
#[test]
fn disabled_route_excluded_from_enabled_route_ids() {
let conn = in_memory_db();
let (route_id, _) = seed_route_with_providers(&conn, 1);
// The seeded backend is "b0"; while enabled the route id is returned.
assert_eq!(
enabled_route_ids_for_backend_name(&conn, "b0").unwrap(),
vec![route_id.clone()]
);
// Disable the route -> it drops out of the virtual-key scope query.
let patch = RoutePatch {
name: None,
description: None,
strategy: None,
rpm: None,
tpm: None,
budget_usd: None,
enabled: Some(false),
guardrail_mode: None,
pxpipe_compress: None,
pxpipe_models: None,
redact_secrets: None,
position: None,
};
assert!(update_route(&conn, &route_id, &patch).unwrap());
assert!(enabled_route_ids_for_backend_name(&conn, "b0")
.unwrap()
.is_empty());
}
#[test]
fn reorder_route_providers_rewrites_priorities() {
let conn = in_memory_db();
let (route_id, ids) = seed_route_with_providers(&conn, 3);
// Reverse the order.
let reversed: Vec<String> = ids.iter().rev().cloned().collect();
let outcome = reorder_route_providers(&conn, &route_id, &reversed).unwrap();
match outcome {
ReorderOutcome::Ok(rows) => {
let new_order: Vec<String> = rows.iter().map(|p| p.id.clone()).collect();
assert_eq!(new_order, reversed);
for (i, row) in rows.iter().enumerate() {
assert_eq!(row.priority, i as i32);
}
}
ReorderOutcome::Mismatch => panic!("expected Ok"),
}
}
#[test]
fn reorder_route_providers_mismatch_rolls_back() {
let conn = in_memory_db();
let (route_id, ids) = seed_route_with_providers(&conn, 3);
// Submit a subset — should be rejected as Mismatch, priorities unchanged.
let partial: Vec<String> = ids.iter().take(2).cloned().collect();
let outcome = reorder_route_providers(&conn, &route_id, &partial).unwrap();
assert!(matches!(outcome, ReorderOutcome::Mismatch));
// Priorities must be untouched.
let rows = list_route_providers(&conn, &route_id).unwrap();
for (i, row) in rows.iter().enumerate() {
assert_eq!(
row.priority, i as i32,
"priorities must be unchanged after Mismatch"
);
}
}
#[test]
fn reorder_route_providers_rejects_duplicates_and_extras() {
let conn = in_memory_db();
let (route_id, ids) = seed_route_with_providers(&conn, 3);
// Duplicate id.
let dup = vec![ids[0].clone(), ids[0].clone(), ids[1].clone()];
assert!(matches!(
reorder_route_providers(&conn, &route_id, &dup).unwrap(),
ReorderOutcome::Mismatch
));
// Extra id that isn't a provider on this route.
let mut extra = ids.clone();
extra.push("bogus-id".into());
assert!(matches!(
reorder_route_providers(&conn, &route_id, &extra).unwrap(),
ReorderOutcome::Mismatch
));
}
}
#[path = "routes/tests.rs"]
mod tests;
+207
View File
@@ -0,0 +1,207 @@
use super::*;
use crate::admin::db::backends::ManagedBackendRow;
fn in_memory_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
super::super::init_db(&conn).unwrap();
conn
}
fn test_row(name: &str) -> ManagedBackendRow {
ManagedBackendRow {
id: format!("id-{name}"),
name: name.to_string(),
provider_id: "openai".to_string(),
api_key: Some("sk-test".to_string()),
api_base: None,
deployment: None,
api_version: None,
project: None,
region: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_session_token: None,
rpm: Some(100),
tpm: Some(10_000),
enabled: true,
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
}
}
fn seed_route_with_providers(conn: &Connection, count: usize) -> (String, Vec<String>) {
let route = RouteRow {
id: uuid::Uuid::new_v4().to_string(),
name: "r".into(),
description: None,
strategy: "failover".into(),
rpm: None,
tpm: None,
budget_usd: None,
enabled: true,
guardrail_mode: None,
pxpipe_compress: None,
pxpipe_models: None,
redact_secrets: None,
position: 0,
created_at: now_iso8601(),
updated_at: now_iso8601(),
};
insert_route(conn, &route).unwrap();
for i in 0..count {
let mut b = test_row(&format!("b{i}"));
b.id = format!("backend-{i}");
crate::admin::db::backends::insert_managed_backend(conn, &b).unwrap();
add_route_provider(conn, &route.id, &b.id, &["*".to_string()], i as i32, true).unwrap();
}
let ids: Vec<String> = list_route_providers(conn, &route.id)
.unwrap()
.into_iter()
.map(|p| p.id)
.collect();
(route.id, ids)
}
#[test]
fn update_route_sets_and_clears_option_override() {
let conn = in_memory_db();
let (route_id, _) = seed_route_with_providers(&conn, 1);
// Set an override.
let set = RoutePatch {
name: None,
description: None,
strategy: None,
rpm: None,
tpm: None,
budget_usd: None,
enabled: Some(false),
guardrail_mode: Some(Some("standard".into())),
pxpipe_compress: Some(Some(true)),
pxpipe_models: None,
redact_secrets: None,
position: None,
};
assert!(update_route(&conn, &route_id, &set).unwrap());
let r = get_route(&conn, &route_id).unwrap().unwrap();
assert!(!r.enabled);
assert_eq!(r.guardrail_mode.as_deref(), Some("standard"));
assert_eq!(r.pxpipe_compress, Some(true));
// Clear the override back to NULL (inherit).
let clear = RoutePatch {
name: None,
description: None,
strategy: None,
rpm: None,
tpm: None,
budget_usd: None,
enabled: None,
guardrail_mode: Some(None),
pxpipe_compress: Some(None),
pxpipe_models: None,
redact_secrets: None,
position: None,
};
assert!(update_route(&conn, &route_id, &clear).unwrap());
let r = get_route(&conn, &route_id).unwrap().unwrap();
assert_eq!(r.guardrail_mode, None);
assert_eq!(r.pxpipe_compress, None);
// enabled was left unchanged (None) — still false.
assert!(!r.enabled);
}
#[test]
fn disabled_route_excluded_from_enabled_route_ids() {
let conn = in_memory_db();
let (route_id, _) = seed_route_with_providers(&conn, 1);
// The seeded backend is "b0"; while enabled the route id is returned.
assert_eq!(
enabled_route_ids_for_backend_name(&conn, "b0").unwrap(),
vec![route_id.clone()]
);
// Disable the route -> it drops out of the virtual-key scope query.
let patch = RoutePatch {
name: None,
description: None,
strategy: None,
rpm: None,
tpm: None,
budget_usd: None,
enabled: Some(false),
guardrail_mode: None,
pxpipe_compress: None,
pxpipe_models: None,
redact_secrets: None,
position: None,
};
assert!(update_route(&conn, &route_id, &patch).unwrap());
assert!(enabled_route_ids_for_backend_name(&conn, "b0")
.unwrap()
.is_empty());
}
#[test]
fn reorder_route_providers_rewrites_priorities() {
let conn = in_memory_db();
let (route_id, ids) = seed_route_with_providers(&conn, 3);
// Reverse the order.
let reversed: Vec<String> = ids.iter().rev().cloned().collect();
let outcome = reorder_route_providers(&conn, &route_id, &reversed).unwrap();
match outcome {
ReorderOutcome::Ok(rows) => {
let new_order: Vec<String> = rows.iter().map(|p| p.id.clone()).collect();
assert_eq!(new_order, reversed);
for (i, row) in rows.iter().enumerate() {
assert_eq!(row.priority, i as i32);
}
}
ReorderOutcome::Mismatch => panic!("expected Ok"),
}
}
#[test]
fn reorder_route_providers_mismatch_rolls_back() {
let conn = in_memory_db();
let (route_id, ids) = seed_route_with_providers(&conn, 3);
// Submit a subset — should be rejected as Mismatch, priorities unchanged.
let partial: Vec<String> = ids.iter().take(2).cloned().collect();
let outcome = reorder_route_providers(&conn, &route_id, &partial).unwrap();
assert!(matches!(outcome, ReorderOutcome::Mismatch));
// Priorities must be untouched.
let rows = list_route_providers(&conn, &route_id).unwrap();
for (i, row) in rows.iter().enumerate() {
assert_eq!(
row.priority, i as i32,
"priorities must be unchanged after Mismatch"
);
}
}
#[test]
fn reorder_route_providers_rejects_duplicates_and_extras() {
let conn = in_memory_db();
let (route_id, ids) = seed_route_with_providers(&conn, 3);
// Duplicate id.
let dup = vec![ids[0].clone(), ids[0].clone(), ids[1].clone()];
assert!(matches!(
reorder_route_providers(&conn, &route_id, &dup).unwrap(),
ReorderOutcome::Mismatch
));
// Extra id that isn't a provider on this route.
let mut extra = ids.clone();
extra.push("bogus-id".into());
assert!(matches!(
reorder_route_providers(&conn, &route_id, &extra).unwrap(),
ReorderOutcome::Mismatch
));
}
-69
View File
@@ -7,75 +7,6 @@ use axum::{
};
use std::net::SocketAddr;
/// GET /admin/api/env -- effective environment variable values.
/// Secrets (API keys, tokens) are masked; plain config values are shown as-is.
pub(super) async fn get_env() -> Json<serde_json::Value> {
fn plain(key: &str) -> serde_json::Value {
match std::env::var(key) {
Ok(v) if !v.is_empty() => serde_json::Value::String(v),
_ => serde_json::Value::Null,
}
}
fn secret(key: &str) -> serde_json::Value {
match std::env::var(key) {
Ok(v) if !v.is_empty() => {
serde_json::Value::String(anyllm_translate::util::redact::redact_secret(&v))
}
_ => serde_json::Value::Null,
}
}
Json(serde_json::json!({
// Core proxy config
"BACKEND": plain("BACKEND"),
"LISTEN_PORT": plain("LISTEN_PORT"),
"BIG_MODEL": plain("BIG_MODEL"),
"SMALL_MODEL": plain("SMALL_MODEL"),
"RUST_LOG": plain("RUST_LOG"),
"LOG_BODIES": plain("LOG_BODIES"),
"REDACT_SECRETS": plain("REDACT_SECRETS"),
"PROXY_CONFIG": plain("PROXY_CONFIG"),
// OpenAI / compatible
"OPENAI_BASE_URL": plain("OPENAI_BASE_URL"),
"OPENAI_API_FORMAT": plain("OPENAI_API_FORMAT"),
"OPENAI_API_KEY": secret("OPENAI_API_KEY"),
// Vertex AI
"VERTEX_PROJECT": plain("VERTEX_PROJECT"),
"VERTEX_REGION": plain("VERTEX_REGION"),
"VERTEX_API_KEY": secret("VERTEX_API_KEY"),
// Gemini
"GEMINI_BASE_URL": plain("GEMINI_BASE_URL"),
"GEMINI_API_KEY": secret("GEMINI_API_KEY"),
// Azure OpenAI
"AZURE_OPENAI_ENDPOINT": plain("AZURE_OPENAI_ENDPOINT"),
"AZURE_OPENAI_DEPLOYMENT": plain("AZURE_OPENAI_DEPLOYMENT"),
"AZURE_OPENAI_API_KEY": secret("AZURE_OPENAI_API_KEY"),
"AZURE_OPENAI_API_VERSION": plain("AZURE_OPENAI_API_VERSION"),
// AWS Bedrock
"AWS_REGION": plain("AWS_REGION"),
"AWS_ACCESS_KEY_ID": secret("AWS_ACCESS_KEY_ID"),
"AWS_SECRET_ACCESS_KEY": secret("AWS_SECRET_ACCESS_KEY"),
"AWS_SESSION_TOKEN": secret("AWS_SESSION_TOKEN"),
// Google OAuth bearer token (full token — treat as secret)
"GOOGLE_ACCESS_TOKEN": secret("GOOGLE_ACCESS_TOKEN"),
// Auth
"PROXY_API_KEYS": secret("PROXY_API_KEYS"),
"PROXY_OPEN_RELAY": plain("PROXY_OPEN_RELAY"),
// TLS
"TLS_CLIENT_CERT_P12": plain("TLS_CLIENT_CERT_P12"),
"TLS_CA_CERT": plain("TLS_CA_CERT"),
// Network / security
"IP_ALLOWLIST": plain("IP_ALLOWLIST"),
"TRUST_PROXY_HEADERS": plain("TRUST_PROXY_HEADERS"),
"WEBHOOK_URLS": plain("WEBHOOK_URLS"),
"RATE_LIMIT_FAIL_POLICY": plain("RATE_LIMIT_FAIL_POLICY"),
// Admin
"ADMIN_PORT": plain("ADMIN_PORT"),
"ADMIN_DB_PATH": plain("ADMIN_DB_PATH"),
"ADMIN_LOG_RETENTION_DAYS": plain("ADMIN_LOG_RETENTION_DAYS"),
}))
}
/// GET /admin/api/config -- effective config (env defaults + overrides).
pub(super) async fn get_config(State(shared): State<SharedState>) -> Json<serde_json::Value> {
// Clone config snapshot and drop the read guard before any .await points.
+69
View File
@@ -17,6 +17,75 @@ use axum::{
use serde::Serialize;
use std::net::SocketAddr;
/// GET /admin/api/env -- effective environment variable values.
/// Secrets (API keys, tokens) are masked; plain config values are shown as-is.
pub(super) async fn get_env() -> Json<serde_json::Value> {
fn plain(key: &str) -> serde_json::Value {
match std::env::var(key) {
Ok(v) if !v.is_empty() => serde_json::Value::String(v),
_ => serde_json::Value::Null,
}
}
fn secret(key: &str) -> serde_json::Value {
match std::env::var(key) {
Ok(v) if !v.is_empty() => {
serde_json::Value::String(anyllm_translate::util::redact::redact_secret(&v))
}
_ => serde_json::Value::Null,
}
}
Json(serde_json::json!({
// Core proxy config
"BACKEND": plain("BACKEND"),
"LISTEN_PORT": plain("LISTEN_PORT"),
"BIG_MODEL": plain("BIG_MODEL"),
"SMALL_MODEL": plain("SMALL_MODEL"),
"RUST_LOG": plain("RUST_LOG"),
"LOG_BODIES": plain("LOG_BODIES"),
"REDACT_SECRETS": plain("REDACT_SECRETS"),
"PROXY_CONFIG": plain("PROXY_CONFIG"),
// OpenAI / compatible
"OPENAI_BASE_URL": plain("OPENAI_BASE_URL"),
"OPENAI_API_FORMAT": plain("OPENAI_API_FORMAT"),
"OPENAI_API_KEY": secret("OPENAI_API_KEY"),
// Vertex AI
"VERTEX_PROJECT": plain("VERTEX_PROJECT"),
"VERTEX_REGION": plain("VERTEX_REGION"),
"VERTEX_API_KEY": secret("VERTEX_API_KEY"),
// Gemini
"GEMINI_BASE_URL": plain("GEMINI_BASE_URL"),
"GEMINI_API_KEY": secret("GEMINI_API_KEY"),
// Azure OpenAI
"AZURE_OPENAI_ENDPOINT": plain("AZURE_OPENAI_ENDPOINT"),
"AZURE_OPENAI_DEPLOYMENT": plain("AZURE_OPENAI_DEPLOYMENT"),
"AZURE_OPENAI_API_KEY": secret("AZURE_OPENAI_API_KEY"),
"AZURE_OPENAI_API_VERSION": plain("AZURE_OPENAI_API_VERSION"),
// AWS Bedrock
"AWS_REGION": plain("AWS_REGION"),
"AWS_ACCESS_KEY_ID": secret("AWS_ACCESS_KEY_ID"),
"AWS_SECRET_ACCESS_KEY": secret("AWS_SECRET_ACCESS_KEY"),
"AWS_SESSION_TOKEN": secret("AWS_SESSION_TOKEN"),
// Google OAuth bearer token (full token — treat as secret)
"GOOGLE_ACCESS_TOKEN": secret("GOOGLE_ACCESS_TOKEN"),
// Auth
"PROXY_API_KEYS": secret("PROXY_API_KEYS"),
"PROXY_OPEN_RELAY": plain("PROXY_OPEN_RELAY"),
// TLS
"TLS_CLIENT_CERT_P12": plain("TLS_CLIENT_CERT_P12"),
"TLS_CA_CERT": plain("TLS_CA_CERT"),
// Network / security
"IP_ALLOWLIST": plain("IP_ALLOWLIST"),
"TRUST_PROXY_HEADERS": plain("TRUST_PROXY_HEADERS"),
"WEBHOOK_URLS": plain("WEBHOOK_URLS"),
"RATE_LIMIT_FAIL_POLICY": plain("RATE_LIMIT_FAIL_POLICY"),
// Admin
"ADMIN_PORT": plain("ADMIN_PORT"),
"ADMIN_DB_PATH": plain("ADMIN_DB_PATH"),
"ADMIN_LOG_RETENTION_DAYS": plain("ADMIN_LOG_RETENTION_DAYS"),
}))
}
/// Maximum accepted upload size for an env file. Env files are tiny;
/// anything larger is almost certainly the wrong file type.
/// The router already enforces a 1 MB body limit; this is a tighter application-level check.
+1 -1
View File
@@ -83,7 +83,7 @@ pub fn admin_router(shared: SharedState, token: Arc<zeroize::Zeroizing<String>>)
"/admin/api/config/overrides/{key}",
delete(config::delete_config_override),
)
.route("/admin/api/env", get(config::get_env))
.route("/admin/api/env", get(env::get_env))
.route("/admin/api/env/import", post(env::import_env))
.route("/admin/api/env/export", get(env::export_env))
.route("/admin/api/metrics", get(logs::get_metrics))
+2 -100
View File
@@ -495,103 +495,5 @@ fn ensure_local_discover_host(parsed: &url::Url) -> Result<(), String> {
}
#[cfg(test)]
mod tests {
use super::*;
fn custom_request(url: &str) -> DiscoverRequest {
DiscoverRequest {
source: "custom".to_string(),
url: Some(url.to_string()),
provider_id: None,
api_key: None,
}
}
#[test]
fn custom_discover_rejects_loopback_url() {
let err = resolve_discover_target(&custom_request("http://127.0.0.1:11434"), false)
.expect_err("loopback custom discovery URL must be rejected");
assert!(err.contains("private/loopback"));
}
#[test]
fn custom_discover_rejects_file_scheme() {
let err = resolve_discover_target(&custom_request("file:///tmp/anyllm"), false)
.expect_err("non-http custom discovery URL must be rejected");
assert!(err.contains("scheme"));
}
#[test]
fn custom_discover_accepts_https_url_and_appends_models() {
let (url, api_key) = resolve_discover_target(&custom_request("https://1.1.1.1"), false)
.expect("public HTTPS custom discovery URL should be accepted");
assert_eq!(url, "https://1.1.1.1/v1/models");
assert_eq!(api_key, None);
}
#[test]
fn custom_discover_accepts_existing_models_suffix() {
let (url, api_key) =
resolve_discover_target(&custom_request("https://1.1.1.1/v1/models"), false)
.expect("public HTTPS custom discovery URL should be accepted");
assert_eq!(url, "https://1.1.1.1/v1/models");
assert_eq!(api_key, None);
}
#[test]
fn custom_discover_does_not_double_v1_suffix() {
// Local-provider catalog defaults already end in /v1; don't produce /v1/v1/models.
let (url, _) =
resolve_discover_target(&custom_request("http://192.168.1.72:4444/v1"), true)
.expect("local LAN /v1 discovery URL should be accepted when allow_local");
assert_eq!(url, "http://192.168.1.72:4444/v1/models");
// Trailing slash on a /v1 base collapses the same way.
let (url, _) =
resolve_discover_target(&custom_request("http://192.168.1.72:4444/v1/"), true)
.expect("trailing-slash /v1 discovery URL should be accepted when allow_local");
assert_eq!(url, "http://192.168.1.72:4444/v1/models");
}
#[test]
fn custom_discover_local_allows_loopback_but_keeps_scheme_check() {
// allow_local=true: loopback is accepted (LM Studio/Ollama on localhost)...
let (url, _) = resolve_discover_target(&custom_request("http://127.0.0.1:1234"), true)
.expect("local loopback discovery URL should be accepted when allow_local");
assert_eq!(url, "http://127.0.0.1:1234/v1/models");
// ...but a non-http scheme is still rejected.
let err = resolve_discover_target(&custom_request("file:///tmp/anyllm"), true)
.expect_err("non-http scheme must be rejected even when allow_local");
assert!(err.contains("scheme"));
}
#[test]
fn custom_discover_threads_api_key() {
let mut req = custom_request("http://192.168.1.72:4444");
req.api_key = Some("sk-local".to_string());
let (_, api_key) = resolve_discover_target(&req, true)
.expect("local LAN discovery URL should be accepted when allow_local");
assert_eq!(api_key.as_deref(), Some("sk-local"));
}
#[test]
fn custom_discover_local_rejects_public_and_metadata_hosts() {
// allow_local must NOT permit public hosts (would be a general outbound fetch).
let err = resolve_discover_target(&custom_request("http://1.1.1.1/v1"), true)
.expect_err("public IP must be rejected even when allow_local");
assert!(err.contains("loopback/LAN"));
// Cloud-metadata / link-local stay blocked here too.
let err = resolve_discover_target(&custom_request("http://169.254.169.254/"), true)
.expect_err("metadata IP must be rejected even when allow_local");
assert!(err.contains("loopback/LAN"));
// A bare host name that isn't `localhost` cannot be verified as local.
let err = resolve_discover_target(&custom_request("http://evil.example.com/"), true)
.expect_err("non-local host name must be rejected when allow_local");
assert!(err.contains("loopback/LAN"));
}
}
#[path = "models/tests.rs"]
mod tests;
@@ -0,0 +1,96 @@
use super::*;
fn custom_request(url: &str) -> DiscoverRequest {
DiscoverRequest {
source: "custom".to_string(),
url: Some(url.to_string()),
provider_id: None,
api_key: None,
}
}
#[test]
fn custom_discover_rejects_loopback_url() {
let err = resolve_discover_target(&custom_request("http://127.0.0.1:11434"), false)
.expect_err("loopback custom discovery URL must be rejected");
assert!(err.contains("private/loopback"));
}
#[test]
fn custom_discover_rejects_file_scheme() {
let err = resolve_discover_target(&custom_request("file:///tmp/anyllm"), false)
.expect_err("non-http custom discovery URL must be rejected");
assert!(err.contains("scheme"));
}
#[test]
fn custom_discover_accepts_https_url_and_appends_models() {
let (url, api_key) = resolve_discover_target(&custom_request("https://1.1.1.1"), false)
.expect("public HTTPS custom discovery URL should be accepted");
assert_eq!(url, "https://1.1.1.1/v1/models");
assert_eq!(api_key, None);
}
#[test]
fn custom_discover_accepts_existing_models_suffix() {
let (url, api_key) =
resolve_discover_target(&custom_request("https://1.1.1.1/v1/models"), false)
.expect("public HTTPS custom discovery URL should be accepted");
assert_eq!(url, "https://1.1.1.1/v1/models");
assert_eq!(api_key, None);
}
#[test]
fn custom_discover_does_not_double_v1_suffix() {
// Local-provider catalog defaults already end in /v1; don't produce /v1/v1/models.
let (url, _) = resolve_discover_target(&custom_request("http://192.168.1.72:4444/v1"), true)
.expect("local LAN /v1 discovery URL should be accepted when allow_local");
assert_eq!(url, "http://192.168.1.72:4444/v1/models");
// Trailing slash on a /v1 base collapses the same way.
let (url, _) = resolve_discover_target(&custom_request("http://192.168.1.72:4444/v1/"), true)
.expect("trailing-slash /v1 discovery URL should be accepted when allow_local");
assert_eq!(url, "http://192.168.1.72:4444/v1/models");
}
#[test]
fn custom_discover_local_allows_loopback_but_keeps_scheme_check() {
// allow_local=true: loopback is accepted (LM Studio/Ollama on localhost)...
let (url, _) = resolve_discover_target(&custom_request("http://127.0.0.1:1234"), true)
.expect("local loopback discovery URL should be accepted when allow_local");
assert_eq!(url, "http://127.0.0.1:1234/v1/models");
// ...but a non-http scheme is still rejected.
let err = resolve_discover_target(&custom_request("file:///tmp/anyllm"), true)
.expect_err("non-http scheme must be rejected even when allow_local");
assert!(err.contains("scheme"));
}
#[test]
fn custom_discover_threads_api_key() {
let mut req = custom_request("http://192.168.1.72:4444");
req.api_key = Some("sk-local".to_string());
let (_, api_key) = resolve_discover_target(&req, true)
.expect("local LAN discovery URL should be accepted when allow_local");
assert_eq!(api_key.as_deref(), Some("sk-local"));
}
#[test]
fn custom_discover_local_rejects_public_and_metadata_hosts() {
// allow_local must NOT permit public hosts (would be a general outbound fetch).
let err = resolve_discover_target(&custom_request("http://1.1.1.1/v1"), true)
.expect_err("public IP must be rejected even when allow_local");
assert!(err.contains("loopback/LAN"));
// Cloud-metadata / link-local stay blocked here too.
let err = resolve_discover_target(&custom_request("http://169.254.169.254/"), true)
.expect_err("metadata IP must be rejected even when allow_local");
assert!(err.contains("loopback/LAN"));
// A bare host name that isn't `localhost` cannot be verified as local.
let err = resolve_discover_target(&custom_request("http://evil.example.com/"), true)
.expect_err("non-local host name must be rejected when allow_local");
assert!(err.contains("loopback/LAN"));
}
+42
View File
@@ -0,0 +1,42 @@
/// Configuration for the cache subsystem.
#[derive(Debug, Clone)]
pub struct CacheConfig {
/// Default TTL in seconds for cached responses.
pub ttl_secs: u64,
/// Maximum number of entries in the in-memory cache.
pub max_entries: u64,
/// Optional Redis URL. Used by the Redis L2 cache backend (requires `redis` feature)
/// and distributed rate limiting. When set, responses are cached in Redis in addition
/// to the in-memory L1 cache, and rate limit state is shared across proxy instances.
pub redis_url: Option<String>,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
ttl_secs: 300,
max_entries: 10_000,
redis_url: None,
}
}
}
impl CacheConfig {
/// Load cache configuration from environment variables.
pub fn from_env() -> Self {
let ttl_secs = std::env::var("CACHE_TTL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300);
let max_entries = std::env::var("CACHE_MAX_ENTRIES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10_000);
let redis_url = std::env::var("REDIS_URL").ok();
Self {
ttl_secs,
max_entries,
redis_url,
}
}
}
+189
View File
@@ -0,0 +1,189 @@
use super::CacheEntry;
use super::MAX_TTL_SECS;
use std::time::Duration;
/// Per-request cache controls after combining local and LiteLLM-style fields.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CacheControl {
/// Whether to read an existing cached response before calling upstream.
pub lookup: bool,
/// Whether to store a successful upstream response after the call.
pub store: bool,
/// Per-entry store TTL override in seconds.
pub ttl_secs: Option<u64>,
/// Maximum acceptable age for an existing cached entry.
pub max_age_secs: Option<u64>,
/// Optional caller namespace for exact-match cache isolation.
pub namespace: Option<String>,
/// Parsed for LiteLLM compatibility. Currently no behavior change.
pub use_cache: bool,
}
impl Default for CacheControl {
fn default() -> Self {
Self {
lookup: true,
store: true,
ttl_secs: None,
max_age_secs: None,
namespace: None,
use_cache: false,
}
}
}
/// Parse the optional `cache_ttl_secs` field from a request body.
///
/// Returns:
/// - `Ok(None)` if the field is absent or null (use default TTL).
/// - `Ok(Some(0))` if explicitly 0 (bypass cache).
/// - `Ok(Some(n))` for valid positive values up to MAX_TTL_SECS.
/// - `Err(message)` for negative values, values > MAX_TTL_SECS, or non-numeric.
pub fn parse_cache_ttl(body: &serde_json::Value) -> Result<Option<u64>, String> {
let Some(val) = body.get("cache_ttl_secs") else {
return Ok(None);
};
if val.is_null() {
return Ok(None);
}
if let Some(n) = val.as_u64() {
if n > MAX_TTL_SECS {
return Err(format!("cache_ttl_secs must be <= {MAX_TTL_SECS}, got {n}"));
}
return Ok(Some(n));
}
if let Some(n) = val.as_i64() {
// Negative values are invalid
return Err(format!("cache_ttl_secs must be non-negative, got {n}"));
}
if let Some(n) = val.as_f64() {
if n < 0.0 {
return Err(format!("cache_ttl_secs must be non-negative, got {n}"));
}
let truncated = n as u64;
if truncated > MAX_TTL_SECS {
return Err(format!(
"cache_ttl_secs must be <= {MAX_TTL_SECS}, got {truncated}"
));
}
return Ok(Some(truncated));
}
Err(format!("cache_ttl_secs must be a number, got {}", val))
}
/// Parse local `cache_ttl_secs` and LiteLLM-compatible top-level `cache`.
pub fn parse_cache_control(body: &serde_json::Value) -> Result<CacheControl, String> {
let mut control = CacheControl::default();
match parse_cache_ttl(body)? {
Some(0) => {
control.lookup = false;
control.store = false;
control.ttl_secs = Some(0);
}
Some(ttl) => {
control.ttl_secs = Some(ttl);
}
None => {}
}
let Some(cache_value) = body.get("cache") else {
return Ok(control);
};
if cache_value.is_null() {
return Ok(control);
}
let Some(cache_obj) = cache_value.as_object() else {
return Err(format!("cache must be an object, got {}", cache_value));
};
if parse_cache_bool_field(cache_obj, "no-cache")?.unwrap_or(false) {
control.lookup = false;
}
if parse_cache_bool_field(cache_obj, "no-store")?.unwrap_or(false) {
control.store = false;
}
if let Some(use_cache) = parse_cache_bool_field(cache_obj, "use-cache")? {
control.use_cache = use_cache;
}
if let Some(ttl) = parse_cache_secs_field(cache_obj, "ttl")? {
control.ttl_secs = Some(ttl);
}
if let Some(max_age) = parse_cache_secs_field(cache_obj, "s-maxage")? {
control.max_age_secs = Some(max_age);
}
if let Some(max_age) = parse_cache_secs_field(cache_obj, "s-max-age")? {
control.max_age_secs = Some(max_age);
}
if let Some(namespace_value) = cache_obj.get("namespace") {
if !namespace_value.is_null() {
let Some(namespace) = namespace_value.as_str() else {
return Err(format!(
"cache.namespace must be a string, got {}",
namespace_value
));
};
if !namespace.is_empty() {
control.namespace = Some(namespace.to_string());
}
}
}
Ok(control)
}
fn parse_cache_bool_field(
cache_obj: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<Option<bool>, String> {
let Some(value) = cache_obj.get(field) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value
.as_bool()
.map(Some)
.ok_or_else(|| format!("cache.{field} must be a boolean, got {value}"))
}
fn parse_cache_secs_field(
cache_obj: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<Option<u64>, String> {
let Some(value) = cache_obj.get(field) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
if let Some(n) = value.as_u64() {
if n > MAX_TTL_SECS {
return Err(format!("cache.{field} must be <= {MAX_TTL_SECS}, got {n}"));
}
return Ok(Some(n));
}
if let Some(n) = value.as_i64() {
return Err(format!("cache.{field} must be non-negative, got {n}"));
}
if let Some(n) = value.as_f64() {
if n < 0.0 {
return Err(format!("cache.{field} must be non-negative, got {n}"));
}
let truncated = n as u64;
if truncated > MAX_TTL_SECS {
return Err(format!(
"cache.{field} must be <= {MAX_TTL_SECS}, got {truncated}"
));
}
return Ok(Some(truncated));
}
Err(format!("cache.{field} must be a number, got {}", value))
}
pub fn cache_entry_is_fresh(entry: &CacheEntry, max_age_secs: Option<u64>) -> bool {
match max_age_secs {
Some(max_age) => entry.created_at.elapsed() <= Duration::from_secs(max_age),
None => true,
}
}
+148
View File
@@ -0,0 +1,148 @@
use sha2::{Digest, Sha256};
use std::io::{self, Write};
/// Namespace prefix for cache keys, preventing cross-endpoint collisions.
#[derive(Debug, Clone, Copy)]
pub enum CacheNamespace {
/// Anthropic /v1/messages endpoint.
Anthropic,
/// OpenAI /v1/chat/completions endpoint.
OpenAI,
}
impl CacheNamespace {
fn prefix(self) -> &'static str {
match self {
Self::Anthropic => "anth",
Self::OpenAI => "oai",
}
}
}
pub struct CacheScope<'a> {
pub backend_name: &'a str,
pub auth_identity: &'a str,
pub namespace: Option<&'a str>,
}
/// Compute a deterministic cache key for a request body.
///
/// Extracts the fields that affect response content, sorts them via BTreeMap,
/// serializes to canonical JSON, SHA-256 hashes the result, and prepends the
/// namespace prefix.
pub fn cache_key_for_request(
body: &serde_json::Value,
ns: CacheNamespace,
scope: &CacheScope<'_>,
) -> String {
let mut hasher = Sha256::new();
write_canonical_cache_body(&mut hasher, body, scope);
let hash = hasher.finalize();
let hex = hex::encode(hash);
format!("{}:{}", ns.prefix(), hex)
}
enum CacheField<'a> {
Json(&'a str, &'a serde_json::Value),
Str(&'static str, &'a str),
}
impl<'a> CacheField<'a> {
fn key(&self) -> &str {
match self {
Self::Json(key, _) | Self::Str(key, _) => key,
}
}
}
struct HashWriter<'a> {
hasher: &'a mut Sha256,
}
impl Write for HashWriter<'_> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.hasher.update(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn write_canonical_cache_body(
hasher: &mut Sha256,
body: &serde_json::Value,
scope: &CacheScope<'_>,
) {
let mut fields = Vec::new();
if let Some(obj) = body.as_object() {
fields.extend(
obj.iter()
.filter(|(key, value)| should_include_cache_field(key, value))
.map(|(key, value)| CacheField::Json(key.as_str(), value)),
);
}
fields.push(CacheField::Str("_scope_auth", scope.auth_identity));
fields.push(CacheField::Str("_scope_backend", scope.backend_name));
if let Some(namespace) = scope.namespace {
fields.push(CacheField::Str("_scope_cache_namespace", namespace));
}
fields.sort_unstable_by(|a, b| a.key().cmp(b.key()));
let mut writer = HashWriter { hasher };
writer
.write_all(b"{")
.expect("hash writer should not fail writing object start");
for (idx, field) in fields.iter().enumerate() {
if idx > 0 {
writer
.write_all(b",")
.expect("hash writer should not fail writing separator");
}
serde_json::to_writer(&mut writer, field.key())
.expect("hash writer should not fail writing key");
writer
.write_all(b":")
.expect("hash writer should not fail writing colon");
match field {
CacheField::Json(_, value) => serde_json::to_writer(&mut writer, value)
.expect("hash writer should not fail writing JSON value"),
CacheField::Str(_, value) => serde_json::to_writer(&mut writer, value)
.expect("hash writer should not fail writing string value"),
}
}
writer
.write_all(b"}")
.expect("hash writer should not fail writing object end");
}
fn should_include_cache_field(key: &str, value: &serde_json::Value) -> bool {
if value.is_null() {
return false;
}
// Exclude fields that do not affect the backend response:
// - stream / stream_options: transport only (a cached non-stream response is
// replayed as a stream and vice versa).
// - cache: request cache controls, handled outside response-content hashing.
// - _scope_*: added separately as scope fields.
// - user / metadata: tracking fields documented as "Ignored" (anyllm_translate
// ChatCompletionRequest user, anthropic Metadata). Hashing them fragments the
// cache per end-user with no correctness benefit (tenant isolation is already
// provided by _scope_auth).
//
// parallel_tool_calls is NOT excluded: backends that honor it (e.g. OpenAI)
// produce different output for true vs false, so it must be part of the cache
// identity. (Gemini/Vertex have it stripped before dispatch by the tool policy.)
!matches!(
key,
"stream"
| "stream_options"
| "cache"
| "_scope_auth"
| "_scope_backend"
| "_scope_cache_namespace"
| "user"
| "metadata"
)
}
+12 -881
View File
@@ -15,14 +15,23 @@ pub mod redis;
#[cfg(feature = "qdrant")]
pub mod semantic;
mod config;
mod control;
mod key;
#[cfg(test)]
mod tests;
use bytes::Bytes;
use sha2::{Digest, Sha256};
use std::io::{self, Write};
use std::time::{Duration, Instant};
use std::time::Instant;
/// Maximum allowed value for per-request `cache_ttl_secs`.
pub const MAX_TTL_SECS: u64 = 86_400;
pub use config::CacheConfig;
pub use control::{cache_entry_is_fresh, parse_cache_control, parse_cache_ttl, CacheControl};
pub use key::{cache_key_for_request, CacheNamespace, CacheScope};
/// Cached response entry stored in any cache backend.
#[derive(Clone, Debug)]
pub struct CacheEntry {
@@ -37,24 +46,6 @@ pub struct CacheEntry {
pub ttl_secs: Option<u64>,
}
/// Namespace prefix for cache keys, preventing cross-endpoint collisions.
#[derive(Debug, Clone, Copy)]
pub enum CacheNamespace {
/// Anthropic /v1/messages endpoint.
Anthropic,
/// OpenAI /v1/chat/completions endpoint.
OpenAI,
}
impl CacheNamespace {
fn prefix(self) -> &'static str {
match self {
Self::Anthropic => "anth",
Self::OpenAI => "oai",
}
}
}
/// Pluggable cache backend trait. Implementations must be Send + Sync
/// for use behind Arc in axum handlers.
pub trait CacheBackend: Send + Sync {
@@ -69,863 +60,3 @@ pub trait CacheBackend: Send + Sync {
ttl_secs: u64,
) -> impl std::future::Future<Output = ()> + Send;
}
/// Compute a deterministic cache key for a request body.
///
/// Extracts the fields that affect response content, sorts them via BTreeMap,
/// serializes to canonical JSON, SHA-256 hashes the result, and prepends the
/// namespace prefix.
pub fn cache_key_for_request(
body: &serde_json::Value,
ns: CacheNamespace,
scope: &CacheScope<'_>,
) -> String {
let mut hasher = Sha256::new();
write_canonical_cache_body(&mut hasher, body, scope);
let hash = hasher.finalize();
let hex = hex::encode(hash);
format!("{}:{}", ns.prefix(), hex)
}
enum CacheField<'a> {
Json(&'a str, &'a serde_json::Value),
Str(&'static str, &'a str),
}
impl<'a> CacheField<'a> {
fn key(&self) -> &str {
match self {
Self::Json(key, _) | Self::Str(key, _) => key,
}
}
}
struct HashWriter<'a> {
hasher: &'a mut Sha256,
}
impl Write for HashWriter<'_> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.hasher.update(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn write_canonical_cache_body(
hasher: &mut Sha256,
body: &serde_json::Value,
scope: &CacheScope<'_>,
) {
let mut fields = Vec::new();
if let Some(obj) = body.as_object() {
fields.extend(
obj.iter()
.filter(|(key, value)| should_include_cache_field(key, value))
.map(|(key, value)| CacheField::Json(key.as_str(), value)),
);
}
fields.push(CacheField::Str("_scope_auth", scope.auth_identity));
fields.push(CacheField::Str("_scope_backend", scope.backend_name));
if let Some(namespace) = scope.namespace {
fields.push(CacheField::Str("_scope_cache_namespace", namespace));
}
fields.sort_unstable_by(|a, b| a.key().cmp(b.key()));
let mut writer = HashWriter { hasher };
writer
.write_all(b"{")
.expect("hash writer should not fail writing object start");
for (idx, field) in fields.iter().enumerate() {
if idx > 0 {
writer
.write_all(b",")
.expect("hash writer should not fail writing separator");
}
serde_json::to_writer(&mut writer, field.key())
.expect("hash writer should not fail writing key");
writer
.write_all(b":")
.expect("hash writer should not fail writing colon");
match field {
CacheField::Json(_, value) => serde_json::to_writer(&mut writer, value)
.expect("hash writer should not fail writing JSON value"),
CacheField::Str(_, value) => serde_json::to_writer(&mut writer, value)
.expect("hash writer should not fail writing string value"),
}
}
writer
.write_all(b"}")
.expect("hash writer should not fail writing object end");
}
fn should_include_cache_field(key: &str, value: &serde_json::Value) -> bool {
if value.is_null() {
return false;
}
// Exclude fields that do not affect the backend response:
// - stream / stream_options: transport only (a cached non-stream response is
// replayed as a stream and vice versa).
// - cache: request cache controls, handled outside response-content hashing.
// - _scope_*: added separately as scope fields.
// - user / metadata: tracking fields documented as "Ignored" (anyllm_translate
// ChatCompletionRequest user, anthropic Metadata). Hashing them fragments the
// cache per end-user with no correctness benefit (tenant isolation is already
// provided by _scope_auth).
//
// parallel_tool_calls is NOT excluded: backends that honor it (e.g. OpenAI)
// produce different output for true vs false, so it must be part of the cache
// identity. (Gemini/Vertex have it stripped before dispatch by the tool policy.)
!matches!(
key,
"stream"
| "stream_options"
| "cache"
| "_scope_auth"
| "_scope_backend"
| "_scope_cache_namespace"
| "user"
| "metadata"
)
}
pub struct CacheScope<'a> {
pub backend_name: &'a str,
pub auth_identity: &'a str,
pub namespace: Option<&'a str>,
}
/// Per-request cache controls after combining local and LiteLLM-style fields.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CacheControl {
/// Whether to read an existing cached response before calling upstream.
pub lookup: bool,
/// Whether to store a successful upstream response after the call.
pub store: bool,
/// Per-entry store TTL override in seconds.
pub ttl_secs: Option<u64>,
/// Maximum acceptable age for an existing cached entry.
pub max_age_secs: Option<u64>,
/// Optional caller namespace for exact-match cache isolation.
pub namespace: Option<String>,
/// Parsed for LiteLLM compatibility. Currently no behavior change.
pub use_cache: bool,
}
impl Default for CacheControl {
fn default() -> Self {
Self {
lookup: true,
store: true,
ttl_secs: None,
max_age_secs: None,
namespace: None,
use_cache: false,
}
}
}
/// Parse the optional `cache_ttl_secs` field from a request body.
///
/// Returns:
/// - `Ok(None)` if the field is absent or null (use default TTL).
/// - `Ok(Some(0))` if explicitly 0 (bypass cache).
/// - `Ok(Some(n))` for valid positive values up to MAX_TTL_SECS.
/// - `Err(message)` for negative values, values > MAX_TTL_SECS, or non-numeric.
pub fn parse_cache_ttl(body: &serde_json::Value) -> Result<Option<u64>, String> {
let Some(val) = body.get("cache_ttl_secs") else {
return Ok(None);
};
if val.is_null() {
return Ok(None);
}
if let Some(n) = val.as_u64() {
if n > MAX_TTL_SECS {
return Err(format!("cache_ttl_secs must be <= {MAX_TTL_SECS}, got {n}"));
}
return Ok(Some(n));
}
if let Some(n) = val.as_i64() {
// Negative values are invalid
return Err(format!("cache_ttl_secs must be non-negative, got {n}"));
}
if let Some(n) = val.as_f64() {
if n < 0.0 {
return Err(format!("cache_ttl_secs must be non-negative, got {n}"));
}
let truncated = n as u64;
if truncated > MAX_TTL_SECS {
return Err(format!(
"cache_ttl_secs must be <= {MAX_TTL_SECS}, got {truncated}"
));
}
return Ok(Some(truncated));
}
Err(format!("cache_ttl_secs must be a number, got {}", val))
}
/// Parse local `cache_ttl_secs` and LiteLLM-compatible top-level `cache`.
pub fn parse_cache_control(body: &serde_json::Value) -> Result<CacheControl, String> {
let mut control = CacheControl::default();
match parse_cache_ttl(body)? {
Some(0) => {
control.lookup = false;
control.store = false;
control.ttl_secs = Some(0);
}
Some(ttl) => {
control.ttl_secs = Some(ttl);
}
None => {}
}
let Some(cache_value) = body.get("cache") else {
return Ok(control);
};
if cache_value.is_null() {
return Ok(control);
}
let Some(cache_obj) = cache_value.as_object() else {
return Err(format!("cache must be an object, got {}", cache_value));
};
if parse_cache_bool_field(cache_obj, "no-cache")?.unwrap_or(false) {
control.lookup = false;
}
if parse_cache_bool_field(cache_obj, "no-store")?.unwrap_or(false) {
control.store = false;
}
if let Some(use_cache) = parse_cache_bool_field(cache_obj, "use-cache")? {
control.use_cache = use_cache;
}
if let Some(ttl) = parse_cache_secs_field(cache_obj, "ttl")? {
control.ttl_secs = Some(ttl);
}
if let Some(max_age) = parse_cache_secs_field(cache_obj, "s-maxage")? {
control.max_age_secs = Some(max_age);
}
if let Some(max_age) = parse_cache_secs_field(cache_obj, "s-max-age")? {
control.max_age_secs = Some(max_age);
}
if let Some(namespace_value) = cache_obj.get("namespace") {
if !namespace_value.is_null() {
let Some(namespace) = namespace_value.as_str() else {
return Err(format!(
"cache.namespace must be a string, got {}",
namespace_value
));
};
if !namespace.is_empty() {
control.namespace = Some(namespace.to_string());
}
}
}
Ok(control)
}
fn parse_cache_bool_field(
cache_obj: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<Option<bool>, String> {
let Some(value) = cache_obj.get(field) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value
.as_bool()
.map(Some)
.ok_or_else(|| format!("cache.{field} must be a boolean, got {value}"))
}
fn parse_cache_secs_field(
cache_obj: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<Option<u64>, String> {
let Some(value) = cache_obj.get(field) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
if let Some(n) = value.as_u64() {
if n > MAX_TTL_SECS {
return Err(format!("cache.{field} must be <= {MAX_TTL_SECS}, got {n}"));
}
return Ok(Some(n));
}
if let Some(n) = value.as_i64() {
return Err(format!("cache.{field} must be non-negative, got {n}"));
}
if let Some(n) = value.as_f64() {
if n < 0.0 {
return Err(format!("cache.{field} must be non-negative, got {n}"));
}
let truncated = n as u64;
if truncated > MAX_TTL_SECS {
return Err(format!(
"cache.{field} must be <= {MAX_TTL_SECS}, got {truncated}"
));
}
return Ok(Some(truncated));
}
Err(format!("cache.{field} must be a number, got {}", value))
}
pub fn cache_entry_is_fresh(entry: &CacheEntry, max_age_secs: Option<u64>) -> bool {
match max_age_secs {
Some(max_age) => entry.created_at.elapsed() <= Duration::from_secs(max_age),
None => true,
}
}
/// Configuration for the cache subsystem.
#[derive(Debug, Clone)]
pub struct CacheConfig {
/// Default TTL in seconds for cached responses.
pub ttl_secs: u64,
/// Maximum number of entries in the in-memory cache.
pub max_entries: u64,
/// Optional Redis URL. Used by the Redis L2 cache backend (requires `redis` feature)
/// and distributed rate limiting. When set, responses are cached in Redis in addition
/// to the in-memory L1 cache, and rate limit state is shared across proxy instances.
pub redis_url: Option<String>,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
ttl_secs: 300,
max_entries: 10_000,
redis_url: None,
}
}
}
impl CacheConfig {
/// Load cache configuration from environment variables.
pub fn from_env() -> Self {
let ttl_secs = std::env::var("CACHE_TTL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300);
let max_entries = std::env::var("CACHE_MAX_ENTRIES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10_000);
let redis_url = std::env::var("REDIS_URL").ok();
Self {
ttl_secs,
max_entries,
redis_url,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_deterministic_same_fields() {
let body = serde_json::json!({
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hello"}],
"temperature": 0.7,
"max_tokens": 100
});
let key1 = cache_key_for_request(
&body,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_eq!(key1, key2);
assert!(key1.starts_with("anth:"));
}
#[test]
fn cache_key_different_for_different_temperature() {
let body1 = serde_json::json!({
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hello"}],
"temperature": 0.7
});
let body2 = serde_json::json!({
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hello"}],
"temperature": 0.9
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_ne!(key1, key2);
}
#[test]
fn cache_key_ignores_field_order() {
// JSON object field order should not affect the key because we
// extract into a BTreeMap.
let body1 = serde_json::json!({
"model": "gpt-4o",
"temperature": 0.5,
"messages": [{"role": "user", "content": "hi"}]
});
let body2 = serde_json::json!({
"messages": [{"role": "user", "content": "hi"}],
"model": "gpt-4o",
"temperature": 0.5
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_eq!(key1, key2);
}
#[test]
fn cache_key_ignores_non_cache_fields() {
let body1 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"stream": true
});
let body2 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}]
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_eq!(key1, key2);
}
#[test]
fn cache_key_namespace_differs() {
let body = serde_json::json!({
"model": "test",
"messages": []
});
let anth = cache_key_for_request(
&body,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let oai = cache_key_for_request(
&body,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_ne!(anth, oai);
assert!(anth.starts_with("anth:"));
assert!(oai.starts_with("oai:"));
}
#[test]
fn cache_key_null_field_same_as_absent() {
let body1 = serde_json::json!({
"model": "gpt-4o",
"messages": [],
"temperature": null
});
let body2 = serde_json::json!({
"model": "gpt-4o",
"messages": []
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_eq!(key1, key2);
}
#[test]
fn parse_cache_ttl_absent() {
let body = serde_json::json!({"model": "test"});
assert_eq!(parse_cache_ttl(&body).unwrap(), None);
}
#[test]
fn parse_cache_ttl_null() {
let body = serde_json::json!({"cache_ttl_secs": null});
assert_eq!(parse_cache_ttl(&body).unwrap(), None);
}
#[test]
fn parse_cache_ttl_zero() {
let body = serde_json::json!({"cache_ttl_secs": 0});
assert_eq!(parse_cache_ttl(&body).unwrap(), Some(0));
}
#[test]
fn parse_cache_ttl_valid() {
let body = serde_json::json!({"cache_ttl_secs": 600});
assert_eq!(parse_cache_ttl(&body).unwrap(), Some(600));
}
#[test]
fn parse_cache_ttl_max() {
let body = serde_json::json!({"cache_ttl_secs": 86400});
assert_eq!(parse_cache_ttl(&body).unwrap(), Some(86400));
}
#[test]
fn parse_cache_ttl_over_max() {
let body = serde_json::json!({"cache_ttl_secs": 86401});
assert!(parse_cache_ttl(&body).is_err());
}
#[test]
fn parse_cache_ttl_negative() {
let body = serde_json::json!({"cache_ttl_secs": -1});
assert!(parse_cache_ttl(&body).is_err());
}
#[test]
fn parse_cache_ttl_string() {
let body = serde_json::json!({"cache_ttl_secs": "not a number"});
assert!(parse_cache_ttl(&body).is_err());
}
#[test]
fn cache_key_differs_for_different_cache_ttl_secs() {
let body1 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"cache_ttl_secs": 60
});
let body2 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"cache_ttl_secs": 3600
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_ne!(
key1, key2,
"different cache_ttl_secs must produce different cache keys"
);
}
#[test]
fn cache_key_ignores_litellm_cache_controls() {
let body1 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"cache": {"ttl": 60, "no-cache": true}
});
let body2 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"cache": {"ttl": 3600, "no-store": true}
});
assert_eq!(openai_key(&body1), openai_key(&body2));
}
#[test]
fn cache_key_namespace_control_separates_keys() {
let body = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}]
});
let key1 = cache_key_for_request(
&body,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: Some("tenant-a"),
},
);
let key2 = cache_key_for_request(
&body,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: Some("tenant-b"),
},
);
assert_ne!(key1, key2);
}
#[test]
fn parse_cache_control_litellm_fields() {
let body = serde_json::json!({
"cache": {
"ttl": 120,
"no-cache": true,
"no-store": false,
"s-maxage": 30,
"namespace": "tenant-a",
"use-cache": true
}
});
let control = parse_cache_control(&body).unwrap();
assert!(!control.lookup);
assert!(control.store);
assert_eq!(control.ttl_secs, Some(120));
assert_eq!(control.max_age_secs, Some(30));
assert_eq!(control.namespace.as_deref(), Some("tenant-a"));
assert!(control.use_cache);
}
#[test]
fn parse_cache_control_preserves_cache_ttl_secs_bypass() {
let body = serde_json::json!({
"cache_ttl_secs": 0,
"cache": {"ttl": 120}
});
let control = parse_cache_control(&body).unwrap();
assert!(!control.lookup);
assert!(!control.store);
assert_eq!(control.ttl_secs, Some(120));
}
#[test]
fn parse_cache_control_rejects_invalid_cache_object() {
let body = serde_json::json!({"cache": true});
assert!(parse_cache_control(&body).is_err());
}
#[test]
fn cache_entry_s_maxage_rejects_stale_entries() {
let entry = CacheEntry {
response_body: Bytes::from_static(b"{}"),
model: "gpt-4o".to_string(),
created_at: Instant::now() - std::time::Duration::from_secs(10),
ttl_secs: None,
};
assert!(!cache_entry_is_fresh(&entry, Some(5)));
assert!(cache_entry_is_fresh(&entry, Some(30)));
assert!(cache_entry_is_fresh(&entry, None));
}
fn test_scope() -> CacheScope<'static> {
CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
}
}
fn anthropic_key(body: &serde_json::Value) -> String {
cache_key_for_request(body, CacheNamespace::Anthropic, &test_scope())
}
fn openai_key(body: &serde_json::Value) -> String {
cache_key_for_request(body, CacheNamespace::OpenAI, &test_scope())
}
#[test]
fn cache_key_includes_anthropic_response_affecting_fields() {
let base = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}]
});
let with_top_k = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}],
"top_k": 10
});
let with_stop_sequences = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}],
"stop_sequences": ["END"]
});
let with_thinking = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}],
"thinking": {"type": "enabled", "budget_tokens": 1024}
});
assert_ne!(anthropic_key(&base), anthropic_key(&with_top_k));
assert_ne!(anthropic_key(&base), anthropic_key(&with_stop_sequences));
assert_ne!(anthropic_key(&base), anthropic_key(&with_thinking));
}
#[test]
fn cache_key_includes_unknown_extra_fields() {
let base = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}]
});
let with_extra = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"prediction": {"type": "content", "content": "expected"}
});
assert_ne!(openai_key(&base), openai_key(&with_extra));
}
#[test]
fn cache_key_ignores_tracking_fields() {
let base = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}]
});
let with_user = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"user": "end-user-123"
});
let with_metadata = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}],
"metadata": {"user_id": "session-abc"}
});
let metadata_base = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}]
});
assert_eq!(openai_key(&base), openai_key(&with_user));
assert_eq!(anthropic_key(&metadata_base), anthropic_key(&with_metadata));
}
#[test]
fn cache_key_includes_parallel_tool_calls() {
let base = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "lookup",
"description": "lookup",
"parameters": {"type": "object", "properties": {}}
}
}]
});
let with_parallel_tool_calls = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "lookup",
"description": "lookup",
"parameters": {"type": "object", "properties": {}}
}
}],
"parallel_tool_calls": false
});
assert_ne!(openai_key(&base), openai_key(&with_parallel_tool_calls));
}
}
+501
View File
@@ -0,0 +1,501 @@
use super::*;
use bytes::Bytes;
use std::time::Instant;
#[test]
fn cache_key_deterministic_same_fields() {
let body = serde_json::json!({
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hello"}],
"temperature": 0.7,
"max_tokens": 100
});
let key1 = cache_key_for_request(
&body,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_eq!(key1, key2);
assert!(key1.starts_with("anth:"));
}
#[test]
fn cache_key_different_for_different_temperature() {
let body1 = serde_json::json!({
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hello"}],
"temperature": 0.7
});
let body2 = serde_json::json!({
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hello"}],
"temperature": 0.9
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_ne!(key1, key2);
}
#[test]
fn cache_key_ignores_field_order() {
// JSON object field order should not affect the key because we
// extract into a BTreeMap.
let body1 = serde_json::json!({
"model": "gpt-4o",
"temperature": 0.5,
"messages": [{"role": "user", "content": "hi"}]
});
let body2 = serde_json::json!({
"messages": [{"role": "user", "content": "hi"}],
"model": "gpt-4o",
"temperature": 0.5
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_eq!(key1, key2);
}
#[test]
fn cache_key_ignores_non_cache_fields() {
let body1 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"stream": true
});
let body2 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}]
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_eq!(key1, key2);
}
#[test]
fn cache_key_namespace_differs() {
let body = serde_json::json!({
"model": "test",
"messages": []
});
let anth = cache_key_for_request(
&body,
CacheNamespace::Anthropic,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let oai = cache_key_for_request(
&body,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_ne!(anth, oai);
assert!(anth.starts_with("anth:"));
assert!(oai.starts_with("oai:"));
}
#[test]
fn cache_key_null_field_same_as_absent() {
let body1 = serde_json::json!({
"model": "gpt-4o",
"messages": [],
"temperature": null
});
let body2 = serde_json::json!({
"model": "gpt-4o",
"messages": []
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_eq!(key1, key2);
}
#[test]
fn parse_cache_ttl_absent() {
let body = serde_json::json!({"model": "test"});
assert_eq!(parse_cache_ttl(&body).unwrap(), None);
}
#[test]
fn parse_cache_ttl_null() {
let body = serde_json::json!({"cache_ttl_secs": null});
assert_eq!(parse_cache_ttl(&body).unwrap(), None);
}
#[test]
fn parse_cache_ttl_zero() {
let body = serde_json::json!({"cache_ttl_secs": 0});
assert_eq!(parse_cache_ttl(&body).unwrap(), Some(0));
}
#[test]
fn parse_cache_ttl_valid() {
let body = serde_json::json!({"cache_ttl_secs": 600});
assert_eq!(parse_cache_ttl(&body).unwrap(), Some(600));
}
#[test]
fn parse_cache_ttl_max() {
let body = serde_json::json!({"cache_ttl_secs": 86400});
assert_eq!(parse_cache_ttl(&body).unwrap(), Some(86400));
}
#[test]
fn parse_cache_ttl_over_max() {
let body = serde_json::json!({"cache_ttl_secs": 86401});
assert!(parse_cache_ttl(&body).is_err());
}
#[test]
fn parse_cache_ttl_negative() {
let body = serde_json::json!({"cache_ttl_secs": -1});
assert!(parse_cache_ttl(&body).is_err());
}
#[test]
fn parse_cache_ttl_string() {
let body = serde_json::json!({"cache_ttl_secs": "not a number"});
assert!(parse_cache_ttl(&body).is_err());
}
#[test]
fn cache_key_differs_for_different_cache_ttl_secs() {
let body1 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"cache_ttl_secs": 60
});
let body2 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"cache_ttl_secs": 3600
});
let key1 = cache_key_for_request(
&body1,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
let key2 = cache_key_for_request(
&body2,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
},
);
assert_ne!(
key1, key2,
"different cache_ttl_secs must produce different cache keys"
);
}
#[test]
fn cache_key_ignores_litellm_cache_controls() {
let body1 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"cache": {"ttl": 60, "no-cache": true}
});
let body2 = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"cache": {"ttl": 3600, "no-store": true}
});
assert_eq!(openai_key(&body1), openai_key(&body2));
}
#[test]
fn cache_key_namespace_control_separates_keys() {
let body = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}]
});
let key1 = cache_key_for_request(
&body,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: Some("tenant-a"),
},
);
let key2 = cache_key_for_request(
&body,
CacheNamespace::OpenAI,
&CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: Some("tenant-b"),
},
);
assert_ne!(key1, key2);
}
#[test]
fn parse_cache_control_litellm_fields() {
let body = serde_json::json!({
"cache": {
"ttl": 120,
"no-cache": true,
"no-store": false,
"s-maxage": 30,
"namespace": "tenant-a",
"use-cache": true
}
});
let control = parse_cache_control(&body).unwrap();
assert!(!control.lookup);
assert!(control.store);
assert_eq!(control.ttl_secs, Some(120));
assert_eq!(control.max_age_secs, Some(30));
assert_eq!(control.namespace.as_deref(), Some("tenant-a"));
assert!(control.use_cache);
}
#[test]
fn parse_cache_control_preserves_cache_ttl_secs_bypass() {
let body = serde_json::json!({
"cache_ttl_secs": 0,
"cache": {"ttl": 120}
});
let control = parse_cache_control(&body).unwrap();
assert!(!control.lookup);
assert!(!control.store);
assert_eq!(control.ttl_secs, Some(120));
}
#[test]
fn parse_cache_control_rejects_invalid_cache_object() {
let body = serde_json::json!({"cache": true});
assert!(parse_cache_control(&body).is_err());
}
#[test]
fn cache_entry_s_maxage_rejects_stale_entries() {
let entry = CacheEntry {
response_body: Bytes::from_static(b"{}"),
model: "gpt-4o".to_string(),
created_at: Instant::now() - std::time::Duration::from_secs(10),
ttl_secs: None,
};
assert!(!cache_entry_is_fresh(&entry, Some(5)));
assert!(cache_entry_is_fresh(&entry, Some(30)));
assert!(cache_entry_is_fresh(&entry, None));
}
fn test_scope() -> CacheScope<'static> {
CacheScope {
backend_name: "openai",
auth_identity: "k1",
namespace: None,
}
}
fn anthropic_key(body: &serde_json::Value) -> String {
cache_key_for_request(body, CacheNamespace::Anthropic, &test_scope())
}
fn openai_key(body: &serde_json::Value) -> String {
cache_key_for_request(body, CacheNamespace::OpenAI, &test_scope())
}
#[test]
fn cache_key_includes_anthropic_response_affecting_fields() {
let base = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}]
});
let with_top_k = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}],
"top_k": 10
});
let with_stop_sequences = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}],
"stop_sequences": ["END"]
});
let with_thinking = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}],
"thinking": {"type": "enabled", "budget_tokens": 1024}
});
assert_ne!(anthropic_key(&base), anthropic_key(&with_top_k));
assert_ne!(anthropic_key(&base), anthropic_key(&with_stop_sequences));
assert_ne!(anthropic_key(&base), anthropic_key(&with_thinking));
}
#[test]
fn cache_key_includes_unknown_extra_fields() {
let base = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}]
});
let with_extra = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"prediction": {"type": "content", "content": "expected"}
});
assert_ne!(openai_key(&base), openai_key(&with_extra));
}
#[test]
fn cache_key_ignores_tracking_fields() {
let base = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}]
});
let with_user = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"user": "end-user-123"
});
let with_metadata = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}],
"metadata": {"user_id": "session-abc"}
});
let metadata_base = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 128,
"messages": [{"role": "user", "content": "hi"}]
});
assert_eq!(openai_key(&base), openai_key(&with_user));
assert_eq!(anthropic_key(&metadata_base), anthropic_key(&with_metadata));
}
#[test]
fn cache_key_includes_parallel_tool_calls() {
let base = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "lookup",
"description": "lookup",
"parameters": {"type": "object", "properties": {}}
}
}]
});
let with_parallel_tool_calls = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "lookup",
"description": "lookup",
"parameters": {"type": "object", "properties": {}}
}
}],
"parallel_tool_calls": false
});
assert_ne!(openai_key(&base), openai_key(&with_parallel_tool_calls));
}
+13 -662
View File
@@ -1,667 +1,18 @@
/// LiteLLM config.yaml parser.
///
/// Accepts LiteLLM's YAML config format (model_list, litellm_settings,
/// router_settings, general_settings) and converts it to anyllm-proxy's
/// MultiConfig + ModelRouter.
use std::collections::HashMap;
use std::sync::Arc;
//! LiteLLM config.yaml parser.
//!
//! Accepts LiteLLM's YAML config format (model_list, litellm_settings,
//! router_settings, general_settings) and converts it to anyllm-proxy's
//! MultiConfig + ModelRouter.
//!
//! [`types`] holds the Serde structs mapping the LiteLLM schema; [`parser`]
//! holds the conversion functions.
use indexmap::IndexMap;
use serde::Deserialize;
mod parser;
mod types;
use super::model_router::{Deployment, ModelRouter, RoutingStrategy};
use super::single::validate_gcp_identifier;
use super::{
resolve_env_value, validate_base_url, BackendAuth, BackendConfig, BackendKind, ModelMapping,
MultiConfig, OpenAIApiFormat, TlsConfig,
};
// ---- Serde structs for LiteLLM config.yaml ----
/// Root structure of a LiteLLM `config.yaml` file.
#[derive(Deserialize)]
pub(crate) struct LiteLLMConfig {
#[serde(default)]
model_list: Vec<LiteLLMModelEntry>,
#[serde(default)]
litellm_settings: Option<LiteLLMSettings>,
#[serde(default)]
router_settings: Option<RouterSettings>,
#[serde(default)]
general_settings: Option<GeneralSettings>,
}
#[derive(Deserialize)]
struct LiteLLMModelEntry {
model_name: String,
litellm_params: LiteLLMParams,
}
#[derive(Deserialize)]
struct LiteLLMParams {
model: String,
api_base: Option<String>,
api_key: Option<String>,
rpm: Option<u32>,
tpm: Option<u64>,
weight: Option<u32>,
// Azure-specific
api_version: Option<String>,
// Vertex-specific
vertex_project: Option<String>,
vertex_location: Option<String>,
// Bedrock-specific
aws_access_key_id: Option<String>,
aws_secret_access_key: Option<String>,
aws_region_name: Option<String>,
// Catch unknown fields silently (LiteLLM has many we don't support).
#[serde(flatten)]
_extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Deserialize)]
struct LiteLLMSettings {
#[serde(default)]
num_retries: Option<u32>,
#[serde(default)]
request_timeout: Option<u64>,
#[serde(default)]
callbacks: Vec<String>,
#[serde(flatten)]
_extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Deserialize)]
struct RouterSettings {
#[serde(default)]
routing_strategy: Option<String>,
#[serde(flatten)]
_extra: serde_json::Map<String, serde_json::Value>,
}
/// Map LiteLLM routing_strategy string to our enum.
pub(crate) fn parse_routing_strategy_str(s: &str) -> RoutingStrategy {
match s.to_ascii_lowercase().replace('_', "-").as_str() {
"simple-shuffle" | "round-robin" => RoutingStrategy::RoundRobin,
"least-busy" => RoutingStrategy::LeastBusy,
"latency-based-routing" | "latency-based" => RoutingStrategy::LatencyBased,
"usage-based-routing" | "usage-based" => RoutingStrategy::LeastBusy,
"weighted" => RoutingStrategy::Weighted,
"cost-based" => RoutingStrategy::CostBased,
other => {
tracing::warn!(
strategy = %other,
"unknown routing_strategy, falling back to round-robin"
);
RoutingStrategy::RoundRobin
}
}
}
#[derive(Deserialize)]
struct GeneralSettings {
master_key: Option<String>,
#[serde(flatten)]
_extra: serde_json::Map<String, serde_json::Value>,
}
// ---- Provider parsing ----
/// Parse LiteLLM's "provider/model_name" format.
/// No prefix defaults to OpenAI (matches LiteLLM behavior).
/// Returns (kind, model_name, stub_provider) where stub_provider is set for
/// registry-resolved OpenAI-compatible providers so callers can use their default URL.
fn parse_provider_model(
model: &str,
) -> (
BackendKind,
String,
Option<&'static anyllm_providers::ProviderDef>,
) {
let (provider, model_name) = model.split_once('/').unwrap_or(("openai", model));
let mut stub_provider: Option<&'static anyllm_providers::ProviderDef> = None;
let kind = match provider.to_ascii_lowercase().as_str() {
"openai" => BackendKind::OpenAI,
"azure" => BackendKind::AzureOpenAI,
"vertex_ai" | "vertex" => BackendKind::Vertex,
"gemini" => BackendKind::Gemini,
"anthropic" => {
stub_provider = anyllm_providers::get_provider("anthropic");
BackendKind::Anthropic
}
"bedrock" => BackendKind::Bedrock,
other => {
// Try the provider registry for known OpenAI-compatible providers
// (e.g. "groq", "together_ai", "mistral", etc.)
let prefix_with_slash = format!("{other}/");
if let Some(p) = anyllm_providers::find_by_litellm_prefix(&prefix_with_slash) {
let resolved = match anyllm_providers::resolve_backend(p.id) {
Some(("openai", _)) => {
stub_provider = Some(p);
BackendKind::OpenAI
}
Some(("anthropic", _)) => BackendKind::Anthropic,
Some(("gemini", _)) => BackendKind::Gemini,
Some(("vertex", _)) => BackendKind::Vertex,
Some(("azure", _)) => BackendKind::AzureOpenAI,
Some(("bedrock", _)) => BackendKind::Bedrock,
_ => {
tracing::warn!(provider = %other, "provider found in registry but protocol not mappable, treating as openai-compatible");
stub_provider = Some(p);
BackendKind::OpenAI
}
};
resolved
} else {
tracing::warn!(
provider = %other,
"unknown LiteLLM provider, treating as openai-compatible"
);
BackendKind::OpenAI
}
}
};
(kind, model_name.to_string(), stub_provider)
}
fn provider_id_for_litellm_model(
model: &str,
kind: &BackendKind,
stub_provider: Option<&'static anyllm_providers::ProviderDef>,
) -> String {
if let Some(provider) = stub_provider {
return provider.id.to_string();
}
let raw_provider = model
.split_once('/')
.map(|(provider, _)| provider)
.unwrap_or("openai")
.to_ascii_lowercase();
match kind {
BackendKind::OpenAI => raw_provider,
BackendKind::AzureOpenAI => "azure".to_string(),
BackendKind::Vertex => "vertex_ai".to_string(),
BackendKind::Gemini => "gemini".to_string(),
BackendKind::Anthropic => "anthropic".to_string(),
BackendKind::Bedrock => "bedrock".to_string(),
}
}
// ---- Backend deduplication key ----
/// Unique identity for a backend: same kind + base_url + api_key share one connection pool.
#[derive(Hash, PartialEq, Eq, Clone)]
struct BackendKey {
kind: String,
provider_id: String,
base_url: String,
/// Hash of the API key (not the key itself) to avoid holding secrets in hash keys.
api_key_hash: u64,
}
fn hash_string(s: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
}
// ---- Conversion ----
/// Parse a LiteLLM config.yaml string and produce a MultiConfig + ModelRouter.
///
/// # Panics
/// On invalid YAML, missing required fields, or unresolvable env var references.
/// Parsed result from a LiteLLM config file.
pub struct LiteLLMParsed {
pub multi_config: MultiConfig,
pub router: ModelRouter,
/// Webhook callback URLs from litellm_settings.callbacks (non-named entries).
pub callback_urls: Vec<String>,
/// True when "langfuse" appears in litellm_settings.callbacks.
pub langfuse_requested: bool,
/// Resolved `general_settings.master_key`, if present.
/// Caller should apply as PROXY_API_KEYS if that var is not already set.
pub master_key: Option<String>,
}
/// Parse a LiteLLM YAML config and return the multi-backend config + model router pair.
pub fn from_litellm_yaml(yaml: &str) -> (MultiConfig, ModelRouter) {
let parsed = parse_litellm_yaml(yaml);
(parsed.multi_config, parsed.router)
}
/// Parse a LiteLLM YAML config into the intermediate `LiteLLMParsed` struct.
/// Panics on invalid YAML (startup-time validation; misconfiguration is unrecoverable).
pub fn parse_litellm_yaml(yaml: &str) -> LiteLLMParsed {
let config: LiteLLMConfig =
serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("invalid LiteLLM config YAML: {e}"));
if config.model_list.is_empty() {
panic!("LiteLLM config must define at least one entry in model_list");
}
// Resolve general_settings.master_key but do not call set_var here.
// The caller applies it in the consolidated env override block.
let master_key = if let Some(ref gs) = config.general_settings {
let mk = gs.master_key.as_ref().map(|mk| {
resolve_env_value(mk).unwrap_or_else(|e| panic!("general_settings.master_key: {e}"))
});
// Log unsupported keys at warn.
for key in gs._extra.keys() {
tracing::warn!(key = %key, "unsupported general_settings key (ignored)");
}
mk
} else {
None
};
if let Some(ref ls) = config.litellm_settings {
for key in ls._extra.keys() {
tracing::warn!(key = %key, "unsupported litellm_settings key (ignored)");
}
}
if let Some(ref rs) = config.router_settings {
for key in rs._extra.keys() {
tracing::warn!(key = %key, "unsupported router_settings key (ignored)");
}
}
let listen_port = std::env::var("LISTEN_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3000);
let log_bodies = std::env::var("LOG_BODIES")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let redact_secrets = std::env::var("REDACT_SECRETS")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let anthropic_thinking_repair = std::env::var("ANTHROPIC_THINKING_REPAIR")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let pxpipe_compress = std::env::var("PXPIPE_COMPRESS")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let tls = TlsConfig::from_env();
// Group model_list entries into deduplicated backends + deployment list.
let mut backend_map: HashMap<BackendKey, (String, BackendConfig)> = HashMap::new();
let mut backend_counter = 0u32;
// model_name -> Vec<(backend_name, actual_model, rpm, tpm)>
let mut model_deployments: HashMap<String, Vec<DeploymentSpec>> = HashMap::new();
for entry in &config.model_list {
let (kind, actual_model, stub_provider) = parse_provider_model(&entry.litellm_params.model);
let provider_id =
provider_id_for_litellm_model(&entry.litellm_params.model, &kind, stub_provider);
let params = &entry.litellm_params;
let api_key = super::sanitize_api_key(
&params
.api_key
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("model_list api_key: {e}")))
.unwrap_or_else(|| {
// Fall back to the provider's own env vars when no api_key in YAML.
stub_provider
.and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
.or_else(|| {
(kind == BackendKind::Anthropic)
.then(|| std::env::var("ANTHROPIC_AUTH_TOKEN").ok())
.flatten()
})
.unwrap_or_default()
}),
);
let base_url = resolve_base_url(&kind, params, stub_provider, &actual_model);
let bk = BackendKey {
kind: format!("{kind:?}"),
provider_id: provider_id.clone(),
base_url: base_url.clone(),
api_key_hash: hash_string(&api_key),
};
let backend_name = if let Some((name, _)) = backend_map.get(&bk) {
name.clone()
} else {
let name = format!("litellm_{backend_counter}");
backend_counter += 1;
let bc = build_backend_config(
&name,
&kind,
&provider_id,
&api_key,
&base_url,
params,
&tls,
log_bodies,
&config,
);
backend_map.insert(bk, (name.clone(), bc));
name
};
model_deployments
.entry(entry.model_name.clone())
.or_default()
.push(DeploymentSpec {
backend_name,
actual_model,
rpm: params.rpm,
tpm: params.tpm,
weight: params.weight,
});
}
// Build MultiConfig backends (ordered).
let mut backends = IndexMap::new();
for (name, bc) in backend_map.values() {
backends.insert(name.clone(), bc.clone());
}
let default_backend = backends
.keys()
.next()
.cloned()
.expect("at least one backend");
let multi = MultiConfig {
listen_port,
log_bodies,
redact_secrets,
anthropic_thinking_repair,
pxpipe_compress,
forward_client_auth: crate::config::env_bool_flag("ANTHROPIC_FORWARD_CLIENT_AUTH"),
default_backend,
backends,
expose_degradation_warnings: false, // overridden in MultiConfig::load()
};
// Determine routing strategy from router_settings.
let strategy = config
.router_settings
.as_ref()
.and_then(|rs| rs.routing_strategy.as_deref())
.map(parse_routing_strategy_str)
.unwrap_or_default();
if strategy != RoutingStrategy::RoundRobin {
tracing::info!(strategy = ?strategy, "using routing strategy from config");
}
// Build ModelRouter.
let mut routes: HashMap<String, Vec<Arc<Deployment>>> = HashMap::new();
for (model_name, specs) in model_deployments {
let deployments = specs
.into_iter()
.map(|s| {
Arc::new(Deployment::with_weight(
s.backend_name,
s.actual_model,
s.rpm,
s.tpm,
s.weight.unwrap_or(1),
))
})
.collect();
routes.insert(model_name, deployments);
}
let router = ModelRouter::with_strategy(routes, strategy);
let callbacks = config
.litellm_settings
.as_ref()
.map(|s| s.callbacks.clone())
.unwrap_or_default();
let langfuse_requested = callbacks.iter().any(|c| c.eq_ignore_ascii_case("langfuse"));
let callback_urls: Vec<String> = callbacks
.into_iter()
.filter(|c| !c.eq_ignore_ascii_case("langfuse"))
.collect();
LiteLLMParsed {
multi_config: multi,
router,
callback_urls,
langfuse_requested,
master_key,
}
}
struct DeploymentSpec {
backend_name: String,
actual_model: String,
rpm: Option<u32>,
tpm: Option<u64>,
weight: Option<u32>,
}
/// Extract `general_settings.master_key` from a LiteLLM YAML string without
/// performing full config parsing. Used by the synchronous `fn main()` to apply
/// the key via `set_var` before the tokio runtime spawns worker threads.
pub fn extract_master_key(yaml: &str) -> Option<String> {
#[derive(Deserialize)]
struct Probe {
general_settings: Option<GeneralSettings>,
}
let probe: Probe = serde_yaml::from_str(yaml).ok()?;
let gs = probe.general_settings?;
let raw = gs.master_key?;
resolve_env_value(&raw).ok()
}
/// Determine the base URL for a deployment, applying provider-specific defaults.
fn resolve_base_url(
kind: &BackendKind,
params: &LiteLLMParams,
stub_provider: Option<&'static anyllm_providers::ProviderDef>,
actual_model: &str,
) -> String {
if let Some(ref url) = params.api_base {
let resolved =
resolve_env_value(url).unwrap_or_else(|e| panic!("model_list api_base: {e}"));
if *kind == BackendKind::AzureOpenAI {
let api_version = params.api_version.as_deref().unwrap_or("2024-10-21");
if !resolved.contains("/openai/deployments/") {
let deployment = azure_deployment_from_model(actual_model);
return format!(
"{}/openai/deployments/{deployment}/chat/completions?api-version={api_version}",
resolved.trim_end_matches('/'),
);
}
// api_base is already a deployment URL. Ensure the required api-version
// query is present so a partial deployment URL still authenticates.
if !resolved.contains("api-version=") {
let sep = if resolved.contains('?') { '&' } else { '?' };
return format!("{resolved}{sep}api-version={api_version}");
}
return resolved;
}
return resolved;
}
match kind {
BackendKind::OpenAI => {
// Use the stub provider's default URL when available (e.g. groq, xai, mistral).
// If a known provider has no safe global default, require explicit api_base.
let url = if let Some(provider) = stub_provider {
if provider.default_base_url.is_empty() {
panic!(
"model_list provider '{}' requires api_base because it has no safe global API base URL",
provider.id
);
}
provider.default_base_url
} else {
"https://api.openai.com"
};
super::strip_v1_suffix(url).to_string()
}
BackendKind::Gemini => {
"https://generativelanguage.googleapis.com/v1beta/openai".to_string()
}
BackendKind::Anthropic => std::env::var("ANTHROPIC_BASE_URL")
.unwrap_or_else(|_| "https://api.anthropic.com".to_string()),
BackendKind::Bedrock => {
// For Bedrock, base_url stores the region.
params
.aws_region_name
.as_deref()
.map(|v| v.to_string())
.or_else(|| std::env::var("AWS_REGION").ok())
.unwrap_or_else(|| "us-east-1".to_string())
}
// Azure and Vertex require api_base in the config.
BackendKind::AzureOpenAI => {
panic!("api_base is required for azure deployments in model_list")
}
BackendKind::Vertex => {
let project = params.vertex_project.as_deref().unwrap_or_else(|| {
panic!("vertex_project is required for vertex deployments in model_list")
});
let location = params.vertex_location.as_deref().unwrap_or_else(|| {
panic!("vertex_location is required for vertex deployments in model_list")
});
validate_gcp_identifier("vertex_project", project);
validate_gcp_identifier("vertex_location", location);
format!(
"https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi"
)
}
}
}
// LiteLLM Azure model names may carry a route-group marker prefix that selects a
// request-shaping path but is not part of the deployment name. This is the complete
// set of such markers; they are defined only here (no external producer). Add a
// marker here if a new Azure route group is introduced.
fn azure_deployment_from_model(model: &str) -> &str {
for marker in ["o_series/", "gpt5_series/"] {
if let Some(deployment) = model.strip_prefix(marker) {
if !deployment.is_empty() {
return deployment;
}
}
}
model
}
/// Build a BackendConfig from LiteLLM model_list params.
#[allow(clippy::too_many_arguments)]
fn build_backend_config(
name: &str,
kind: &BackendKind,
provider_id: &str,
api_key: &str,
base_url: &str,
params: &LiteLLMParams,
tls: &TlsConfig,
log_bodies: bool,
config: &LiteLLMConfig,
) -> BackendConfig {
let backend_auth = match kind {
BackendKind::AzureOpenAI => BackendAuth::AzureApiKey(api_key.to_string()),
BackendKind::Gemini | BackendKind::Vertex => BackendAuth::GoogleApiKey(api_key.to_string()),
BackendKind::Anthropic => BackendAuth::anthropic_from_api_key_like(api_key.to_string()),
_ => BackendAuth::BearerToken(api_key.to_string()),
};
// For Azure, resolve_base_url already produced the full deployment URL
// (deployment name + api-version), so use it as-is here.
let effective_url = if *kind == BackendKind::AzureOpenAI {
base_url.to_string()
} else {
// Validate non-Azure URLs.
if *kind != BackendKind::Bedrock {
if let Err(e) = validate_base_url(base_url) {
panic!("backend '{name}' base_url rejected: {e}");
}
}
base_url.to_string()
};
// Bedrock credentials.
let bedrock_credentials = if *kind == BackendKind::Bedrock {
let region = params
.aws_region_name
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
.or_else(|| std::env::var("AWS_REGION").ok())
.unwrap_or_else(|| "us-east-1".to_string());
let access_key = params
.aws_access_key_id
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
.or_else(|| std::env::var("AWS_ACCESS_KEY_ID").ok())
.unwrap_or_else(|| panic!("backend '{name}': aws_access_key_id required for bedrock"));
let secret_key = params
.aws_secret_access_key
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
.or_else(|| std::env::var("AWS_SECRET_ACCESS_KEY").ok())
.unwrap_or_else(|| {
panic!("backend '{name}': aws_secret_access_key required for bedrock")
});
// Store region as base_url for Bedrock (matches existing convention).
// The effective_url was already set to the region string.
let _ = region; // region is used as base_url via resolve_base_url
Some(aws_credential_types::Credentials::new(
access_key,
secret_key,
None, // session token not commonly in LiteLLM configs
None,
"litellm-config",
))
} else {
None
};
// Placeholder model mapping: with model router, these are not used for routing.
// They serve as fallback for Anthropic model name translation if needed.
let model_mapping = ModelMapping {
big_model: String::new(),
small_model: String::new(),
};
let _num_retries = config.litellm_settings.as_ref().and_then(|s| s.num_retries);
let _request_timeout = config
.litellm_settings
.as_ref()
.and_then(|s| s.request_timeout);
BackendConfig {
kind: kind.clone(),
provider_id: Some(provider_id.to_string()),
api_key: api_key.to_string(),
base_url: effective_url,
api_format: OpenAIApiFormat::Chat,
model_mapping,
tls: tls.clone(),
backend_auth,
log_bodies,
omit_stream_options: false,
stream_timeout_secs: std::env::var("REQUEST_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(900u64),
bedrock_credentials,
// Config-file backends are validated against SSRF at load time; no auto-relax.
allow_local_ssrf: false,
}
}
pub(crate) use parser::parse_routing_strategy_str;
pub use parser::{extract_master_key, from_litellm_yaml, parse_litellm_yaml};
pub use types::LiteLLMParsed;
#[cfg(test)]
mod tests;
+576
View File
@@ -0,0 +1,576 @@
//! Conversion logic: turn parsed LiteLLM YAML structs into anyllm-proxy's
//! `MultiConfig` + `ModelRouter`.
use std::collections::HashMap;
use std::sync::Arc;
use indexmap::IndexMap;
use serde::Deserialize;
use super::types::{GeneralSettings, LiteLLMConfig, LiteLLMParams, LiteLLMParsed};
use crate::config::model_router::{Deployment, ModelRouter, RoutingStrategy};
use crate::config::single::validate_gcp_identifier;
use crate::config::{
resolve_env_value, sanitize_api_key, strip_v1_suffix, validate_base_url, BackendAuth,
BackendConfig, BackendKind, ModelMapping, MultiConfig, OpenAIApiFormat, TlsConfig,
};
// ---- Provider parsing ----
/// Map LiteLLM routing_strategy string to our enum.
pub(crate) fn parse_routing_strategy_str(s: &str) -> RoutingStrategy {
match s.to_ascii_lowercase().replace('_', "-").as_str() {
"simple-shuffle" | "round-robin" => RoutingStrategy::RoundRobin,
"least-busy" => RoutingStrategy::LeastBusy,
"latency-based-routing" | "latency-based" => RoutingStrategy::LatencyBased,
"usage-based-routing" | "usage-based" => RoutingStrategy::LeastBusy,
"weighted" => RoutingStrategy::Weighted,
"cost-based" => RoutingStrategy::CostBased,
other => {
tracing::warn!(
strategy = %other,
"unknown routing_strategy, falling back to round-robin"
);
RoutingStrategy::RoundRobin
}
}
}
/// Parse LiteLLM's "provider/model_name" format.
/// No prefix defaults to OpenAI (matches LiteLLM behavior).
/// Returns (kind, model_name, stub_provider) where stub_provider is set for
/// registry-resolved OpenAI-compatible providers so callers can use their default URL.
pub(super) fn parse_provider_model(
model: &str,
) -> (
BackendKind,
String,
Option<&'static anyllm_providers::ProviderDef>,
) {
let (provider, model_name) = model.split_once('/').unwrap_or(("openai", model));
let mut stub_provider: Option<&'static anyllm_providers::ProviderDef> = None;
let kind = match provider.to_ascii_lowercase().as_str() {
"openai" => BackendKind::OpenAI,
"azure" => BackendKind::AzureOpenAI,
"vertex_ai" | "vertex" => BackendKind::Vertex,
"gemini" => BackendKind::Gemini,
"anthropic" => {
stub_provider = anyllm_providers::get_provider("anthropic");
BackendKind::Anthropic
}
"bedrock" => BackendKind::Bedrock,
other => {
// Try the provider registry for known OpenAI-compatible providers
// (e.g. "groq", "together_ai", "mistral", etc.)
let prefix_with_slash = format!("{other}/");
if let Some(p) = anyllm_providers::find_by_litellm_prefix(&prefix_with_slash) {
let resolved = match anyllm_providers::resolve_backend(p.id) {
Some(("openai", _)) => {
stub_provider = Some(p);
BackendKind::OpenAI
}
Some(("anthropic", _)) => BackendKind::Anthropic,
Some(("gemini", _)) => BackendKind::Gemini,
Some(("vertex", _)) => BackendKind::Vertex,
Some(("azure", _)) => BackendKind::AzureOpenAI,
Some(("bedrock", _)) => BackendKind::Bedrock,
_ => {
tracing::warn!(provider = %other, "provider found in registry but protocol not mappable, treating as openai-compatible");
stub_provider = Some(p);
BackendKind::OpenAI
}
};
resolved
} else {
tracing::warn!(
provider = %other,
"unknown LiteLLM provider, treating as openai-compatible"
);
BackendKind::OpenAI
}
}
};
(kind, model_name.to_string(), stub_provider)
}
fn provider_id_for_litellm_model(
model: &str,
kind: &BackendKind,
stub_provider: Option<&'static anyllm_providers::ProviderDef>,
) -> String {
if let Some(provider) = stub_provider {
return provider.id.to_string();
}
let raw_provider = model
.split_once('/')
.map(|(provider, _)| provider)
.unwrap_or("openai")
.to_ascii_lowercase();
match kind {
BackendKind::OpenAI => raw_provider,
BackendKind::AzureOpenAI => "azure".to_string(),
BackendKind::Vertex => "vertex_ai".to_string(),
BackendKind::Gemini => "gemini".to_string(),
BackendKind::Anthropic => "anthropic".to_string(),
BackendKind::Bedrock => "bedrock".to_string(),
}
}
// ---- Backend deduplication key ----
/// Unique identity for a backend: same kind + base_url + api_key share one connection pool.
#[derive(Hash, PartialEq, Eq, Clone)]
struct BackendKey {
kind: String,
provider_id: String,
base_url: String,
/// Hash of the API key (not the key itself) to avoid holding secrets in hash keys.
api_key_hash: u64,
}
fn hash_string(s: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
}
// ---- Conversion ----
/// Parse a LiteLLM YAML config and return the multi-backend config + model router pair.
pub fn from_litellm_yaml(yaml: &str) -> (MultiConfig, ModelRouter) {
let parsed = parse_litellm_yaml(yaml);
(parsed.multi_config, parsed.router)
}
/// Parse a LiteLLM YAML config into the intermediate `LiteLLMParsed` struct.
/// Panics on invalid YAML (startup-time validation; misconfiguration is unrecoverable).
pub fn parse_litellm_yaml(yaml: &str) -> LiteLLMParsed {
let config: LiteLLMConfig =
serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("invalid LiteLLM config YAML: {e}"));
if config.model_list.is_empty() {
panic!("LiteLLM config must define at least one entry in model_list");
}
// Resolve general_settings.master_key but do not call set_var here.
// The caller applies it in the consolidated env override block.
let master_key = if let Some(ref gs) = config.general_settings {
let mk = gs.master_key.as_ref().map(|mk| {
resolve_env_value(mk).unwrap_or_else(|e| panic!("general_settings.master_key: {e}"))
});
// Log unsupported keys at warn.
for key in gs._extra.keys() {
tracing::warn!(key = %key, "unsupported general_settings key (ignored)");
}
mk
} else {
None
};
if let Some(ref ls) = config.litellm_settings {
for key in ls._extra.keys() {
tracing::warn!(key = %key, "unsupported litellm_settings key (ignored)");
}
}
if let Some(ref rs) = config.router_settings {
for key in rs._extra.keys() {
tracing::warn!(key = %key, "unsupported router_settings key (ignored)");
}
}
let listen_port = std::env::var("LISTEN_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3000);
let log_bodies = std::env::var("LOG_BODIES")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let redact_secrets = std::env::var("REDACT_SECRETS")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let anthropic_thinking_repair = std::env::var("ANTHROPIC_THINKING_REPAIR")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let pxpipe_compress = std::env::var("PXPIPE_COMPRESS")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let tls = TlsConfig::from_env();
// Group model_list entries into deduplicated backends + deployment list.
let mut backend_map: HashMap<BackendKey, (String, BackendConfig)> = HashMap::new();
let mut backend_counter = 0u32;
// model_name -> Vec<(backend_name, actual_model, rpm, tpm)>
let mut model_deployments: HashMap<String, Vec<DeploymentSpec>> = HashMap::new();
for entry in &config.model_list {
let (kind, actual_model, stub_provider) = parse_provider_model(&entry.litellm_params.model);
let provider_id =
provider_id_for_litellm_model(&entry.litellm_params.model, &kind, stub_provider);
let params = &entry.litellm_params;
let api_key = sanitize_api_key(
&params
.api_key
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("model_list api_key: {e}")))
.unwrap_or_else(|| {
// Fall back to the provider's own env vars when no api_key in YAML.
stub_provider
.and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
.or_else(|| {
(kind == BackendKind::Anthropic)
.then(|| std::env::var("ANTHROPIC_AUTH_TOKEN").ok())
.flatten()
})
.unwrap_or_default()
}),
);
let base_url = resolve_base_url(&kind, params, stub_provider, &actual_model);
let bk = BackendKey {
kind: format!("{kind:?}"),
provider_id: provider_id.clone(),
base_url: base_url.clone(),
api_key_hash: hash_string(&api_key),
};
let backend_name = if let Some((name, _)) = backend_map.get(&bk) {
name.clone()
} else {
let name = format!("litellm_{backend_counter}");
backend_counter += 1;
let bc = build_backend_config(
&name,
&kind,
&provider_id,
&api_key,
&base_url,
params,
&tls,
log_bodies,
&config,
);
backend_map.insert(bk, (name.clone(), bc));
name
};
model_deployments
.entry(entry.model_name.clone())
.or_default()
.push(DeploymentSpec {
backend_name,
actual_model,
rpm: params.rpm,
tpm: params.tpm,
weight: params.weight,
});
}
// Build MultiConfig backends (ordered).
let mut backends = IndexMap::new();
for (name, bc) in backend_map.values() {
backends.insert(name.clone(), bc.clone());
}
let default_backend = backends
.keys()
.next()
.cloned()
.expect("at least one backend");
let multi = MultiConfig {
listen_port,
log_bodies,
redact_secrets,
anthropic_thinking_repair,
pxpipe_compress,
forward_client_auth: crate::config::env_bool_flag("ANTHROPIC_FORWARD_CLIENT_AUTH"),
default_backend,
backends,
expose_degradation_warnings: false, // overridden in MultiConfig::load()
};
// Determine routing strategy from router_settings.
let strategy = config
.router_settings
.as_ref()
.and_then(|rs| rs.routing_strategy.as_deref())
.map(parse_routing_strategy_str)
.unwrap_or_default();
if strategy != RoutingStrategy::RoundRobin {
tracing::info!(strategy = ?strategy, "using routing strategy from config");
}
// Build ModelRouter.
let mut routes: HashMap<String, Vec<Arc<Deployment>>> = HashMap::new();
for (model_name, specs) in model_deployments {
let deployments = specs
.into_iter()
.map(|s| {
Arc::new(Deployment::with_weight(
s.backend_name,
s.actual_model,
s.rpm,
s.tpm,
s.weight.unwrap_or(1),
))
})
.collect();
routes.insert(model_name, deployments);
}
let router = ModelRouter::with_strategy(routes, strategy);
let callbacks = config
.litellm_settings
.as_ref()
.map(|s| s.callbacks.clone())
.unwrap_or_default();
let langfuse_requested = callbacks.iter().any(|c| c.eq_ignore_ascii_case("langfuse"));
let callback_urls: Vec<String> = callbacks
.into_iter()
.filter(|c| !c.eq_ignore_ascii_case("langfuse"))
.collect();
LiteLLMParsed {
multi_config: multi,
router,
callback_urls,
langfuse_requested,
master_key,
}
}
struct DeploymentSpec {
backend_name: String,
actual_model: String,
rpm: Option<u32>,
tpm: Option<u64>,
weight: Option<u32>,
}
/// Extract `general_settings.master_key` from a LiteLLM YAML string without
/// performing full config parsing. Used by the synchronous `fn main()` to apply
/// the key via `set_var` before the tokio runtime spawns worker threads.
pub fn extract_master_key(yaml: &str) -> Option<String> {
#[derive(Deserialize)]
struct Probe {
general_settings: Option<GeneralSettings>,
}
let probe: Probe = serde_yaml::from_str(yaml).ok()?;
let gs = probe.general_settings?;
let raw = gs.master_key?;
resolve_env_value(&raw).ok()
}
/// Determine the base URL for a deployment, applying provider-specific defaults.
fn resolve_base_url(
kind: &BackendKind,
params: &LiteLLMParams,
stub_provider: Option<&'static anyllm_providers::ProviderDef>,
actual_model: &str,
) -> String {
if let Some(ref url) = params.api_base {
let resolved =
resolve_env_value(url).unwrap_or_else(|e| panic!("model_list api_base: {e}"));
if *kind == BackendKind::AzureOpenAI {
let api_version = params.api_version.as_deref().unwrap_or("2024-10-21");
if !resolved.contains("/openai/deployments/") {
let deployment = azure_deployment_from_model(actual_model);
return format!(
"{}/openai/deployments/{deployment}/chat/completions?api-version={api_version}",
resolved.trim_end_matches('/'),
);
}
// api_base is already a deployment URL. Ensure the required api-version
// query is present so a partial deployment URL still authenticates.
if !resolved.contains("api-version=") {
let sep = if resolved.contains('?') { '&' } else { '?' };
return format!("{resolved}{sep}api-version={api_version}");
}
return resolved;
}
return resolved;
}
match kind {
BackendKind::OpenAI => {
// Use the stub provider's default URL when available (e.g. groq, xai, mistral).
// If a known provider has no safe global default, require explicit api_base.
let url = if let Some(provider) = stub_provider {
if provider.default_base_url.is_empty() {
panic!(
"model_list provider '{}' requires api_base because it has no safe global API base URL",
provider.id
);
}
provider.default_base_url
} else {
"https://api.openai.com"
};
strip_v1_suffix(url).to_string()
}
BackendKind::Gemini => {
"https://generativelanguage.googleapis.com/v1beta/openai".to_string()
}
BackendKind::Anthropic => std::env::var("ANTHROPIC_BASE_URL")
.unwrap_or_else(|_| "https://api.anthropic.com".to_string()),
BackendKind::Bedrock => {
// For Bedrock, base_url stores the region.
params
.aws_region_name
.as_deref()
.map(|v| v.to_string())
.or_else(|| std::env::var("AWS_REGION").ok())
.unwrap_or_else(|| "us-east-1".to_string())
}
// Azure and Vertex require api_base in the config.
BackendKind::AzureOpenAI => {
panic!("api_base is required for azure deployments in model_list")
}
BackendKind::Vertex => {
let project = params.vertex_project.as_deref().unwrap_or_else(|| {
panic!("vertex_project is required for vertex deployments in model_list")
});
let location = params.vertex_location.as_deref().unwrap_or_else(|| {
panic!("vertex_location is required for vertex deployments in model_list")
});
validate_gcp_identifier("vertex_project", project);
validate_gcp_identifier("vertex_location", location);
format!(
"https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi"
)
}
}
}
// LiteLLM Azure model names may carry a route-group marker prefix that selects a
// request-shaping path but is not part of the deployment name. This is the complete
// set of such markers; they are defined only here (no external producer). Add a
// marker here if a new Azure route group is introduced.
fn azure_deployment_from_model(model: &str) -> &str {
for marker in ["o_series/", "gpt5_series/"] {
if let Some(deployment) = model.strip_prefix(marker) {
if !deployment.is_empty() {
return deployment;
}
}
}
model
}
/// Build a BackendConfig from LiteLLM model_list params.
#[allow(clippy::too_many_arguments)]
fn build_backend_config(
name: &str,
kind: &BackendKind,
provider_id: &str,
api_key: &str,
base_url: &str,
params: &LiteLLMParams,
tls: &TlsConfig,
log_bodies: bool,
config: &LiteLLMConfig,
) -> BackendConfig {
let backend_auth = match kind {
BackendKind::AzureOpenAI => BackendAuth::AzureApiKey(api_key.to_string()),
BackendKind::Gemini | BackendKind::Vertex => BackendAuth::GoogleApiKey(api_key.to_string()),
BackendKind::Anthropic => BackendAuth::anthropic_from_api_key_like(api_key.to_string()),
_ => BackendAuth::BearerToken(api_key.to_string()),
};
// For Azure, resolve_base_url already produced the full deployment URL
// (deployment name + api-version), so use it as-is here.
let effective_url = if *kind == BackendKind::AzureOpenAI {
base_url.to_string()
} else {
// Validate non-Azure URLs.
if *kind != BackendKind::Bedrock {
if let Err(e) = validate_base_url(base_url) {
panic!("backend '{name}' base_url rejected: {e}");
}
}
base_url.to_string()
};
// Bedrock credentials.
let bedrock_credentials = if *kind == BackendKind::Bedrock {
let region = params
.aws_region_name
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
.or_else(|| std::env::var("AWS_REGION").ok())
.unwrap_or_else(|| "us-east-1".to_string());
let access_key = params
.aws_access_key_id
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
.or_else(|| std::env::var("AWS_ACCESS_KEY_ID").ok())
.unwrap_or_else(|| panic!("backend '{name}': aws_access_key_id required for bedrock"));
let secret_key = params
.aws_secret_access_key
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
.or_else(|| std::env::var("AWS_SECRET_ACCESS_KEY").ok())
.unwrap_or_else(|| {
panic!("backend '{name}': aws_secret_access_key required for bedrock")
});
// Store region as base_url for Bedrock (matches existing convention).
// The effective_url was already set to the region string.
let _ = region; // region is used as base_url via resolve_base_url
Some(aws_credential_types::Credentials::new(
access_key,
secret_key,
None, // session token not commonly in LiteLLM configs
None,
"litellm-config",
))
} else {
None
};
// Placeholder model mapping: with model router, these are not used for routing.
// They serve as fallback for Anthropic model name translation if needed.
let model_mapping = ModelMapping {
big_model: String::new(),
small_model: String::new(),
};
let _num_retries = config.litellm_settings.as_ref().and_then(|s| s.num_retries);
let _request_timeout = config
.litellm_settings
.as_ref()
.and_then(|s| s.request_timeout);
BackendConfig {
kind: kind.clone(),
provider_id: Some(provider_id.to_string()),
api_key: api_key.to_string(),
base_url: effective_url,
api_format: OpenAIApiFormat::Chat,
model_mapping,
tls: tls.clone(),
backend_auth,
log_bodies,
omit_stream_options: false,
stream_timeout_secs: std::env::var("REQUEST_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(900u64),
bedrock_credentials,
// Config-file backends are validated against SSRF at load time; no auto-relax.
allow_local_ssrf: false,
}
}
+3
View File
@@ -1,4 +1,7 @@
use super::parser::parse_provider_model;
use super::*;
use crate::config::model_router::RoutingStrategy;
use crate::config::BackendKind;
#[test]
fn parse_provider_model_openai() {
+88
View File
@@ -0,0 +1,88 @@
//! Serde structs mapping the LiteLLM `config.yaml` schema, plus the parsed-result
//! type returned by the conversion functions in [`super::parser`].
use serde::Deserialize;
use crate::config::model_router::ModelRouter;
use crate::config::MultiConfig;
/// Root structure of a LiteLLM `config.yaml` file.
#[derive(Deserialize)]
pub(super) struct LiteLLMConfig {
#[serde(default)]
pub(super) model_list: Vec<LiteLLMModelEntry>,
#[serde(default)]
pub(super) litellm_settings: Option<LiteLLMSettings>,
#[serde(default)]
pub(super) router_settings: Option<RouterSettings>,
#[serde(default)]
pub(super) general_settings: Option<GeneralSettings>,
}
#[derive(Deserialize)]
pub(super) struct LiteLLMModelEntry {
pub(super) model_name: String,
pub(super) litellm_params: LiteLLMParams,
}
#[derive(Deserialize)]
pub(super) struct LiteLLMParams {
pub(super) model: String,
pub(super) api_base: Option<String>,
pub(super) api_key: Option<String>,
pub(super) rpm: Option<u32>,
pub(super) tpm: Option<u64>,
pub(super) weight: Option<u32>,
// Azure-specific
pub(super) api_version: Option<String>,
// Vertex-specific
pub(super) vertex_project: Option<String>,
pub(super) vertex_location: Option<String>,
// Bedrock-specific
pub(super) aws_access_key_id: Option<String>,
pub(super) aws_secret_access_key: Option<String>,
pub(super) aws_region_name: Option<String>,
// Catch unknown fields silently (LiteLLM has many we don't support).
#[serde(flatten)]
pub(super) _extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Deserialize)]
pub(super) struct LiteLLMSettings {
#[serde(default)]
pub(super) num_retries: Option<u32>,
#[serde(default)]
pub(super) request_timeout: Option<u64>,
#[serde(default)]
pub(super) callbacks: Vec<String>,
#[serde(flatten)]
pub(super) _extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Deserialize)]
pub(super) struct RouterSettings {
#[serde(default)]
pub(super) routing_strategy: Option<String>,
#[serde(flatten)]
pub(super) _extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Deserialize)]
pub(super) struct GeneralSettings {
pub(super) master_key: Option<String>,
#[serde(flatten)]
pub(super) _extra: serde_json::Map<String, serde_json::Value>,
}
/// Parsed result from a LiteLLM config file.
pub struct LiteLLMParsed {
pub multi_config: MultiConfig,
pub router: ModelRouter,
/// Webhook callback URLs from litellm_settings.callbacks (non-named entries).
pub callback_urls: Vec<String>,
/// True when "langfuse" appears in litellm_settings.callbacks.
pub langfuse_requested: bool,
/// Resolved `general_settings.master_key`, if present.
/// Caller should apply as PROXY_API_KEYS if that var is not already set.
pub master_key: Option<String>,
}
+1 -429
View File
@@ -346,432 +346,4 @@ fn anthropic_request_requires_tool_choice(req: &anthropic::MessageCreateRequest)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn catalog(json: serde_json::Value) -> ProviderCatalog {
ProviderCatalog::from_litellm_json(&json.to_string()).unwrap()
}
fn empty_catalog() -> ProviderCatalog {
ProviderCatalog::from_litellm_json("{}").unwrap()
}
fn request(value: serde_json::Value) -> openai::ChatCompletionRequest {
serde_json::from_value(value).unwrap()
}
fn ctx<'a>(
backend_kind: BackendKind,
provider_id: Option<&'a str>,
model: &'a str,
provider_catalog: &'a ProviderCatalog,
) -> OpenAiToolPolicyContext<'a> {
OpenAiToolPolicyContext {
backend_kind,
provider_id,
model,
provider_catalog,
}
}
#[test]
fn openai_tool_policy_mistral_rewrites_ids_and_matching_tool_results() {
let mut req = request(json!({
"model": "mistral-large-latest",
"messages": [
{
"role": "assistant",
"tool_calls": [
{"id": "call_alpha", "type": "function", "function": {"name": "a", "arguments": "{}"}},
{"id": "call_alpha", "type": "function", "function": {"name": "b", "arguments": "{}"}}
]
},
{"role": "tool", "tool_call_id": "call_alpha", "content": "first"},
{"role": "tool", "tool_call_id": "call_alpha", "content": "second"}
]
}));
let catalog = empty_catalog();
let mut warnings = TranslationWarnings::default();
let report = prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::OpenAI,
Some("mistral"),
"mistral-large-latest",
&catalog,
),
&mut warnings,
)
.unwrap();
let calls = req.messages[0].tool_calls.as_ref().unwrap();
assert_eq!(calls[0].id, "000000000");
assert_eq!(calls[1].id, "000000001");
assert_eq!(req.messages[1].tool_call_id.as_deref(), Some("000000000"));
assert_eq!(req.messages[2].tool_call_id.as_deref(), Some("000000001"));
assert_eq!(report.duplicate_tool_call_ids, 1);
assert_eq!(report.remapped_tool_results, 2);
}
#[test]
fn openai_tool_policy_gemini_sanitizes_schema_and_removes_strict() {
let mut req = request(json!({
"model": "gemini-2.5-pro",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "search",
"strict": true,
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"q": {"type": "string", "default": ""}
}
}
}
}]
}));
let catalog = empty_catalog();
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::Gemini,
Some("gemini"),
"gemini-2.5-pro",
&catalog,
),
&mut warnings,
)
.unwrap();
let tool = req.tools.as_ref().unwrap().first().unwrap();
assert_eq!(tool.function.strict, None);
let params = tool.function.parameters.as_ref().unwrap();
assert!(params.get("$schema").is_none());
assert!(params.get("additionalProperties").is_none());
assert!(
params.pointer("/properties/q/default").is_none(),
"Gemini rejects JSON Schema default in nested properties"
);
assert_eq!(
warnings.as_header_value().as_deref(),
Some("tools.function.strict")
);
}
#[test]
fn openai_tool_policy_applies_gemini_policy_by_protocol_for_managed_backend() {
// A managed/custom backend can run BackendKind::OpenAI while its provider_id
// resolves to the Gemini OpenAI shim in the catalog. The policy must key off
// the provider protocol, not a hardcoded "gemini" id, so strict-stripping
// still applies.
let catalog = ProviderCatalog::bundled();
let mut req = request(json!({
"model": "gemini-2.5-pro",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "search",
"strict": true,
"parameters": {"type": "object", "additionalProperties": false}
}
}]
}));
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::OpenAI,
Some("gemini"),
"gemini-2.5-pro",
&catalog,
),
&mut warnings,
)
.unwrap();
let tool = req.tools.as_ref().unwrap().first().unwrap();
assert_eq!(tool.function.strict, None);
assert!(tool
.function
.parameters
.as_ref()
.unwrap()
.get("additionalProperties")
.is_none());
}
#[test]
fn openai_tool_policy_gemini_strips_parallel_false_with_multiple_tools() {
let mut req = request(json!({
"model": "gemini-2.5-pro",
"messages": [{"role": "user", "content": "hi"}],
"parallel_tool_calls": false,
"tools": [
{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}},
{"type": "function", "function": {"name": "b", "parameters": {"type": "object"}}}
]
}));
let catalog = empty_catalog();
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::Gemini,
Some("gemini"),
"gemini-2.5-pro",
&catalog,
),
&mut warnings,
)
.unwrap();
// Gemini cannot honor parallel_tool_calls; the field is stripped and the
// degradation is reported rather than the request being rejected.
assert!(req.parallel_tool_calls.is_none());
assert_eq!(
warnings.as_header_value().as_deref(),
Some("parallel_tool_calls")
);
}
#[test]
fn openai_tool_policy_rejects_required_tool_choice_when_model_lacks_it() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "demo",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": false
}
}));
let mut req = request(json!({
"model": "demo-model",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
"tool_choice": "required"
}));
let mut warnings = TranslationWarnings::default();
let err = prepare_openai_tool_request(
&mut req,
ctx(BackendKind::OpenAI, Some("demo"), "demo-model", &catalog),
&mut warnings,
)
.unwrap_err();
assert!(err.message().contains("tool_choice=required"));
}
#[test]
fn openai_tool_policy_drops_auto_tool_choice_when_model_lacks_it() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "demo",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": false
}
}));
let mut req = request(json!({
"model": "demo-model",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
"tool_choice": "auto"
}));
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(BackendKind::OpenAI, Some("demo"), "demo-model", &catalog),
&mut warnings,
)
.unwrap();
assert!(req.tool_choice.is_none());
assert!(req.tools.is_some());
assert_eq!(warnings.as_header_value().as_deref(), Some("tool_choice"));
}
#[test]
fn openai_tool_policy_drops_none_tool_choice_and_tools_without_continuation() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "demo",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": false
}
}));
let mut req = request(json!({
"model": "demo-model",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
"tool_choice": "none"
}));
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(BackendKind::OpenAI, Some("demo"), "demo-model", &catalog),
&mut warnings,
)
.unwrap();
assert!(req.tool_choice.is_none());
assert!(req.tools.is_none());
assert_eq!(
warnings.as_header_value().as_deref(),
Some("tool_choice, tools")
);
}
#[test]
fn openai_tool_policy_rejects_tools_when_known_model_lacks_tool_use() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "demo",
"mode": "chat",
"supports_function_calling": false,
"supports_tool_choice": false
}
}));
let mut req = request(json!({
"model": "demo-model",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}]
}));
let mut warnings = TranslationWarnings::default();
let err = prepare_openai_tool_request(
&mut req,
ctx(BackendKind::OpenAI, Some("demo"), "demo-model", &catalog),
&mut warnings,
)
.unwrap_err();
assert!(err.message().contains("does not support tools"));
}
#[test]
fn openai_tool_policy_allows_forced_tool_choice_for_self_hosted_provider() {
// vllm/lm_studio/llamafile/triton stubs advertise tool_use but a
// conservative provider-level tool_choice:false and carry no per-model
// metadata. Forced tool_choice must pass through (the backend decides),
// not 400 -- the provider-level flag is not authoritative for an unknown
// self-hosted model.
let catalog = ProviderCatalog::bundled();
assert!(
catalog.list_models("vllm").is_empty(),
"test assumes vllm has no per-model metadata"
);
let mut req = request(json!({
"model": "Qwen/Qwen2.5-7B-Instruct",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
"tool_choice": "required"
}));
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::OpenAI,
Some("vllm"),
"Qwen/Qwen2.5-7B-Instruct",
&catalog,
),
&mut warnings,
)
.unwrap();
assert!(matches!(
req.tool_choice,
Some(ChatToolChoice::Simple(ref v)) if v == "required"
));
}
#[test]
fn openai_tool_policy_rejects_native_anthropic_tools_when_known_model_lacks_tool_use() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "bedrock",
"mode": "chat",
"supports_function_calling": false,
"supports_tool_choice": false
}
}));
let req: anthropic::MessageCreateRequest = serde_json::from_value(json!({
"model": "demo-model",
"max_tokens": 64,
"tools": [{
"name": "lookup",
"input_schema": {"type": "object"}
}],
"messages": [
{"role": "user", "content": "hi"}
]
}))
.unwrap();
let err = validate_anthropic_tool_request(
&req,
ctx(
BackendKind::Bedrock,
Some("bedrock"),
"demo-model",
&catalog,
),
)
.unwrap_err();
assert!(err.message().contains("does not support tools"));
}
#[test]
fn openai_tool_policy_normalizes_streaming_top_level_tool_call_delta() {
let chunk = parse_openai_chat_completion_chunk(
r#"{
"id": "chatcmpl_1",
"object": "chat.completion.chunk",
"created": 1,
"model": "local",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"id": "call_1",
"name": "lookup",
"arguments": {"q":"x"}
}]
}
}]
}"#,
)
.unwrap();
let tool_call = &chunk.choices[0].delta.tool_calls.as_ref().unwrap()[0];
assert_eq!(tool_call.call_type.as_deref(), Some("function"));
assert_eq!(
tool_call.function.as_ref().unwrap().name.as_deref(),
Some("lookup")
);
assert_eq!(
tool_call.function.as_ref().unwrap().arguments.as_deref(),
Some(r#"{"q":"x"}"#)
);
}
}
mod tests;
@@ -0,0 +1,427 @@
use serde_json::json;
use super::*;
fn catalog(json: serde_json::Value) -> ProviderCatalog {
ProviderCatalog::from_litellm_json(&json.to_string()).unwrap()
}
fn empty_catalog() -> ProviderCatalog {
ProviderCatalog::from_litellm_json("{}").unwrap()
}
fn request(value: serde_json::Value) -> openai::ChatCompletionRequest {
serde_json::from_value(value).unwrap()
}
fn ctx<'a>(
backend_kind: BackendKind,
provider_id: Option<&'a str>,
model: &'a str,
provider_catalog: &'a ProviderCatalog,
) -> OpenAiToolPolicyContext<'a> {
OpenAiToolPolicyContext {
backend_kind,
provider_id,
model,
provider_catalog,
}
}
#[test]
fn openai_tool_policy_mistral_rewrites_ids_and_matching_tool_results() {
let mut req = request(json!({
"model": "mistral-large-latest",
"messages": [
{
"role": "assistant",
"tool_calls": [
{"id": "call_alpha", "type": "function", "function": {"name": "a", "arguments": "{}"}},
{"id": "call_alpha", "type": "function", "function": {"name": "b", "arguments": "{}"}}
]
},
{"role": "tool", "tool_call_id": "call_alpha", "content": "first"},
{"role": "tool", "tool_call_id": "call_alpha", "content": "second"}
]
}));
let catalog = empty_catalog();
let mut warnings = TranslationWarnings::default();
let report = prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::OpenAI,
Some("mistral"),
"mistral-large-latest",
&catalog,
),
&mut warnings,
)
.unwrap();
let calls = req.messages[0].tool_calls.as_ref().unwrap();
assert_eq!(calls[0].id, "000000000");
assert_eq!(calls[1].id, "000000001");
assert_eq!(req.messages[1].tool_call_id.as_deref(), Some("000000000"));
assert_eq!(req.messages[2].tool_call_id.as_deref(), Some("000000001"));
assert_eq!(report.duplicate_tool_call_ids, 1);
assert_eq!(report.remapped_tool_results, 2);
}
#[test]
fn openai_tool_policy_gemini_sanitizes_schema_and_removes_strict() {
let mut req = request(json!({
"model": "gemini-2.5-pro",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "search",
"strict": true,
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"q": {"type": "string", "default": ""}
}
}
}
}]
}));
let catalog = empty_catalog();
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::Gemini,
Some("gemini"),
"gemini-2.5-pro",
&catalog,
),
&mut warnings,
)
.unwrap();
let tool = req.tools.as_ref().unwrap().first().unwrap();
assert_eq!(tool.function.strict, None);
let params = tool.function.parameters.as_ref().unwrap();
assert!(params.get("$schema").is_none());
assert!(params.get("additionalProperties").is_none());
assert!(
params.pointer("/properties/q/default").is_none(),
"Gemini rejects JSON Schema default in nested properties"
);
assert_eq!(
warnings.as_header_value().as_deref(),
Some("tools.function.strict")
);
}
#[test]
fn openai_tool_policy_applies_gemini_policy_by_protocol_for_managed_backend() {
// A managed/custom backend can run BackendKind::OpenAI while its provider_id
// resolves to the Gemini OpenAI shim in the catalog. The policy must key off
// the provider protocol, not a hardcoded "gemini" id, so strict-stripping
// still applies.
let catalog = ProviderCatalog::bundled();
let mut req = request(json!({
"model": "gemini-2.5-pro",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "search",
"strict": true,
"parameters": {"type": "object", "additionalProperties": false}
}
}]
}));
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::OpenAI,
Some("gemini"),
"gemini-2.5-pro",
&catalog,
),
&mut warnings,
)
.unwrap();
let tool = req.tools.as_ref().unwrap().first().unwrap();
assert_eq!(tool.function.strict, None);
assert!(tool
.function
.parameters
.as_ref()
.unwrap()
.get("additionalProperties")
.is_none());
}
#[test]
fn openai_tool_policy_gemini_strips_parallel_false_with_multiple_tools() {
let mut req = request(json!({
"model": "gemini-2.5-pro",
"messages": [{"role": "user", "content": "hi"}],
"parallel_tool_calls": false,
"tools": [
{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}},
{"type": "function", "function": {"name": "b", "parameters": {"type": "object"}}}
]
}));
let catalog = empty_catalog();
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::Gemini,
Some("gemini"),
"gemini-2.5-pro",
&catalog,
),
&mut warnings,
)
.unwrap();
// Gemini cannot honor parallel_tool_calls; the field is stripped and the
// degradation is reported rather than the request being rejected.
assert!(req.parallel_tool_calls.is_none());
assert_eq!(
warnings.as_header_value().as_deref(),
Some("parallel_tool_calls")
);
}
#[test]
fn openai_tool_policy_rejects_required_tool_choice_when_model_lacks_it() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "demo",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": false
}
}));
let mut req = request(json!({
"model": "demo-model",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
"tool_choice": "required"
}));
let mut warnings = TranslationWarnings::default();
let err = prepare_openai_tool_request(
&mut req,
ctx(BackendKind::OpenAI, Some("demo"), "demo-model", &catalog),
&mut warnings,
)
.unwrap_err();
assert!(err.message().contains("tool_choice=required"));
}
#[test]
fn openai_tool_policy_drops_auto_tool_choice_when_model_lacks_it() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "demo",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": false
}
}));
let mut req = request(json!({
"model": "demo-model",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
"tool_choice": "auto"
}));
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(BackendKind::OpenAI, Some("demo"), "demo-model", &catalog),
&mut warnings,
)
.unwrap();
assert!(req.tool_choice.is_none());
assert!(req.tools.is_some());
assert_eq!(warnings.as_header_value().as_deref(), Some("tool_choice"));
}
#[test]
fn openai_tool_policy_drops_none_tool_choice_and_tools_without_continuation() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "demo",
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": false
}
}));
let mut req = request(json!({
"model": "demo-model",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
"tool_choice": "none"
}));
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(BackendKind::OpenAI, Some("demo"), "demo-model", &catalog),
&mut warnings,
)
.unwrap();
assert!(req.tool_choice.is_none());
assert!(req.tools.is_none());
assert_eq!(
warnings.as_header_value().as_deref(),
Some("tool_choice, tools")
);
}
#[test]
fn openai_tool_policy_rejects_tools_when_known_model_lacks_tool_use() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "demo",
"mode": "chat",
"supports_function_calling": false,
"supports_tool_choice": false
}
}));
let mut req = request(json!({
"model": "demo-model",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}]
}));
let mut warnings = TranslationWarnings::default();
let err = prepare_openai_tool_request(
&mut req,
ctx(BackendKind::OpenAI, Some("demo"), "demo-model", &catalog),
&mut warnings,
)
.unwrap_err();
assert!(err.message().contains("does not support tools"));
}
#[test]
fn openai_tool_policy_allows_forced_tool_choice_for_self_hosted_provider() {
// vllm/lm_studio/llamafile/triton stubs advertise tool_use but a
// conservative provider-level tool_choice:false and carry no per-model
// metadata. Forced tool_choice must pass through (the backend decides),
// not 400 -- the provider-level flag is not authoritative for an unknown
// self-hosted model.
let catalog = ProviderCatalog::bundled();
assert!(
catalog.list_models("vllm").is_empty(),
"test assumes vllm has no per-model metadata"
);
let mut req = request(json!({
"model": "Qwen/Qwen2.5-7B-Instruct",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
"tool_choice": "required"
}));
let mut warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut req,
ctx(
BackendKind::OpenAI,
Some("vllm"),
"Qwen/Qwen2.5-7B-Instruct",
&catalog,
),
&mut warnings,
)
.unwrap();
assert!(matches!(
req.tool_choice,
Some(ChatToolChoice::Simple(ref v)) if v == "required"
));
}
#[test]
fn openai_tool_policy_rejects_native_anthropic_tools_when_known_model_lacks_tool_use() {
let catalog = catalog(json!({
"demo-model": {
"litellm_provider": "bedrock",
"mode": "chat",
"supports_function_calling": false,
"supports_tool_choice": false
}
}));
let req: anthropic::MessageCreateRequest = serde_json::from_value(json!({
"model": "demo-model",
"max_tokens": 64,
"tools": [{
"name": "lookup",
"input_schema": {"type": "object"}
}],
"messages": [
{"role": "user", "content": "hi"}
]
}))
.unwrap();
let err = validate_anthropic_tool_request(
&req,
ctx(
BackendKind::Bedrock,
Some("bedrock"),
"demo-model",
&catalog,
),
)
.unwrap_err();
assert!(err.message().contains("does not support tools"));
}
#[test]
fn openai_tool_policy_normalizes_streaming_top_level_tool_call_delta() {
let chunk = parse_openai_chat_completion_chunk(
r#"{
"id": "chatcmpl_1",
"object": "chat.completion.chunk",
"created": 1,
"model": "local",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"id": "call_1",
"name": "lookup",
"arguments": {"q":"x"}
}]
}
}]
}"#,
)
.unwrap();
let tool_call = &chunk.choices[0].delta.tool_calls.as_ref().unwrap()[0];
assert_eq!(tool_call.call_type.as_deref(), Some("function"));
assert_eq!(
tool_call.function.as_ref().unwrap().name.as_deref(),
Some("lookup")
);
assert_eq!(
tool_call.function.as_ref().unwrap().arguments.as_deref(),
Some(r#"{"q":"x"}"#)
);
}
+2 -246
View File
@@ -435,249 +435,5 @@ pub fn resolve_runtime_optimizer_locked(
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn long_text() -> String {
"The quick brown fox jumps over the lazy dog again and again across the wide \
green field toward the distant blue mountains far beyond the winding river."
.repeat(2)
}
fn long_openai_body() -> Value {
let long = long_text();
let mut messages = vec![json!({"role":"system","content":"you are helpful"})];
for _ in 0..16 {
messages.push(json!({"role":"user","content": long}));
messages.push(json!({"role":"assistant","content": long}));
}
messages.push(json!({"role":"user","content":"what is the latest?"}));
json!({"model":"gpt-4o","messages": messages})
}
#[test]
fn resolve_default_mode_unset_is_off() {
// Deliberately does not touch process env: relies on OPTIMIZER_MODE being
// absent by default in the test environment.
if std::env::var("OPTIMIZER_MODE").is_err() {
assert_eq!(resolve_default_mode(), Mode::Off);
}
}
#[test]
fn off_mode_never_mutates_and_reports_off() {
let mut body = long_openai_body();
let before = body.clone();
let engine = OptimizerEngine::new(Mode::Off);
let report = engine.optimize_openai(&mut body, "chat_completions");
assert_eq!(body, before, "off mode must not mutate the body");
assert_eq!(report.mode, Mode::Off);
assert!(!report.applied);
}
#[test]
fn shadow_mode_never_mutates_but_reports_savings() {
let mut body = long_openai_body();
let before = body.clone();
let engine = OptimizerEngine::new(Mode::Shadow);
let report = engine.optimize_openai(&mut body, "chat_completions");
assert_eq!(body, before, "shadow mode must not mutate the body");
assert!(
report.removed_tokens_est > 0,
"shadow should still report would-be savings on a long convo"
);
}
#[test]
fn live_mode_compresses_openai_history_and_preserves_latest_and_system() {
let mut body = long_openai_body();
let orig_msgs = body["messages"].as_array().unwrap().clone();
let engine = OptimizerEngine::new(Mode::Live);
let report = engine.optimize_openai(&mut body, "chat_completions");
assert!(report.applied || report.rewrite_suffix_tokens == 0);
let new_msgs = body["messages"].as_array().unwrap();
assert_eq!(orig_msgs.len(), new_msgs.len());
assert_eq!(orig_msgs[0], new_msgs[0], "system untouched");
assert_eq!(
orig_msgs.last().unwrap(),
new_msgs.last().unwrap(),
"latest message untouched"
);
}
#[test]
fn live_mode_compresses_anthropic_history_and_preserves_latest() {
let long = long_text();
let mut messages = vec![];
for _ in 0..16 {
messages.push(json!({"role":"user","content": long}));
messages.push(json!({"role":"assistant","content": long}));
}
messages.push(json!({"role":"user","content":"what is the latest?"}));
let mut body = json!({
"model":"claude-sonnet-5",
"system":"you are helpful",
"messages": messages,
});
let orig_msgs = body["messages"].as_array().unwrap().clone();
let orig_system = body["system"].clone();
let engine = OptimizerEngine::new(Mode::Live);
let report = engine.optimize_anthropic(&mut body, "messages");
assert_eq!(body["system"], orig_system, "system field untouched");
let new_msgs = body["messages"].as_array().unwrap();
assert_eq!(orig_msgs.len(), new_msgs.len());
assert_eq!(
orig_msgs.last().unwrap(),
new_msgs.last().unwrap(),
"latest message untouched"
);
// Live mode must place the deepest cache breakpoint at the frontier, not just
// compress text (crates/optimizer/CLAUDE.md checklist item 5).
assert!(
report.frontier > 0,
"long history must have a nonzero frontier"
);
let bp_idx = report.frontier - 1;
let bp_msg = &new_msgs[bp_idx];
let has_marker = bp_msg["content"]
.as_array()
.is_some_and(|arr| arr.iter().any(|b| b.get("cache_control").is_some()));
assert!(
has_marker,
"expected a cache_control breakpoint on message {bp_idx}, got {bp_msg}"
);
}
fn long_anthropic_body() -> Value {
let long = long_text();
let mut messages = vec![];
for _ in 0..16 {
messages.push(json!({"role":"user","content": long}));
messages.push(json!({"role":"assistant","content": long}));
}
messages.push(json!({"role":"user","content":"what is the latest?"}));
json!({
"model":"claude-sonnet-5",
"system":"you are helpful",
"messages": messages,
})
}
#[test]
fn optimize_anthropic_bytes_live_shrinks_and_keeps_cache_control() {
let body = Bytes::from(serde_json::to_vec(&long_anthropic_body()).unwrap());
let metrics = crate::metrics::Metrics::new();
let engine = OptimizerEngine::new(Mode::Live);
let out = engine.optimize_anthropic_bytes(body.clone(), "messages", &metrics);
assert!(out.len() < body.len(), "live output must be smaller");
// The frontier cache_control breakpoint must survive to the wire bytes --
// the whole point of doing this on Bytes instead of the typed round-trip.
let root: Value = serde_json::from_slice(&out).unwrap();
let has_marker = root["messages"].as_array().unwrap().iter().any(|m| {
m["content"]
.as_array()
.is_some_and(|arr| arr.iter().any(|b| b.get("cache_control").is_some()))
});
assert!(
has_marker,
"cache_control breakpoint dropped from wire bytes"
);
assert_eq!(metrics.snapshot().optimizer_compressed_total, 1);
}
#[test]
fn optimize_anthropic_bytes_off_is_noop() {
let body = Bytes::from(serde_json::to_vec(&long_anthropic_body()).unwrap());
let metrics = crate::metrics::Metrics::new();
let engine = OptimizerEngine::new(Mode::Off);
let out = engine.optimize_anthropic_bytes(body.clone(), "messages", &metrics);
assert_eq!(out, body, "off mode must return the body unchanged");
assert_eq!(metrics.snapshot().optimizer_compressed_total, 0);
}
#[test]
fn optimize_anthropic_bytes_fails_open_on_garbage() {
let body = Bytes::from_static(b"not json");
let metrics = crate::metrics::Metrics::new();
let engine = OptimizerEngine::new(Mode::Live);
let out = engine.optimize_anthropic_bytes(body.clone(), "messages", &metrics);
assert_eq!(out, body, "garbage body returned unchanged");
assert_eq!(metrics.snapshot().optimizer_compressed_total, 0);
}
#[test]
fn with_mode_override_prefers_override() {
let engine = OptimizerEngine::new(Mode::Off);
let overridden = engine.with_mode_override("live");
assert_eq!(overridden.policy.mode, Mode::Live);
// The original engine is untouched.
assert_eq!(engine.policy.mode, Mode::Off);
}
#[test]
fn with_mode_override_falls_back_on_unparseable_value() {
let engine = OptimizerEngine::new(Mode::Shadow);
let overridden = engine.with_mode_override("not-a-real-mode");
assert_eq!(overridden.policy.mode, Mode::Shadow);
}
#[test]
fn resolve_runtime_optimizer_prefers_runtime_override() {
let engine = OptimizerEngine::new(Mode::Off);
let resolved = resolve_runtime_optimizer(&engine, "live");
assert_eq!(resolved.policy.mode, Mode::Live);
}
#[test]
fn resolve_runtime_optimizer_keeps_static_when_modes_match() {
let engine = OptimizerEngine::new(Mode::Shadow);
let resolved = resolve_runtime_optimizer(&engine, "shadow");
assert_eq!(resolved.policy.mode, Mode::Shadow);
}
#[test]
fn resolve_runtime_optimizer_falls_back_on_unparseable_value() {
let engine = OptimizerEngine::new(Mode::Shadow);
let resolved = resolve_runtime_optimizer(&engine, "not-a-real-mode");
assert_eq!(resolved.policy.mode, Mode::Shadow);
}
#[test]
fn route_precedence_wins_over_runtime_and_static() {
// Full three-tier precedence, mirroring `effective_optimizer`'s own logic:
// static (env-seeded) engine mode is Off, runtime tier says Shadow, but a
// route-level override of Live must win over both.
let static_engine = OptimizerEngine::new(Mode::Off);
let runtime_tier = resolve_runtime_optimizer(&static_engine, "shadow");
assert_eq!(
runtime_tier.policy.mode,
Mode::Shadow,
"runtime beats static"
);
// Route override is applied directly against the static engine (as
// `effective_optimizer` does), bypassing the runtime tier entirely.
let route_tier = static_engine.with_mode_override("live");
assert_eq!(
route_tier.policy.mode,
Mode::Live,
"route beats runtime and static"
);
}
#[test]
fn fails_open_on_malformed_messages_field() {
// "messages" is a string, not an array — adapters degrade to an empty
// Conversation rather than panicking, so this must not mutate or panic.
let mut body = json!({"model":"gpt-4o","messages":"not an array"});
let before = body.clone();
let engine = OptimizerEngine::new(Mode::Live);
let report = engine.optimize_openai(&mut body, "chat_completions");
assert_eq!(body, before);
assert!(!report.applied);
}
}
#[path = "optimizer/tests.rs"]
mod tests;
+244
View File
@@ -0,0 +1,244 @@
use super::*;
use serde_json::json;
fn long_text() -> String {
"The quick brown fox jumps over the lazy dog again and again across the wide \
green field toward the distant blue mountains far beyond the winding river."
.repeat(2)
}
fn long_openai_body() -> Value {
let long = long_text();
let mut messages = vec![json!({"role":"system","content":"you are helpful"})];
for _ in 0..16 {
messages.push(json!({"role":"user","content": long}));
messages.push(json!({"role":"assistant","content": long}));
}
messages.push(json!({"role":"user","content":"what is the latest?"}));
json!({"model":"gpt-4o","messages": messages})
}
#[test]
fn resolve_default_mode_unset_is_off() {
// Deliberately does not touch process env: relies on OPTIMIZER_MODE being
// absent by default in the test environment.
if std::env::var("OPTIMIZER_MODE").is_err() {
assert_eq!(resolve_default_mode(), Mode::Off);
}
}
#[test]
fn off_mode_never_mutates_and_reports_off() {
let mut body = long_openai_body();
let before = body.clone();
let engine = OptimizerEngine::new(Mode::Off);
let report = engine.optimize_openai(&mut body, "chat_completions");
assert_eq!(body, before, "off mode must not mutate the body");
assert_eq!(report.mode, Mode::Off);
assert!(!report.applied);
}
#[test]
fn shadow_mode_never_mutates_but_reports_savings() {
let mut body = long_openai_body();
let before = body.clone();
let engine = OptimizerEngine::new(Mode::Shadow);
let report = engine.optimize_openai(&mut body, "chat_completions");
assert_eq!(body, before, "shadow mode must not mutate the body");
assert!(
report.removed_tokens_est > 0,
"shadow should still report would-be savings on a long convo"
);
}
#[test]
fn live_mode_compresses_openai_history_and_preserves_latest_and_system() {
let mut body = long_openai_body();
let orig_msgs = body["messages"].as_array().unwrap().clone();
let engine = OptimizerEngine::new(Mode::Live);
let report = engine.optimize_openai(&mut body, "chat_completions");
assert!(report.applied || report.rewrite_suffix_tokens == 0);
let new_msgs = body["messages"].as_array().unwrap();
assert_eq!(orig_msgs.len(), new_msgs.len());
assert_eq!(orig_msgs[0], new_msgs[0], "system untouched");
assert_eq!(
orig_msgs.last().unwrap(),
new_msgs.last().unwrap(),
"latest message untouched"
);
}
#[test]
fn live_mode_compresses_anthropic_history_and_preserves_latest() {
let long = long_text();
let mut messages = vec![];
for _ in 0..16 {
messages.push(json!({"role":"user","content": long}));
messages.push(json!({"role":"assistant","content": long}));
}
messages.push(json!({"role":"user","content":"what is the latest?"}));
let mut body = json!({
"model":"claude-sonnet-5",
"system":"you are helpful",
"messages": messages,
});
let orig_msgs = body["messages"].as_array().unwrap().clone();
let orig_system = body["system"].clone();
let engine = OptimizerEngine::new(Mode::Live);
let report = engine.optimize_anthropic(&mut body, "messages");
assert_eq!(body["system"], orig_system, "system field untouched");
let new_msgs = body["messages"].as_array().unwrap();
assert_eq!(orig_msgs.len(), new_msgs.len());
assert_eq!(
orig_msgs.last().unwrap(),
new_msgs.last().unwrap(),
"latest message untouched"
);
// Live mode must place the deepest cache breakpoint at the frontier, not just
// compress text (crates/optimizer/CLAUDE.md checklist item 5).
assert!(
report.frontier > 0,
"long history must have a nonzero frontier"
);
let bp_idx = report.frontier - 1;
let bp_msg = &new_msgs[bp_idx];
let has_marker = bp_msg["content"]
.as_array()
.is_some_and(|arr| arr.iter().any(|b| b.get("cache_control").is_some()));
assert!(
has_marker,
"expected a cache_control breakpoint on message {bp_idx}, got {bp_msg}"
);
}
fn long_anthropic_body() -> Value {
let long = long_text();
let mut messages = vec![];
for _ in 0..16 {
messages.push(json!({"role":"user","content": long}));
messages.push(json!({"role":"assistant","content": long}));
}
messages.push(json!({"role":"user","content":"what is the latest?"}));
json!({
"model":"claude-sonnet-5",
"system":"you are helpful",
"messages": messages,
})
}
#[test]
fn optimize_anthropic_bytes_live_shrinks_and_keeps_cache_control() {
let body = Bytes::from(serde_json::to_vec(&long_anthropic_body()).unwrap());
let metrics = crate::metrics::Metrics::new();
let engine = OptimizerEngine::new(Mode::Live);
let out = engine.optimize_anthropic_bytes(body.clone(), "messages", &metrics);
assert!(out.len() < body.len(), "live output must be smaller");
// The frontier cache_control breakpoint must survive to the wire bytes --
// the whole point of doing this on Bytes instead of the typed round-trip.
let root: Value = serde_json::from_slice(&out).unwrap();
let has_marker = root["messages"].as_array().unwrap().iter().any(|m| {
m["content"]
.as_array()
.is_some_and(|arr| arr.iter().any(|b| b.get("cache_control").is_some()))
});
assert!(
has_marker,
"cache_control breakpoint dropped from wire bytes"
);
assert_eq!(metrics.snapshot().optimizer_compressed_total, 1);
}
#[test]
fn optimize_anthropic_bytes_off_is_noop() {
let body = Bytes::from(serde_json::to_vec(&long_anthropic_body()).unwrap());
let metrics = crate::metrics::Metrics::new();
let engine = OptimizerEngine::new(Mode::Off);
let out = engine.optimize_anthropic_bytes(body.clone(), "messages", &metrics);
assert_eq!(out, body, "off mode must return the body unchanged");
assert_eq!(metrics.snapshot().optimizer_compressed_total, 0);
}
#[test]
fn optimize_anthropic_bytes_fails_open_on_garbage() {
let body = Bytes::from_static(b"not json");
let metrics = crate::metrics::Metrics::new();
let engine = OptimizerEngine::new(Mode::Live);
let out = engine.optimize_anthropic_bytes(body.clone(), "messages", &metrics);
assert_eq!(out, body, "garbage body returned unchanged");
assert_eq!(metrics.snapshot().optimizer_compressed_total, 0);
}
#[test]
fn with_mode_override_prefers_override() {
let engine = OptimizerEngine::new(Mode::Off);
let overridden = engine.with_mode_override("live");
assert_eq!(overridden.policy.mode, Mode::Live);
// The original engine is untouched.
assert_eq!(engine.policy.mode, Mode::Off);
}
#[test]
fn with_mode_override_falls_back_on_unparseable_value() {
let engine = OptimizerEngine::new(Mode::Shadow);
let overridden = engine.with_mode_override("not-a-real-mode");
assert_eq!(overridden.policy.mode, Mode::Shadow);
}
#[test]
fn resolve_runtime_optimizer_prefers_runtime_override() {
let engine = OptimizerEngine::new(Mode::Off);
let resolved = resolve_runtime_optimizer(&engine, "live");
assert_eq!(resolved.policy.mode, Mode::Live);
}
#[test]
fn resolve_runtime_optimizer_keeps_static_when_modes_match() {
let engine = OptimizerEngine::new(Mode::Shadow);
let resolved = resolve_runtime_optimizer(&engine, "shadow");
assert_eq!(resolved.policy.mode, Mode::Shadow);
}
#[test]
fn resolve_runtime_optimizer_falls_back_on_unparseable_value() {
let engine = OptimizerEngine::new(Mode::Shadow);
let resolved = resolve_runtime_optimizer(&engine, "not-a-real-mode");
assert_eq!(resolved.policy.mode, Mode::Shadow);
}
#[test]
fn route_precedence_wins_over_runtime_and_static() {
// Full three-tier precedence, mirroring `effective_optimizer`'s own logic:
// static (env-seeded) engine mode is Off, runtime tier says Shadow, but a
// route-level override of Live must win over both.
let static_engine = OptimizerEngine::new(Mode::Off);
let runtime_tier = resolve_runtime_optimizer(&static_engine, "shadow");
assert_eq!(
runtime_tier.policy.mode,
Mode::Shadow,
"runtime beats static"
);
// Route override is applied directly against the static engine (as
// `effective_optimizer` does), bypassing the runtime tier entirely.
let route_tier = static_engine.with_mode_override("live");
assert_eq!(
route_tier.policy.mode,
Mode::Live,
"route beats runtime and static"
);
}
#[test]
fn fails_open_on_malformed_messages_field() {
// "messages" is a string, not an array — adapters degrade to an empty
// Conversation rather than panicking, so this must not mutate or panic.
let mut body = json!({"model":"gpt-4o","messages":"not an array"});
let before = body.clone();
let engine = OptimizerEngine::new(Mode::Live);
let report = engine.optimize_openai(&mut body, "chat_completions");
assert_eq!(body, before);
assert!(!report.applied);
}
+72
View File
@@ -0,0 +1,72 @@
//! Runtime-specific error type (no axum response types).
use crate::backend::BackendError;
use crate::config::BackendKind;
use std::fmt;
/// Runtime errors that do not expose axum response types.
#[derive(Debug)]
pub enum ChatCompletionError {
InvalidRequest(String),
Translation(anyllm_translate::TranslateError),
Routing(String),
UnsupportedBackend {
backend_name: String,
backend_kind: BackendKind,
},
Backend(BackendError),
StreamRead(String),
StreamParse(String),
StreamBufferOverflow,
StreamTimeout,
}
impl ChatCompletionError {
pub fn status_code(&self) -> u16 {
match self {
Self::InvalidRequest(_) | Self::Translation(_) | Self::UnsupportedBackend { .. } => 400,
Self::Routing(_) => 429,
Self::Backend(e) => e.status_code(),
Self::StreamRead(_)
| Self::StreamParse(_)
| Self::StreamBufferOverflow
| Self::StreamTimeout => 502,
}
}
}
impl fmt::Display for ChatCompletionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRequest(msg) => write!(f, "{msg}"),
Self::Translation(e) => write!(f, "{e}"),
Self::Routing(msg) => write!(f, "{msg}"),
Self::UnsupportedBackend {
backend_name,
backend_kind,
} => write!(
f,
"backend '{backend_name}' ({backend_kind:?}) does not support Chat Completions runtime"
),
Self::Backend(e) => write!(f, "{e}"),
Self::StreamRead(e) => write!(f, "stream read error: {e}"),
Self::StreamParse(e) => write!(f, "stream parse error: {e}"),
Self::StreamBufferOverflow => write!(f, "SSE buffer exceeded maximum size"),
Self::StreamTimeout => write!(f, "stream exceeded wall-clock timeout"),
}
}
}
impl std::error::Error for ChatCompletionError {}
impl From<anyllm_translate::TranslateError> for ChatCompletionError {
fn from(e: anyllm_translate::TranslateError) -> Self {
Self::Translation(e)
}
}
impl From<BackendError> for ChatCompletionError {
fn from(e: BackendError) -> Self {
Self::Backend(e)
}
}
+14 -566
View File
@@ -2,572 +2,20 @@
//!
//! This module exposes model routing and backend dispatch without taking
//! ownership of HTTP routing, auth, admin UI, caching, or tool execution.
//!
//! - [`error`] runtime error type
//! - [`types`] result / metadata / service-trait types
//! - [`service`] the `ChatCompletionRuntime` implementation
//! - [`stream`] SSE chunk stream adapters
use crate::backend::{BackendClient, BackendError, RateLimitHeaders};
use crate::config::{BackendKind, Config, ModelMapping, MultiConfig, OpenAIApiFormat};
use crate::openai_tool_policy::{prepare_openai_tool_request, OpenAiToolPolicyContext};
use anyllm_providers::ProviderCatalog;
use anyllm_translate::{
mapping, openai, translate_anthropic_to_openai_response, translate_openai_to_anthropic_request,
TranslationWarnings,
};
use futures::{
future::{BoxFuture, FutureExt},
Stream,
};
use std::collections::HashMap;
use std::fmt;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::time::Instant;
/// Stream returned by [`ChatCompletionRuntime::complete_stream`].
pub type ChatCompletionChunkStream =
Pin<Box<dyn Stream<Item = Result<openai::ChatCompletionChunk, ChatCompletionError>> + Send>>;
/// Object-safe service API for one Chat Completions call at a time.
pub trait ChatCompletionService: Send + Sync {
fn complete<'a>(
&'a self,
req: openai::ChatCompletionRequest,
) -> BoxFuture<'a, Result<ChatCompletionResult, ChatCompletionError>>;
fn complete_stream<'a>(
&'a self,
req: openai::ChatCompletionRequest,
) -> BoxFuture<'a, Result<ChatCompletionStreamResult, ChatCompletionError>>;
}
/// Non-streaming runtime response.
#[derive(Debug)]
pub struct ChatCompletionResult {
pub response: openai::ChatCompletionResponse,
pub usage: Option<openai::ChatUsage>,
pub rate_limits: RateLimitHeaders,
pub metadata: ChatCompletionMetadata,
pub warnings: TranslationWarnings,
}
/// Streaming runtime response.
pub struct ChatCompletionStreamResult {
pub chunks: ChatCompletionChunkStream,
pub rate_limits: RateLimitHeaders,
pub metadata: ChatCompletionMetadata,
pub warnings: TranslationWarnings,
}
impl fmt::Debug for ChatCompletionStreamResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ChatCompletionStreamResult")
.field("chunks", &"<stream>")
.field("rate_limits", &self.rate_limits)
.field("metadata", &self.metadata)
.field("warnings", &self.warnings)
.finish()
}
}
/// Backend selection metadata for observability and callers that need accounting.
#[derive(Debug, Clone)]
pub struct ChatCompletionMetadata {
pub requested_model: String,
pub selected_backend: String,
pub mapped_model: String,
pub backend_kind: BackendKind,
pub provider_id: Option<String>,
pub api_format: OpenAIApiFormat,
pub used_responses_api: bool,
}
/// Runtime errors that do not expose axum response types.
#[derive(Debug)]
pub enum ChatCompletionError {
InvalidRequest(String),
Translation(anyllm_translate::TranslateError),
Routing(String),
UnsupportedBackend {
backend_name: String,
backend_kind: BackendKind,
},
Backend(BackendError),
StreamRead(String),
StreamParse(String),
StreamBufferOverflow,
StreamTimeout,
}
impl ChatCompletionError {
pub fn status_code(&self) -> u16 {
match self {
Self::InvalidRequest(_) | Self::Translation(_) | Self::UnsupportedBackend { .. } => 400,
Self::Routing(_) => 429,
Self::Backend(e) => e.status_code(),
Self::StreamRead(_)
| Self::StreamParse(_)
| Self::StreamBufferOverflow
| Self::StreamTimeout => 502,
}
}
}
impl fmt::Display for ChatCompletionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRequest(msg) => write!(f, "{msg}"),
Self::Translation(e) => write!(f, "{e}"),
Self::Routing(msg) => write!(f, "{msg}"),
Self::UnsupportedBackend {
backend_name,
backend_kind,
} => write!(
f,
"backend '{backend_name}' ({backend_kind:?}) does not support Chat Completions runtime"
),
Self::Backend(e) => write!(f, "{e}"),
Self::StreamRead(e) => write!(f, "stream read error: {e}"),
Self::StreamParse(e) => write!(f, "stream parse error: {e}"),
Self::StreamBufferOverflow => write!(f, "SSE buffer exceeded maximum size"),
Self::StreamTimeout => write!(f, "stream exceeded wall-clock timeout"),
}
}
}
impl std::error::Error for ChatCompletionError {}
impl From<anyllm_translate::TranslateError> for ChatCompletionError {
fn from(e: anyllm_translate::TranslateError) -> Self {
Self::Translation(e)
}
}
impl From<BackendError> for ChatCompletionError {
fn from(e: BackendError) -> Self {
Self::Backend(e)
}
}
/// Chat Completions runtime built from proxy backend configuration.
#[derive(Clone)]
pub struct ChatCompletionRuntime {
default_backend: String,
backends: Arc<HashMap<String, RuntimeBackend>>,
model_router: Option<Arc<RwLock<crate::config::model_router::ModelRouter>>>,
provider_catalog: Arc<ProviderCatalog>,
}
#[derive(Clone)]
struct RuntimeBackend {
backend: BackendClient,
backend_name: String,
model_mapping: ModelMapping,
backend_kind: BackendKind,
api_format: OpenAIApiFormat,
omit_stream_options: bool,
stream_timeout_secs: u64,
provider_id: Option<String>,
}
struct ResolvedBackend {
state: RuntimeBackend,
mapped_model: String,
deployment: Option<Arc<crate::config::model_router::Deployment>>,
}
impl ChatCompletionRuntime {
/// Build a runtime from a legacy single-backend config.
pub fn from_config(config: Config) -> Self {
let multi = MultiConfig::from_single_config(&config);
let default_backend = multi.default_backend.clone();
let (_, bc) = multi
.backends
.get_key_value(&default_backend)
.expect("wrapped config must contain default backend");
let backend = if config.backend == BackendKind::Bedrock {
BackendClient::from_backend_config(bc)
} else {
BackendClient::new(&config)
};
let mut backends = HashMap::new();
backends.insert(
default_backend.clone(),
RuntimeBackend {
backend,
backend_name: default_backend.clone(),
model_mapping: bc.model_mapping.clone(),
backend_kind: bc.kind.clone(),
api_format: bc.api_format.clone(),
omit_stream_options: bc.omit_stream_options,
stream_timeout_secs: bc.stream_timeout_secs,
provider_id: config.provider_id.clone(),
},
);
Self {
default_backend,
backends: Arc::new(backends),
model_router: None,
provider_catalog: Arc::new(ProviderCatalog::bundled()),
}
}
/// Build a runtime from multi-backend config without model-list routing.
pub fn from_multi_config(config: MultiConfig) -> Self {
Self::from_multi_config_with_model_router(config, None)
}
/// Build a runtime from multi-backend config and an optional model router.
pub fn from_multi_config_with_model_router(
config: MultiConfig,
model_router: Option<Arc<RwLock<crate::config::model_router::ModelRouter>>>,
) -> Self {
let mut backends = HashMap::new();
for (name, bc) in &config.backends {
backends.insert(
name.clone(),
RuntimeBackend {
backend: BackendClient::from_backend_config(bc),
backend_name: name.clone(),
model_mapping: bc.model_mapping.clone(),
backend_kind: bc.kind.clone(),
api_format: bc.api_format.clone(),
omit_stream_options: bc.omit_stream_options,
stream_timeout_secs: bc.stream_timeout_secs,
provider_id: bc.provider_id.clone(),
},
);
}
Self {
default_backend: config.default_backend,
backends: Arc::new(backends),
model_router,
provider_catalog: Arc::new(ProviderCatalog::bundled()),
}
}
fn resolve(&self, model: &str) -> Result<ResolvedBackend, ChatCompletionError> {
if let Some(ref router_lock) = self.model_router {
let router = router_lock.read().unwrap_or_else(|e| e.into_inner());
if let Some(routed) = router.route(model) {
let state = self
.backends
.get(routed.backend_name)
.cloned()
.ok_or_else(|| {
ChatCompletionError::Routing(format!(
"model '{model}' routed to unknown backend '{}'",
routed.backend_name
))
})?;
return Ok(ResolvedBackend {
state,
mapped_model: routed.actual_model.to_string(),
deployment: Some(routed.deployment.clone()),
});
}
if router.has_model(model) {
return Err(ChatCompletionError::Routing(
"all deployments for this model are at their RPM limit".to_string(),
));
}
return Err(ChatCompletionError::InvalidRequest(format!(
"model '{model}' is not configured in model_list"
)));
}
let state = self
.backends
.get(&self.default_backend)
.cloned()
.ok_or_else(|| {
ChatCompletionError::Routing(format!(
"default backend '{}' is not configured",
self.default_backend
))
})?;
Ok(ResolvedBackend {
mapped_model: state.model_mapping.map_model(model),
state,
deployment: None,
})
}
}
impl ChatCompletionService for ChatCompletionRuntime {
fn complete<'a>(
&'a self,
req: openai::ChatCompletionRequest,
) -> BoxFuture<'a, Result<ChatCompletionResult, ChatCompletionError>> {
async move { self.complete_inner(req).await }.boxed()
}
fn complete_stream<'a>(
&'a self,
req: openai::ChatCompletionRequest,
) -> BoxFuture<'a, Result<ChatCompletionStreamResult, ChatCompletionError>> {
async move { self.complete_stream_inner(req).await }.boxed()
}
}
impl ChatCompletionRuntime {
async fn complete_inner(
&self,
req: openai::ChatCompletionRequest,
) -> Result<ChatCompletionResult, ChatCompletionError> {
if req.stream == Some(true) {
return Err(ChatCompletionError::InvalidRequest(
"complete does not accept stream=true; use complete_stream".to_string(),
));
}
let requested_model = req.model.clone();
let resolved = self.resolve(&requested_model)?;
let metadata = metadata(&requested_model, &resolved);
let mut warnings = TranslationWarnings::default();
match &resolved.state.backend {
BackendClient::OpenAI(client)
| BackendClient::AzureOpenAI(client)
| BackendClient::Vertex(client)
| BackendClient::GeminiOpenAI(client) => {
let mut openai_req = req;
prepare_openai_request(
&mut openai_req,
&resolved,
false,
&mut warnings,
&self.provider_catalog,
)?;
let start = record_start(&resolved.deployment);
match client.chat_completion(&openai_req).await {
Ok((response, _status, rate_limits)) => {
record_finish(&resolved.deployment, start);
if let Some(ref deployment) = resolved.deployment {
if let Some(ref usage) = response.usage {
deployment.record_tokens(usage.total_tokens as u64);
}
}
Ok(ChatCompletionResult {
usage: response.usage.clone(),
response,
rate_limits,
metadata,
warnings,
})
}
Err(e) => {
record_finish(&resolved.deployment, start);
Err(ChatCompletionError::Backend(BackendError::from(e)))
}
}
}
BackendClient::OpenAIResponses(client) => {
let anthropic_req = translate_openai_to_anthropic_request(&req, &mut warnings)?;
let mut responses_req =
mapping::responses_message_map::anthropic_to_responses_request(&anthropic_req);
responses_req.model = resolved.mapped_model.clone();
let start = record_start(&resolved.deployment);
match client.responses(&responses_req).await {
Ok((resp, _status, rate_limits)) => {
record_finish(&resolved.deployment, start);
let anthropic_resp =
mapping::responses_message_map::responses_to_anthropic_response(
&resp,
&requested_model,
);
if let Some(ref deployment) = resolved.deployment {
deployment.record_tokens(
anthropic_resp.usage.input_tokens as u64
+ anthropic_resp.usage.output_tokens as u64,
);
}
let response = translate_anthropic_to_openai_response(
&anthropic_resp,
&requested_model,
);
Ok(ChatCompletionResult {
usage: response.usage.clone(),
response,
rate_limits,
metadata,
warnings,
})
}
Err(e) => {
record_finish(&resolved.deployment, start);
Err(ChatCompletionError::Backend(BackendError::from(e)))
}
}
}
BackendClient::Anthropic(_)
| BackendClient::Bedrock(_)
| BackendClient::GeminiNative(_) => Err(ChatCompletionError::UnsupportedBackend {
backend_name: resolved.state.backend_name,
backend_kind: resolved.state.backend_kind,
}),
}
}
async fn complete_stream_inner(
&self,
req: openai::ChatCompletionRequest,
) -> Result<ChatCompletionStreamResult, ChatCompletionError> {
let requested_model = req.model.clone();
let resolved = self.resolve(&requested_model)?;
let metadata = metadata(&requested_model, &resolved);
let mut warnings = TranslationWarnings::default();
match &resolved.state.backend {
BackendClient::OpenAI(client)
| BackendClient::AzureOpenAI(client)
| BackendClient::Vertex(client)
| BackendClient::GeminiOpenAI(client) => {
let mut openai_req = req;
prepare_openai_request(
&mut openai_req,
&resolved,
true,
&mut warnings,
&self.provider_catalog,
)?;
let start = record_start(&resolved.deployment);
match client.chat_completion_stream(&openai_req).await {
Ok((response, rate_limits)) => Ok(ChatCompletionStreamResult {
chunks: openai_chunk_stream(
response,
resolved.state.stream_timeout_secs,
DeploymentLatencyGuard::from_started(
resolved.deployment.clone(),
start,
),
),
rate_limits,
metadata,
warnings,
}),
Err(e) => {
record_finish(&resolved.deployment, start);
Err(ChatCompletionError::Backend(BackendError::from(e)))
}
}
}
BackendClient::OpenAIResponses(client) => {
let anthropic_req = translate_openai_to_anthropic_request(&req, &mut warnings)?;
let mut responses_req =
mapping::responses_message_map::anthropic_to_responses_request(&anthropic_req);
responses_req.model = resolved.mapped_model.clone();
responses_req.stream = Some(true);
let start = record_start(&resolved.deployment);
match client.responses_stream(&responses_req).await {
Ok((response, rate_limits)) => Ok(ChatCompletionStreamResult {
chunks: responses_chunk_stream(
response,
requested_model,
resolved.state.stream_timeout_secs,
DeploymentLatencyGuard::from_started(
resolved.deployment.clone(),
start,
),
),
rate_limits,
metadata,
warnings,
}),
Err(e) => {
record_finish(&resolved.deployment, start);
Err(ChatCompletionError::Backend(BackendError::from(e)))
}
}
}
BackendClient::Anthropic(_)
| BackendClient::Bedrock(_)
| BackendClient::GeminiNative(_) => Err(ChatCompletionError::UnsupportedBackend {
backend_name: resolved.state.backend_name,
backend_kind: resolved.state.backend_kind,
}),
}
}
}
fn metadata(requested_model: &str, resolved: &ResolvedBackend) -> ChatCompletionMetadata {
ChatCompletionMetadata {
requested_model: requested_model.to_string(),
selected_backend: resolved.state.backend_name.clone(),
mapped_model: resolved.mapped_model.clone(),
backend_kind: resolved.state.backend_kind.clone(),
provider_id: resolved.state.provider_id.clone(),
api_format: resolved.state.api_format.clone(),
used_responses_api: matches!(resolved.state.backend, BackendClient::OpenAIResponses(_)),
}
}
fn record_start(
deployment: &Option<Arc<crate::config::model_router::Deployment>>,
) -> Option<Instant> {
if let Some(d) = deployment {
d.record_start();
Some(Instant::now())
} else {
None
}
}
fn record_finish(
deployment: &Option<Arc<crate::config::model_router::Deployment>>,
start: Option<Instant>,
) {
if let (Some(d), Some(start)) = (deployment, start) {
d.record_finish(start.elapsed().as_millis() as u64);
}
}
mod error;
mod service;
pub mod stream;
use stream::{openai_chunk_stream, responses_chunk_stream, DeploymentLatencyGuard};
mod types;
fn prepare_openai_request(
req: &mut openai::ChatCompletionRequest,
resolved: &ResolvedBackend,
streaming: bool,
warnings: &mut TranslationWarnings,
provider_catalog: &ProviderCatalog,
) -> Result<(), ChatCompletionError> {
req.model = resolved.mapped_model.clone();
req.stream = Some(streaming);
if streaming {
if resolved.state.omit_stream_options {
if req.stream_options.is_some() {
warnings.add("stream_options");
}
req.stream_options = None;
} else {
req.stream_options = Some(openai::StreamOptions {
include_usage: true,
});
}
} else if resolved.state.omit_stream_options {
if req.stream_options.is_some() {
warnings.add("stream_options");
}
req.stream_options = None;
}
prepare_openai_tool_request(
req,
OpenAiToolPolicyContext {
backend_kind: resolved.state.backend_kind.clone(),
provider_id: resolved.state.provider_id.as_deref(),
model: &resolved.mapped_model,
provider_catalog,
},
warnings,
)
.map(|_| ())
.map_err(|e| ChatCompletionError::InvalidRequest(e.to_string()))
}
pub use error::ChatCompletionError;
pub use service::ChatCompletionRuntime;
pub use types::{
ChatCompletionChunkStream, ChatCompletionMetadata, ChatCompletionResult, ChatCompletionService,
ChatCompletionStreamResult,
};
+443
View File
@@ -0,0 +1,443 @@
//! `ChatCompletionRuntime`: model routing, backend dispatch, and the
//! `ChatCompletionService` implementation.
use super::error::ChatCompletionError;
use super::stream::{openai_chunk_stream, responses_chunk_stream, DeploymentLatencyGuard};
use super::types::{
ChatCompletionMetadata, ChatCompletionResult, ChatCompletionService, ChatCompletionStreamResult,
};
use crate::backend::{BackendClient, BackendError};
use crate::config::{BackendKind, Config, ModelMapping, MultiConfig, OpenAIApiFormat};
use crate::openai_tool_policy::{prepare_openai_tool_request, OpenAiToolPolicyContext};
use anyllm_providers::ProviderCatalog;
use anyllm_translate::{
mapping, openai, translate_anthropic_to_openai_response, translate_openai_to_anthropic_request,
TranslationWarnings,
};
use futures::future::{BoxFuture, FutureExt};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::Instant;
/// Chat Completions runtime built from proxy backend configuration.
#[derive(Clone)]
pub struct ChatCompletionRuntime {
default_backend: String,
backends: Arc<HashMap<String, RuntimeBackend>>,
model_router: Option<Arc<RwLock<crate::config::model_router::ModelRouter>>>,
provider_catalog: Arc<ProviderCatalog>,
}
#[derive(Clone)]
struct RuntimeBackend {
backend: BackendClient,
backend_name: String,
model_mapping: ModelMapping,
backend_kind: BackendKind,
api_format: OpenAIApiFormat,
omit_stream_options: bool,
stream_timeout_secs: u64,
provider_id: Option<String>,
}
struct ResolvedBackend {
state: RuntimeBackend,
mapped_model: String,
deployment: Option<Arc<crate::config::model_router::Deployment>>,
}
impl ChatCompletionRuntime {
/// Build a runtime from a legacy single-backend config.
pub fn from_config(config: Config) -> Self {
let multi = MultiConfig::from_single_config(&config);
let default_backend = multi.default_backend.clone();
let (_, bc) = multi
.backends
.get_key_value(&default_backend)
.expect("wrapped config must contain default backend");
let backend = if config.backend == BackendKind::Bedrock {
BackendClient::from_backend_config(bc)
} else {
BackendClient::new(&config)
};
let mut backends = HashMap::new();
backends.insert(
default_backend.clone(),
RuntimeBackend {
backend,
backend_name: default_backend.clone(),
model_mapping: bc.model_mapping.clone(),
backend_kind: bc.kind.clone(),
api_format: bc.api_format.clone(),
omit_stream_options: bc.omit_stream_options,
stream_timeout_secs: bc.stream_timeout_secs,
provider_id: config.provider_id.clone(),
},
);
Self {
default_backend,
backends: Arc::new(backends),
model_router: None,
provider_catalog: Arc::new(ProviderCatalog::bundled()),
}
}
/// Build a runtime from multi-backend config without model-list routing.
pub fn from_multi_config(config: MultiConfig) -> Self {
Self::from_multi_config_with_model_router(config, None)
}
/// Build a runtime from multi-backend config and an optional model router.
pub fn from_multi_config_with_model_router(
config: MultiConfig,
model_router: Option<Arc<RwLock<crate::config::model_router::ModelRouter>>>,
) -> Self {
let mut backends = HashMap::new();
for (name, bc) in &config.backends {
backends.insert(
name.clone(),
RuntimeBackend {
backend: BackendClient::from_backend_config(bc),
backend_name: name.clone(),
model_mapping: bc.model_mapping.clone(),
backend_kind: bc.kind.clone(),
api_format: bc.api_format.clone(),
omit_stream_options: bc.omit_stream_options,
stream_timeout_secs: bc.stream_timeout_secs,
provider_id: bc.provider_id.clone(),
},
);
}
Self {
default_backend: config.default_backend,
backends: Arc::new(backends),
model_router,
provider_catalog: Arc::new(ProviderCatalog::bundled()),
}
}
fn resolve(&self, model: &str) -> Result<ResolvedBackend, ChatCompletionError> {
if let Some(ref router_lock) = self.model_router {
let router = router_lock.read().unwrap_or_else(|e| e.into_inner());
if let Some(routed) = router.route(model) {
let state = self
.backends
.get(routed.backend_name)
.cloned()
.ok_or_else(|| {
ChatCompletionError::Routing(format!(
"model '{model}' routed to unknown backend '{}'",
routed.backend_name
))
})?;
return Ok(ResolvedBackend {
state,
mapped_model: routed.actual_model.to_string(),
deployment: Some(routed.deployment.clone()),
});
}
if router.has_model(model) {
return Err(ChatCompletionError::Routing(
"all deployments for this model are at their RPM limit".to_string(),
));
}
return Err(ChatCompletionError::InvalidRequest(format!(
"model '{model}' is not configured in model_list"
)));
}
let state = self
.backends
.get(&self.default_backend)
.cloned()
.ok_or_else(|| {
ChatCompletionError::Routing(format!(
"default backend '{}' is not configured",
self.default_backend
))
})?;
Ok(ResolvedBackend {
mapped_model: state.model_mapping.map_model(model),
state,
deployment: None,
})
}
}
impl ChatCompletionService for ChatCompletionRuntime {
fn complete<'a>(
&'a self,
req: openai::ChatCompletionRequest,
) -> BoxFuture<'a, Result<ChatCompletionResult, ChatCompletionError>> {
async move { self.complete_inner(req).await }.boxed()
}
fn complete_stream<'a>(
&'a self,
req: openai::ChatCompletionRequest,
) -> BoxFuture<'a, Result<ChatCompletionStreamResult, ChatCompletionError>> {
async move { self.complete_stream_inner(req).await }.boxed()
}
}
impl ChatCompletionRuntime {
async fn complete_inner(
&self,
req: openai::ChatCompletionRequest,
) -> Result<ChatCompletionResult, ChatCompletionError> {
if req.stream == Some(true) {
return Err(ChatCompletionError::InvalidRequest(
"complete does not accept stream=true; use complete_stream".to_string(),
));
}
let requested_model = req.model.clone();
let resolved = self.resolve(&requested_model)?;
let metadata = metadata(&requested_model, &resolved);
let mut warnings = TranslationWarnings::default();
match &resolved.state.backend {
BackendClient::OpenAI(client)
| BackendClient::AzureOpenAI(client)
| BackendClient::Vertex(client)
| BackendClient::GeminiOpenAI(client) => {
let mut openai_req = req;
prepare_openai_request(
&mut openai_req,
&resolved,
false,
&mut warnings,
&self.provider_catalog,
)?;
let start = record_start(&resolved.deployment);
match client.chat_completion(&openai_req).await {
Ok((response, _status, rate_limits)) => {
record_finish(&resolved.deployment, start);
if let Some(ref deployment) = resolved.deployment {
if let Some(ref usage) = response.usage {
deployment.record_tokens(usage.total_tokens as u64);
}
}
Ok(ChatCompletionResult {
usage: response.usage.clone(),
response,
rate_limits,
metadata,
warnings,
})
}
Err(e) => {
record_finish(&resolved.deployment, start);
Err(ChatCompletionError::Backend(BackendError::from(e)))
}
}
}
BackendClient::OpenAIResponses(client) => {
let anthropic_req = translate_openai_to_anthropic_request(&req, &mut warnings)?;
let mut responses_req =
mapping::responses_message_map::anthropic_to_responses_request(&anthropic_req);
responses_req.model = resolved.mapped_model.clone();
let start = record_start(&resolved.deployment);
match client.responses(&responses_req).await {
Ok((resp, _status, rate_limits)) => {
record_finish(&resolved.deployment, start);
let anthropic_resp =
mapping::responses_message_map::responses_to_anthropic_response(
&resp,
&requested_model,
);
if let Some(ref deployment) = resolved.deployment {
deployment.record_tokens(
anthropic_resp.usage.input_tokens as u64
+ anthropic_resp.usage.output_tokens as u64,
);
}
let response = translate_anthropic_to_openai_response(
&anthropic_resp,
&requested_model,
);
Ok(ChatCompletionResult {
usage: response.usage.clone(),
response,
rate_limits,
metadata,
warnings,
})
}
Err(e) => {
record_finish(&resolved.deployment, start);
Err(ChatCompletionError::Backend(BackendError::from(e)))
}
}
}
BackendClient::Anthropic(_)
| BackendClient::Bedrock(_)
| BackendClient::GeminiNative(_) => Err(ChatCompletionError::UnsupportedBackend {
backend_name: resolved.state.backend_name,
backend_kind: resolved.state.backend_kind,
}),
}
}
async fn complete_stream_inner(
&self,
req: openai::ChatCompletionRequest,
) -> Result<ChatCompletionStreamResult, ChatCompletionError> {
let requested_model = req.model.clone();
let resolved = self.resolve(&requested_model)?;
let metadata = metadata(&requested_model, &resolved);
let mut warnings = TranslationWarnings::default();
match &resolved.state.backend {
BackendClient::OpenAI(client)
| BackendClient::AzureOpenAI(client)
| BackendClient::Vertex(client)
| BackendClient::GeminiOpenAI(client) => {
let mut openai_req = req;
prepare_openai_request(
&mut openai_req,
&resolved,
true,
&mut warnings,
&self.provider_catalog,
)?;
let start = record_start(&resolved.deployment);
match client.chat_completion_stream(&openai_req).await {
Ok((response, rate_limits)) => Ok(ChatCompletionStreamResult {
chunks: openai_chunk_stream(
response,
resolved.state.stream_timeout_secs,
DeploymentLatencyGuard::from_started(
resolved.deployment.clone(),
start,
),
),
rate_limits,
metadata,
warnings,
}),
Err(e) => {
record_finish(&resolved.deployment, start);
Err(ChatCompletionError::Backend(BackendError::from(e)))
}
}
}
BackendClient::OpenAIResponses(client) => {
let anthropic_req = translate_openai_to_anthropic_request(&req, &mut warnings)?;
let mut responses_req =
mapping::responses_message_map::anthropic_to_responses_request(&anthropic_req);
responses_req.model = resolved.mapped_model.clone();
responses_req.stream = Some(true);
let start = record_start(&resolved.deployment);
match client.responses_stream(&responses_req).await {
Ok((response, rate_limits)) => Ok(ChatCompletionStreamResult {
chunks: responses_chunk_stream(
response,
requested_model,
resolved.state.stream_timeout_secs,
DeploymentLatencyGuard::from_started(
resolved.deployment.clone(),
start,
),
),
rate_limits,
metadata,
warnings,
}),
Err(e) => {
record_finish(&resolved.deployment, start);
Err(ChatCompletionError::Backend(BackendError::from(e)))
}
}
}
BackendClient::Anthropic(_)
| BackendClient::Bedrock(_)
| BackendClient::GeminiNative(_) => Err(ChatCompletionError::UnsupportedBackend {
backend_name: resolved.state.backend_name,
backend_kind: resolved.state.backend_kind,
}),
}
}
}
fn metadata(requested_model: &str, resolved: &ResolvedBackend) -> ChatCompletionMetadata {
ChatCompletionMetadata {
requested_model: requested_model.to_string(),
selected_backend: resolved.state.backend_name.clone(),
mapped_model: resolved.mapped_model.clone(),
backend_kind: resolved.state.backend_kind.clone(),
provider_id: resolved.state.provider_id.clone(),
api_format: resolved.state.api_format.clone(),
used_responses_api: matches!(resolved.state.backend, BackendClient::OpenAIResponses(_)),
}
}
fn record_start(
deployment: &Option<Arc<crate::config::model_router::Deployment>>,
) -> Option<Instant> {
if let Some(d) = deployment {
d.record_start();
Some(Instant::now())
} else {
None
}
}
fn record_finish(
deployment: &Option<Arc<crate::config::model_router::Deployment>>,
start: Option<Instant>,
) {
if let (Some(d), Some(start)) = (deployment, start) {
d.record_finish(start.elapsed().as_millis() as u64);
}
}
fn prepare_openai_request(
req: &mut openai::ChatCompletionRequest,
resolved: &ResolvedBackend,
streaming: bool,
warnings: &mut TranslationWarnings,
provider_catalog: &ProviderCatalog,
) -> Result<(), ChatCompletionError> {
req.model = resolved.mapped_model.clone();
req.stream = Some(streaming);
if streaming {
if resolved.state.omit_stream_options {
if req.stream_options.is_some() {
warnings.add("stream_options");
}
req.stream_options = None;
} else {
req.stream_options = Some(openai::StreamOptions {
include_usage: true,
});
}
} else if resolved.state.omit_stream_options {
if req.stream_options.is_some() {
warnings.add("stream_options");
}
req.stream_options = None;
}
prepare_openai_tool_request(
req,
OpenAiToolPolicyContext {
backend_kind: resolved.state.backend_kind.clone(),
provider_id: resolved.state.provider_id.as_deref(),
model: &resolved.mapped_model,
provider_catalog,
},
warnings,
)
.map(|_| ())
.map_err(|e| ChatCompletionError::InvalidRequest(e.to_string()))
}
+67
View File
@@ -0,0 +1,67 @@
//! Runtime result, metadata, and service-trait types.
use super::error::ChatCompletionError;
use crate::backend::RateLimitHeaders;
use crate::config::{BackendKind, OpenAIApiFormat};
use anyllm_translate::{openai, TranslationWarnings};
use futures::{future::BoxFuture, Stream};
use std::fmt;
use std::pin::Pin;
/// Stream returned by [`ChatCompletionRuntime::complete_stream`].
pub type ChatCompletionChunkStream =
Pin<Box<dyn Stream<Item = Result<openai::ChatCompletionChunk, ChatCompletionError>> + Send>>;
/// Object-safe service API for one Chat Completions call at a time.
pub trait ChatCompletionService: Send + Sync {
fn complete<'a>(
&'a self,
req: openai::ChatCompletionRequest,
) -> BoxFuture<'a, Result<ChatCompletionResult, ChatCompletionError>>;
fn complete_stream<'a>(
&'a self,
req: openai::ChatCompletionRequest,
) -> BoxFuture<'a, Result<ChatCompletionStreamResult, ChatCompletionError>>;
}
/// Non-streaming runtime response.
#[derive(Debug)]
pub struct ChatCompletionResult {
pub response: openai::ChatCompletionResponse,
pub usage: Option<openai::ChatUsage>,
pub rate_limits: RateLimitHeaders,
pub metadata: ChatCompletionMetadata,
pub warnings: TranslationWarnings,
}
/// Streaming runtime response.
pub struct ChatCompletionStreamResult {
pub chunks: ChatCompletionChunkStream,
pub rate_limits: RateLimitHeaders,
pub metadata: ChatCompletionMetadata,
pub warnings: TranslationWarnings,
}
impl fmt::Debug for ChatCompletionStreamResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ChatCompletionStreamResult")
.field("chunks", &"<stream>")
.field("rate_limits", &self.rate_limits)
.field("metadata", &self.metadata)
.field("warnings", &self.warnings)
.finish()
}
}
/// Backend selection metadata for observability and callers that need accounting.
#[derive(Debug, Clone)]
pub struct ChatCompletionMetadata {
pub requested_model: String,
pub selected_backend: String,
pub mapped_model: String,
pub backend_kind: BackendKind,
pub provider_id: Option<String>,
pub api_format: OpenAIApiFormat,
pub used_responses_api: bool,
}
+2 -75
View File
@@ -509,78 +509,5 @@ pub async fn validate_auth(
}
#[cfg(test)]
mod auth_mode_tests {
use super::*;
#[test]
fn parse_auth_mode_new_names() {
assert_eq!(AuthMode::from_env_str("oidc"), AuthMode::OidcOnly);
assert_eq!(AuthMode::from_env_str("oidc-only"), AuthMode::OidcOnly);
assert_eq!(AuthMode::from_env_str("oidc_only"), AuthMode::OidcOnly);
assert_eq!(AuthMode::from_env_str("keys"), AuthMode::KeysOnly);
assert_eq!(AuthMode::from_env_str("keys-only"), AuthMode::KeysOnly);
assert_eq!(AuthMode::from_env_str("keys_only"), AuthMode::KeysOnly);
assert_eq!(AuthMode::from_env_str("both"), AuthMode::Both);
}
#[test]
fn parse_auth_mode_legacy_names() {
assert_eq!(AuthMode::from_env_str("jwt_only"), AuthMode::OidcOnly);
assert_eq!(AuthMode::from_env_str("jwt_or_keys"), AuthMode::Both);
assert_eq!(AuthMode::from_env_str("JWT_ONLY"), AuthMode::OidcOnly);
}
#[test]
fn parse_auth_mode_unknown_defaults_to_both() {
assert_eq!(AuthMode::from_env_str("unknown"), AuthMode::Both);
assert_eq!(AuthMode::from_env_str(""), AuthMode::Both);
}
#[test]
fn auth_mode_oidc_only() {
assert!(AuthMode::OidcOnly.allows_oidc());
assert!(!AuthMode::OidcOnly.allows_key_auth());
}
#[test]
fn auth_mode_keys_only() {
assert!(AuthMode::KeysOnly.allows_key_auth());
assert!(!AuthMode::KeysOnly.allows_oidc());
}
#[test]
fn auth_mode_both_allows_all() {
assert!(AuthMode::Both.allows_oidc());
assert!(AuthMode::Both.allows_key_auth());
}
#[test]
fn auth_mode_from_env_defaults_to_both() {
let mode = AuthMode::from_env_str("unrecognized_value");
assert_eq!(mode, AuthMode::Both);
}
#[test]
fn forward_client_auth_rejects_multiple_static_keys_without_open_relay() {
assert!(forward_client_auth_misconfigured(2, false));
}
#[test]
fn forward_client_auth_allows_open_relay_even_with_multiple_keys() {
// Reflects that `open_relay_active()` can only be true when
// `distinct_static_key_count()` is 0 (see the OPEN_RELAY static) --
// this combination is unreachable via those two real accessors, but
// the pure decision function itself must still handle it sanely.
assert!(!forward_client_auth_misconfigured(2, true));
}
#[test]
fn forward_client_auth_allows_exactly_one_key() {
assert!(!forward_client_auth_misconfigured(1, false));
}
#[test]
fn forward_client_auth_allows_zero_keys() {
assert!(!forward_client_auth_misconfigured(0, false));
}
}
#[path = "auth/tests.rs"]
mod tests;
@@ -0,0 +1,73 @@
use super::*;
#[test]
fn parse_auth_mode_new_names() {
assert_eq!(AuthMode::from_env_str("oidc"), AuthMode::OidcOnly);
assert_eq!(AuthMode::from_env_str("oidc-only"), AuthMode::OidcOnly);
assert_eq!(AuthMode::from_env_str("oidc_only"), AuthMode::OidcOnly);
assert_eq!(AuthMode::from_env_str("keys"), AuthMode::KeysOnly);
assert_eq!(AuthMode::from_env_str("keys-only"), AuthMode::KeysOnly);
assert_eq!(AuthMode::from_env_str("keys_only"), AuthMode::KeysOnly);
assert_eq!(AuthMode::from_env_str("both"), AuthMode::Both);
}
#[test]
fn parse_auth_mode_legacy_names() {
assert_eq!(AuthMode::from_env_str("jwt_only"), AuthMode::OidcOnly);
assert_eq!(AuthMode::from_env_str("jwt_or_keys"), AuthMode::Both);
assert_eq!(AuthMode::from_env_str("JWT_ONLY"), AuthMode::OidcOnly);
}
#[test]
fn parse_auth_mode_unknown_defaults_to_both() {
assert_eq!(AuthMode::from_env_str("unknown"), AuthMode::Both);
assert_eq!(AuthMode::from_env_str(""), AuthMode::Both);
}
#[test]
fn auth_mode_oidc_only() {
assert!(AuthMode::OidcOnly.allows_oidc());
assert!(!AuthMode::OidcOnly.allows_key_auth());
}
#[test]
fn auth_mode_keys_only() {
assert!(AuthMode::KeysOnly.allows_key_auth());
assert!(!AuthMode::KeysOnly.allows_oidc());
}
#[test]
fn auth_mode_both_allows_all() {
assert!(AuthMode::Both.allows_oidc());
assert!(AuthMode::Both.allows_key_auth());
}
#[test]
fn auth_mode_from_env_defaults_to_both() {
let mode = AuthMode::from_env_str("unrecognized_value");
assert_eq!(mode, AuthMode::Both);
}
#[test]
fn forward_client_auth_rejects_multiple_static_keys_without_open_relay() {
assert!(forward_client_auth_misconfigured(2, false));
}
#[test]
fn forward_client_auth_allows_open_relay_even_with_multiple_keys() {
// Reflects that `open_relay_active()` can only be true when
// `distinct_static_key_count()` is 0 (see the OPEN_RELAY static) --
// this combination is unreachable via those two real accessors, but
// the pure decision function itself must still handle it sanely.
assert!(!forward_client_auth_misconfigured(2, true));
}
#[test]
fn forward_client_auth_allows_exactly_one_key() {
assert!(!forward_client_auth_misconfigured(1, false));
}
#[test]
fn forward_client_auth_allows_zero_keys() {
assert!(!forward_client_auth_misconfigured(0, false));
}
+207
View File
@@ -0,0 +1,207 @@
use crate::server::middleware::ClientAuthPath;
use axum::http::HeaderMap;
/// Selects the exact incoming credential to forward upstream when
/// `ANTHROPIC_FORWARD_CLIENT_AUTH` is enabled. Same precedence as
/// `validate_auth` (`x-api-key` / `x-goog-api-key` win over `authorization`,
/// see `server/middleware/auth.rs`'s `api_key = headers.get("x-api-key")
/// .or_else(|| headers.get("x-goog-api-key"))`) so this always forwards the
/// credential that actually gated the request into the proxy, never a
/// second, unrelated header the client also happened to send.
///
/// `x-goog-api-key` (Gemini-CLI compatibility) is folded into the
/// `x-api-key` slot rather than forwarded under its own name: Anthropic's API
/// only recognizes `x-api-key`/`authorization`, so a client authenticated via
/// `x-goog-api-key` must still have its value sent upstream as `x-api-key`,
/// not as a header name Anthropic would silently ignore. Beyond that one
/// rename, no shape detection/conversion happens -- forwarded byte-for-byte,
/// unlike LiteLLM's `optionally_handle_anthropic_oauth()`, which mis-converts
/// a Bearer token into `x-api-key`.
fn select_client_auth_override(headers: &HeaderMap) -> Option<(&'static str, &str)> {
if let Some(v) = headers
.get("x-api-key")
.or_else(|| headers.get("x-goog-api-key"))
.and_then(|v| v.to_str().ok())
{
if !v.is_empty() {
return Some(("x-api-key", v));
}
}
if let Some(v) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
if !v.is_empty() {
return Some(("authorization", v));
}
}
None
}
/// Only `StaticKey`/`OpenRelay` mean "the credential that gated this request
/// IS the operator's own secret" (a single-key/BYOK deployment). A virtual
/// key is deliberately not a real Anthropic credential and a JWT is a
/// proxy-auth artifact, so those must never be forwarded upstream regardless
/// of the `ANTHROPIC_FORWARD_CLIENT_AUTH` toggle.
fn client_auth_forwardable(auth_path: Option<ClientAuthPath>) -> bool {
matches!(
auth_path,
Some(ClientAuthPath::StaticKey) | Some(ClientAuthPath::OpenRelay)
)
}
/// Resolves the client-credential override shared by both passthrough
/// handlers. `vk_ctx`/`claims` are checked directly, not just via
/// `client_auth_forwardable(auth_path)`: `ClientAuthPath` and
/// `VirtualKeyContext`/`JwtClaims` are inserted as two independent
/// `request.extensions_mut().insert()` calls in `validate_auth`, with
/// nothing structurally coupling them, so a future edit to one of those
/// branches could desync them without a compile error. Re-checking presence
/// of the extension that actually gates virtual-key/OIDC requests fails
/// closed instead of silently forwarding a non-operator credential if that
/// ever happens.
pub(crate) fn resolve_client_auth_override<'h>(
forward_client_auth: bool,
auth_path: Option<ClientAuthPath>,
vk_ctx: &Option<crate::server::middleware::VirtualKeyContext>,
claims: &Option<crate::server::oidc::JwtClaims>,
headers: &'h HeaderMap,
) -> Option<(&'static str, &'h str)> {
if forward_client_auth
&& client_auth_forwardable(auth_path)
&& vk_ctx.is_none()
&& claims.is_none()
{
select_client_auth_override(headers)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::{
client_auth_forwardable, resolve_client_auth_override, select_client_auth_override,
ClientAuthPath,
};
use axum::http::HeaderMap;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.insert(
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
v.parse().unwrap(),
);
}
h
}
#[test]
fn selects_x_api_key_when_only_that_is_sent() {
let h = headers(&[("x-api-key", "client-key")]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "client-key"))
);
}
#[test]
fn selects_authorization_when_only_that_is_sent() {
let h = headers(&[("authorization", "Bearer sk-ant-oat-abc")]);
assert_eq!(
select_client_auth_override(&h),
Some(("authorization", "Bearer sk-ant-oat-abc"))
);
}
#[test]
fn prefers_x_api_key_when_both_sent_matching_validate_auth_precedence() {
let h = headers(&[
("x-api-key", "client-key"),
("authorization", "Bearer sk-ant-oat-abc"),
]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "client-key"))
);
}
#[test]
fn returns_none_when_neither_header_sent() {
let h = headers(&[]);
assert_eq!(select_client_auth_override(&h), None);
}
#[test]
fn selects_x_goog_api_key_forwarded_as_x_api_key() {
// validate_auth (server/middleware/auth.rs) treats x-goog-api-key as
// fully equivalent to x-api-key for authentication, but Anthropic's
// API only understands x-api-key -- the value must be forwarded
// under the x-api-key name, not the literal x-goog-api-key name.
let h = headers(&[("x-goog-api-key", "gemini-cli-key")]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "gemini-cli-key"))
);
}
#[test]
fn prefers_x_api_key_over_x_goog_api_key_matching_validate_auth_precedence() {
let h = headers(&[
("x-api-key", "primary-key"),
("x-goog-api-key", "secondary-key"),
]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "primary-key"))
);
}
#[test]
fn client_auth_forwardable_only_for_static_key_and_open_relay() {
assert!(client_auth_forwardable(Some(ClientAuthPath::StaticKey)));
assert!(client_auth_forwardable(Some(ClientAuthPath::OpenRelay)));
assert!(!client_auth_forwardable(Some(ClientAuthPath::VirtualKey)));
assert!(!client_auth_forwardable(Some(ClientAuthPath::OidcJwt)));
assert!(!client_auth_forwardable(None));
}
#[test]
fn resolve_client_auth_override_forwards_on_static_key() {
let h = headers(&[("x-api-key", "client-key")]);
assert_eq!(
resolve_client_auth_override(true, Some(ClientAuthPath::StaticKey), &None, &None, &h),
Some(("x-api-key", "client-key"))
);
}
#[test]
fn resolve_client_auth_override_refuses_when_vk_ctx_present_even_if_auth_path_says_static_key()
{
// Regression guard for the ClientAuthPath/VirtualKeyContext desync
// risk: even if a future bug leaves auth_path reporting StaticKey
// while a VirtualKeyContext extension is also present, forwarding
// must still be refused.
let h = headers(&[("x-api-key", "client-key")]);
let vk_ctx = Some(crate::server::middleware::VirtualKeyContext {
key_id: 1,
#[cfg(feature = "redis")]
key_hash_hex: String::new(),
rate_state: std::sync::Arc::new(crate::admin::keys::RateLimitState::new()),
allowed_models: None,
allowed_routes: None,
period_reset: None,
});
assert_eq!(
resolve_client_auth_override(true, Some(ClientAuthPath::StaticKey), &vk_ctx, &None, &h),
None
);
}
#[test]
fn resolve_client_auth_override_refuses_when_feature_disabled() {
let h = headers(&[("x-api-key", "client-key")]);
assert_eq!(
resolve_client_auth_override(false, Some(ClientAuthPath::StaticKey), &None, &None, &h),
None
);
}
}
@@ -1,12 +1,10 @@
// Anthropic passthrough handler: forwards raw request bytes to the real Anthropic API.
// No translation: the proxy receives Anthropic format and returns Anthropic format.
use crate::backend::{BackendClient, MAX_SSE_BUFFER_SIZE};
use crate::openai_tool_policy::{
backend_kind_for_policy, validate_anthropic_tool_request, OpenAiToolPolicyContext,
};
use crate::server::middleware::ClientAuthPath;
use crate::server::routes::{log_request, record_virtual_key_usage, RequestCtx};
use crate::server::state::ConcurrencyPermit;
use crate::server::state::{AppState, ConcurrencyPermit};
use crate::server::streaming::{observe_anthropic_sse_frames, AnthropicStreamUsage, StreamOutcome};
use anyllm_translate::{anthropic, mapping};
use axum::{
@@ -20,89 +18,14 @@ use futures::StreamExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use super::middleware::ClientAuthPath;
use super::state::AppState;
/// Selects the exact incoming credential to forward upstream when
/// `ANTHROPIC_FORWARD_CLIENT_AUTH` is enabled. Same precedence as
/// `validate_auth` (`x-api-key` / `x-goog-api-key` win over `authorization`,
/// see `server/middleware/auth.rs`'s `api_key = headers.get("x-api-key")
/// .or_else(|| headers.get("x-goog-api-key"))`) so this always forwards the
/// credential that actually gated the request into the proxy, never a
/// second, unrelated header the client also happened to send.
///
/// `x-goog-api-key` (Gemini-CLI compatibility) is folded into the
/// `x-api-key` slot rather than forwarded under its own name: Anthropic's API
/// only recognizes `x-api-key`/`authorization`, so a client authenticated via
/// `x-goog-api-key` must still have its value sent upstream as `x-api-key`,
/// not as a header name Anthropic would silently ignore. Beyond that one
/// rename, no shape detection/conversion happens -- forwarded byte-for-byte,
/// unlike LiteLLM's `optionally_handle_anthropic_oauth()`, which mis-converts
/// a Bearer token into `x-api-key`.
fn select_client_auth_override(headers: &axum::http::HeaderMap) -> Option<(&'static str, &str)> {
if let Some(v) = headers
.get("x-api-key")
.or_else(|| headers.get("x-goog-api-key"))
.and_then(|v| v.to_str().ok())
{
if !v.is_empty() {
return Some(("x-api-key", v));
}
}
if let Some(v) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
if !v.is_empty() {
return Some(("authorization", v));
}
}
None
}
/// Only `StaticKey`/`OpenRelay` mean "the credential that gated this request
/// IS the operator's own secret" (a single-key/BYOK deployment). A virtual
/// key is deliberately not a real Anthropic credential and a JWT is a
/// proxy-auth artifact, so those must never be forwarded upstream regardless
/// of the `ANTHROPIC_FORWARD_CLIENT_AUTH` toggle.
fn client_auth_forwardable(auth_path: Option<ClientAuthPath>) -> bool {
matches!(
auth_path,
Some(ClientAuthPath::StaticKey) | Some(ClientAuthPath::OpenRelay)
)
}
/// Resolves the client-credential override shared by both passthrough
/// handlers. `vk_ctx`/`claims` are checked directly, not just via
/// `client_auth_forwardable(auth_path)`: `ClientAuthPath` and
/// `VirtualKeyContext`/`JwtClaims` are inserted as two independent
/// `request.extensions_mut().insert()` calls in `validate_auth`, with
/// nothing structurally coupling them, so a future edit to one of those
/// branches could desync them without a compile error. Re-checking presence
/// of the extension that actually gates virtual-key/OIDC requests fails
/// closed instead of silently forwarding a non-operator credential if that
/// ever happens.
fn resolve_client_auth_override<'h>(
forward_client_auth: bool,
auth_path: Option<ClientAuthPath>,
vk_ctx: &Option<super::middleware::VirtualKeyContext>,
claims: &Option<crate::server::oidc::JwtClaims>,
headers: &'h axum::http::HeaderMap,
) -> Option<(&'static str, &'h str)> {
if forward_client_auth
&& client_auth_forwardable(auth_path)
&& vk_ctx.is_none()
&& claims.is_none()
{
select_client_auth_override(headers)
} else {
None
}
}
use super::auth::resolve_client_auth_override;
/// Forward an Anthropic-format request byte-for-byte to the upstream Anthropic API.
/// No translation is performed. Only active when `BACKEND=anthropic`.
pub(crate) async fn anthropic_passthrough(
State(state): State<AppState>,
permit: Option<axum::Extension<ConcurrencyPermit>>,
vk_ctx: Option<axum::Extension<super::middleware::VirtualKeyContext>>,
vk_ctx: Option<axum::Extension<crate::server::middleware::VirtualKeyContext>>,
auth_path: Option<axum::Extension<ClientAuthPath>>,
claims: Option<axum::Extension<crate::server::oidc::JwtClaims>>,
headers: axum::http::HeaderMap,
@@ -196,7 +119,7 @@ pub(crate) async fn anthropic_passthrough(
if let Some(ref ctx) = vk_ctx {
match &peek.model {
Some(m) => {
if !super::policy::is_model_allowed(m, &ctx.allowed_models) {
if !crate::server::policy::is_model_allowed(m, &ctx.allowed_models) {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::PermissionError,
format!("Model '{}' is not allowed for this API key.", m),
@@ -303,7 +226,7 @@ pub(crate) async fn anthropic_passthrough(
}
}
let mut body = match super::secret_redaction::redact_body_with_content_type(
let mut body = match crate::server::secret_redaction::redact_body_with_content_type(
state.redact_secrets(),
Some("application/json"),
body,
@@ -311,7 +234,7 @@ pub(crate) async fn anthropic_passthrough(
.await
{
Ok(body) => body,
Err(err) => return super::secret_redaction::error_response(err),
Err(err) => return crate::server::secret_redaction::error_response(err),
};
// FFEC prompt compression runs FIRST: it compresses conversation history text
@@ -576,7 +499,7 @@ pub(crate) async fn anthropic_passthrough(
#[allow(clippy::too_many_arguments)]
pub(crate) async fn anthropic_generic_passthrough(
State(state): State<AppState>,
vk_ctx: Option<axum::Extension<super::middleware::VirtualKeyContext>>,
vk_ctx: Option<axum::Extension<crate::server::middleware::VirtualKeyContext>>,
auth_path: Option<axum::Extension<ClientAuthPath>>,
claims: Option<axum::Extension<crate::server::oidc::JwtClaims>>,
OriginalUri(uri): OriginalUri,
@@ -650,9 +573,11 @@ pub(crate) async fn anthropic_generic_passthrough(
}
let body =
match super::secret_redaction::redact_body(state.redact_secrets(), &headers, body).await {
match crate::server::secret_redaction::redact_body(state.redact_secrets(), &headers, body)
.await
{
Ok(body) => body,
Err(err) => return super::secret_redaction::error_response(err),
Err(err) => return crate::server::secret_redaction::error_response(err),
};
match client
@@ -730,134 +655,3 @@ fn virtual_key_accounting_parse_error() -> Response {
);
(StatusCode::BAD_GATEWAY, Json(err)).into_response()
}
#[cfg(test)]
mod tests {
use super::{
client_auth_forwardable, resolve_client_auth_override, select_client_auth_override,
ClientAuthPath,
};
use axum::http::HeaderMap;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.insert(
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
v.parse().unwrap(),
);
}
h
}
#[test]
fn selects_x_api_key_when_only_that_is_sent() {
let h = headers(&[("x-api-key", "client-key")]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "client-key"))
);
}
#[test]
fn selects_authorization_when_only_that_is_sent() {
let h = headers(&[("authorization", "Bearer sk-ant-oat-abc")]);
assert_eq!(
select_client_auth_override(&h),
Some(("authorization", "Bearer sk-ant-oat-abc"))
);
}
#[test]
fn prefers_x_api_key_when_both_sent_matching_validate_auth_precedence() {
let h = headers(&[
("x-api-key", "client-key"),
("authorization", "Bearer sk-ant-oat-abc"),
]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "client-key"))
);
}
#[test]
fn returns_none_when_neither_header_sent() {
let h = headers(&[]);
assert_eq!(select_client_auth_override(&h), None);
}
#[test]
fn selects_x_goog_api_key_forwarded_as_x_api_key() {
// validate_auth (server/middleware/auth.rs) treats x-goog-api-key as
// fully equivalent to x-api-key for authentication, but Anthropic's
// API only understands x-api-key -- the value must be forwarded
// under the x-api-key name, not the literal x-goog-api-key name.
let h = headers(&[("x-goog-api-key", "gemini-cli-key")]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "gemini-cli-key"))
);
}
#[test]
fn prefers_x_api_key_over_x_goog_api_key_matching_validate_auth_precedence() {
let h = headers(&[
("x-api-key", "primary-key"),
("x-goog-api-key", "secondary-key"),
]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "primary-key"))
);
}
#[test]
fn client_auth_forwardable_only_for_static_key_and_open_relay() {
assert!(client_auth_forwardable(Some(ClientAuthPath::StaticKey)));
assert!(client_auth_forwardable(Some(ClientAuthPath::OpenRelay)));
assert!(!client_auth_forwardable(Some(ClientAuthPath::VirtualKey)));
assert!(!client_auth_forwardable(Some(ClientAuthPath::OidcJwt)));
assert!(!client_auth_forwardable(None));
}
#[test]
fn resolve_client_auth_override_forwards_on_static_key() {
let h = headers(&[("x-api-key", "client-key")]);
assert_eq!(
resolve_client_auth_override(true, Some(ClientAuthPath::StaticKey), &None, &None, &h),
Some(("x-api-key", "client-key"))
);
}
#[test]
fn resolve_client_auth_override_refuses_when_vk_ctx_present_even_if_auth_path_says_static_key()
{
// Regression guard for the ClientAuthPath/VirtualKeyContext desync
// risk: even if a future bug leaves auth_path reporting StaticKey
// while a VirtualKeyContext extension is also present, forwarding
// must still be refused.
let h = headers(&[("x-api-key", "client-key")]);
let vk_ctx = Some(crate::server::middleware::VirtualKeyContext {
key_id: 1,
#[cfg(feature = "redis")]
key_hash_hex: String::new(),
rate_state: std::sync::Arc::new(crate::admin::keys::RateLimitState::new()),
allowed_models: None,
allowed_routes: None,
period_reset: None,
});
assert_eq!(
resolve_client_auth_override(true, Some(ClientAuthPath::StaticKey), &vk_ctx, &None, &h),
None
);
}
#[test]
fn resolve_client_auth_override_refuses_when_feature_disabled() {
let h = headers(&[("x-api-key", "client-key")]);
assert_eq!(
resolve_client_auth_override(false, Some(ClientAuthPath::StaticKey), &None, &None, &h),
None
);
}
}
@@ -0,0 +1,7 @@
// Anthropic passthrough handlers module: forwards raw request bytes to the real Anthropic API.
// No translation: the proxy receives Anthropic format and returns Anthropic format.
pub(crate) mod auth;
pub(crate) mod handlers;
pub(crate) use handlers::{anthropic_generic_passthrough, anthropic_passthrough};
-977
View File
@@ -1,977 +0,0 @@
// Shared state types for request handlers: AppState, AnthropicJson, ResolvedModel, etc.
// Extracted from routes.rs so consumers can import state independently of the router setup.
use crate::admin::state::{RuntimeConfig, SharedState};
use crate::backend::BackendClient;
use crate::metrics::Metrics;
use anyllm_providers::ProviderCatalog;
use anyllm_translate::{anthropic, mapping, openai};
use axum::{
extract::{rejection::JsonRejection, FromRequest},
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio::sync::Semaphore;
/// Custom JSON extractor that returns Anthropic-shaped error responses on
/// parse failure. Axum's built-in Json returns its own error format, which
/// would break clients expecting Anthropic error shapes.
pub(crate) struct AnthropicJson<T>(pub T);
impl<S, T> FromRequest<S> for AnthropicJson<T>
where
Json<T>: FromRequest<S, Rejection = JsonRejection>,
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
match Json::<T>::from_request(req, state).await {
Ok(Json(value)) => Ok(AnthropicJson(value)),
Err(rejection) => {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::InvalidRequestError,
rejection.body_text(),
None,
);
Err((StatusCode::BAD_REQUEST, Json(err)).into_response())
}
}
}
}
/// Result of resolving a model name through the model router.
pub(crate) enum ResolvedModel {
/// Routed via model_list to a specific backend and actual model name.
Routed {
backend_name: String,
model: String,
/// The deployment Arc for recording in-flight/latency stats.
deployment: Arc<crate::config::model_router::Deployment>,
/// Per-route option overrides when routed via a DB route; `None` when
/// routed via the LiteLLM model_router (inherit global config).
options: Option<Arc<crate::config::route_router::RouteOptions>>,
},
/// Model is known but all deployments are at their RPM limit.
AllAtLimit,
/// Model router is active but the model alias is not configured.
UnknownModel,
/// No model router, or model not in router. Used legacy ModelMapping.
Legacy(String),
}
/// Shared state for tool execution, stored in AppState.
#[derive(Clone)]
pub struct ToolEngineState {
pub registry: Arc<crate::tools::ToolRegistry>,
pub policy: Arc<crate::tools::ToolExecutionPolicy>,
pub loop_config: crate::tools::LoopConfig,
pub guardrails: crate::tools::ToolGuardrailConfig,
pub mcp_manager: Option<Arc<crate::tools::McpServerManager>>,
}
/// Per-backend state shared across request handlers.
///
/// In single-backend mode, one `AppState` serves all routes. In multi-backend mode,
/// each backend gets its own `AppState` mounted under a prefix path (e.g., `/openai/v1/messages`).
#[derive(Clone)]
pub struct AppState {
pub backend: BackendClient,
pub metrics: Metrics,
/// Runtime config (model mappings, body logging, redaction) read on every request.
/// Shared with admin server so config changes take effect immediately.
pub runtime_config: Arc<RwLock<RuntimeConfig>>,
/// Shared admin state for request logging and live updates. None in tests.
pub shared: Option<SharedState>,
/// Per-route option overrides for the request that produced this (cloned)
/// state, set by `resolve_model_and_state` when a DB route was selected.
/// `None` means "no route override; use the global RuntimeConfig value".
/// Read by the option accessors (`redact_secrets`, `effective_tool_guardrails`,
/// `active_pxpipe`, `pxpipe_models`).
pub route_options: Option<Arc<crate::config::route_router::RouteOptions>>,
/// Backend name for logging purposes.
pub backend_name: String,
/// Canonical provider id used for provider/model policy decisions.
pub provider_id: Option<String>,
/// Concurrency limiter. Uses try_acquire (fail-fast) instead of queueing
/// to prevent cascading latency under load. Requests exceeding the limit
/// get 429 immediately, matching Anthropic's rate limiting behavior.
pub concurrency: Arc<Semaphore>,
/// Strip `stream_options` from streaming requests for local LLM compat.
pub omit_stream_options: bool,
/// Wall-clock cap for streaming responses in seconds. 0 = disabled.
/// Prevents resource exhaustion from stalled backends.
pub stream_timeout_secs: u64,
/// When true, set `x-anyllm-degradation` header on responses that silently drop features.
/// Mirrors Config::expose_degradation_warnings / MultiConfig::expose_degradation_warnings.
pub expose_degradation_warnings: bool,
/// Optional response cache for non-streaming requests.
pub cache: Option<Arc<crate::cache::memory::MemoryCache>>,
/// Anthropic thinking-block record-and-restore repair store. `None`
/// unless `backend` is `BackendClient::Anthropic`; only consulted by
/// `anthropic_passthrough`. Always `Some` for Anthropic backends
/// regardless of whether the feature is enabled -- use
/// `thinking_repair_enabled()` to check the live toggle before using it.
pub thinking_repair: Option<Arc<crate::thinking_repair::ThinkingRepairStore>>,
/// Text-to-image context compression engine (pxpipe). `None` unless
/// `backend` is `BackendClient::Anthropic`; only consulted by
/// `anthropic_passthrough`. Always `Some` for Anthropic backends regardless
/// of the live toggle -- use `active_pxpipe()`, which checks
/// `RuntimeConfig.pxpipe_compress`, before using it.
pub pxpipe: Option<Arc<crate::pxpipe::PxpipeEngine>>,
/// Command-aware tool-output compression engine (RTK). `Some` for Anthropic
/// and Translate modes; only consulted when `RuntimeConfig.rtk_compress` is
/// on -- use `rtk_engine_for(model)`.
pub rtk: Option<Arc<crate::rtk::RtkEngine>>,
/// FFEC prompt-compression engine (`OptimizerEngine`). `Some` for Anthropic
/// and Translate modes, mirroring `rtk`; baked with the static
/// `OPTIMIZER_MODE`-env default at startup -- use `effective_optimizer()`,
/// which applies the live `RouteOptions.optimizer_mode` override on top.
pub optimizer: Option<Arc<crate::optimizer::OptimizerEngine>>,
/// Model-level router for LiteLLM model_list configs. None for TOML/env configs.
/// Wrapped in RwLock for dynamic model management via admin API.
pub model_router: Option<Arc<RwLock<crate::config::model_router::ModelRouter>>>,
/// Immutable provider/model catalog used for runtime model metadata.
pub provider_catalog: Arc<ProviderCatalog>,
/// All backend states, for cross-backend model routing. None unless model_router is set.
pub all_backends: Option<Arc<HashMap<String, AppState>>>,
/// Tool execution engine state. None when tool execution is not configured.
pub tool_engine: Option<Arc<ToolEngineState>>,
/// Batch orchestration engine. None in test configs that don't need batch.
pub batch_engine: Option<
Arc<
anyllm_batch_engine::BatchEngine<
anyllm_batch_engine::queue::sqlite::SqliteQueue,
anyllm_batch_engine::webhook::sqlite::SqliteWebhookQueue,
>,
>,
>,
}
impl AppState {
/// Map a model name through the current runtime config for this backend.
pub(crate) fn map_model(&self, model: &str) -> String {
let config = self
.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner());
if let Some(mapping) = config.model_mappings.get(&self.backend_name) {
mapping.map_model(model)
} else {
model.to_string()
}
}
/// Resolve a model name to a backend.
///
/// Precedence: (1) admin-DB routes (`RouteRouter`), (2) LiteLLM model_router,
/// (3) legacy ModelMapping. An empty route router falls straight through so
/// installs without routes behave exactly as before.
pub(crate) fn resolve_model(&self, model: &str) -> ResolvedModel {
if let Some(shared) = self.shared.as_ref() {
if let Some(ref rr_lock) = shared.route_router {
use crate::config::route_router::RouteResolution;
let rr = rr_lock.read().unwrap_or_else(|e| e.into_inner());
if !rr.is_empty() {
match rr.resolve(model) {
RouteResolution::Routed(res) => {
return ResolvedModel::Routed {
backend_name: res.backend_name,
model: res.model,
deployment: res.deployment,
options: Some(res.options),
};
}
RouteResolution::AllAtLimit => return ResolvedModel::AllAtLimit,
// No route serves this model: fall through to the layers below.
RouteResolution::NoRoute => {}
}
}
}
}
if let Some(ref router_lock) = self.model_router {
let router = router_lock.read().unwrap_or_else(|e| e.into_inner());
if let Some(routed) = router.route(model) {
return ResolvedModel::Routed {
backend_name: routed.backend_name.to_string(),
model: routed.actual_model.to_string(),
deployment: routed.deployment.clone(),
options: None,
};
}
if router.has_model(model) {
return ResolvedModel::AllAtLimit;
}
return ResolvedModel::UnknownModel;
}
ResolvedModel::Legacy(self.map_model(model))
}
/// Resolve model and return (mapped_model, effective AppState, optional deployment).
/// If the model routes to a different backend, the returned state is cloned from
/// all_backends. Returns Err with a 429 response if all deployments are at limit.
/// The deployment Arc is returned so handlers can call record_start/record_finish.
#[allow(clippy::result_large_err)]
pub(crate) fn resolve_model_and_state(
&self,
model: &str,
) -> Result<
(
String,
AppState,
Option<Arc<crate::config::model_router::Deployment>>,
),
Response,
> {
match self.resolve_model(model) {
ResolvedModel::Routed {
backend_name,
model: mapped,
deployment,
options,
} => {
let mut effective = self
.all_backends
.as_ref()
.and_then(|m| m.get(&backend_name))
.cloned()
.or_else(|| {
// Check managed backends (SQLite-backed, zero-restart)
self.shared.as_ref().and_then(|s| {
let guard = s.managed_backends
.read()
.ok()
.or_else(|| {
tracing::warn!("managed_backends RwLock is poisoned; skipping managed backend lookup");
None
})?;
guard.get(&backend_name).map(|(row, client)| {
let mut state = self.clone();
state.backend = client.clone();
state.backend_name = backend_name.clone();
state.provider_id = Some(row.provider_id.clone());
state
})
})
})
.unwrap_or_else(|| self.clone());
// Carry the per-route option overrides onto the effective state so
// the option accessors resolve route-first, global-fallback.
effective.route_options = options;
Ok((mapped, effective, Some(deployment)))
}
ResolvedModel::AllAtLimit => {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::RateLimitError,
"all deployments for this model are at their RPM limit".to_string(),
None,
);
Err((StatusCode::TOO_MANY_REQUESTS, Json(err)).into_response())
}
ResolvedModel::UnknownModel => {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::InvalidRequestError,
format!("model '{model}' is not configured in model_list"),
None,
);
Err((StatusCode::BAD_REQUEST, Json(err)).into_response())
}
ResolvedModel::Legacy(mapped) => Ok((mapped, self.clone(), None)),
}
}
/// Whether request/response body logging is enabled.
pub(crate) fn log_bodies(&self) -> bool {
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.log_bodies
}
/// Whether upstream JSON/text request payloads should be redacted.
/// Route override (if set) wins over the global RuntimeConfig value.
pub(crate) fn redact_secrets(&self) -> bool {
if let Some(v) = self.route_options.as_ref().and_then(|o| o.redact_secrets) {
return v;
}
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.redact_secrets
}
/// Effective tool-call guardrail config for this request: the runtime,
/// admin-tunable override (`RuntimeConfig.tool_guardrail_mode`, no
/// restart required) applied on top of `engine.guardrails` (the static
/// preset built from YAML/env at startup). See
/// `crate::tools::resolve_runtime_guardrails`.
pub(crate) fn effective_tool_guardrails(
&self,
engine: &ToolEngineState,
) -> crate::tools::ToolGuardrailConfig {
// Route override (if set) wins over the live global RuntimeConfig mode.
if let Some(mode) = self
.route_options
.as_ref()
.and_then(|o| o.guardrail_mode.as_deref())
{
return crate::tools::resolve_runtime_guardrails(&engine.guardrails, mode);
}
crate::tools::resolve_runtime_guardrails_locked(&self.runtime_config, &engine.guardrails)
}
/// Whether Anthropic thinking-block repair (record + restore) is active.
/// `self.thinking_repair` may be `Some` even when this is `false` -- the
/// store is always constructed for Anthropic backends; only this flag
/// gates whether it's actually used.
pub(crate) fn thinking_repair_enabled(&self) -> bool {
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.anthropic_thinking_repair
}
/// Whether Anthropic passthrough forwards the client's own incoming
/// credential upstream instead of the operator's (`ANTHROPIC_FORWARD_CLIENT_AUTH`,
/// live-toggleable via `RuntimeConfig.forward_client_auth`). Read fresh on
/// every request -- unlike the old frozen `AppState` field this replaced,
/// this reflects an admin-UI change immediately without a restart, and
/// applies uniformly to every `BackendKind::Anthropic` backend since they
/// all share one `RuntimeConfig`.
pub(crate) fn forward_client_auth_enabled(&self) -> bool {
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.forward_client_auth
}
/// The thinking-repair store, but only when the live admin-toggleable
/// flag is actually on. `None` both when repair is entirely absent (non-
/// Anthropic backend) and when it's present-but-disabled -- single
/// accessor so call sites collapse to `if let Some(store) = ...` instead
/// of separately checking `thinking_repair_enabled()` and
/// `thinking_repair.is_some()`.
pub(crate) fn active_thinking_repair(
&self,
) -> Option<Arc<crate::thinking_repair::ThinkingRepairStore>> {
if self.thinking_repair_enabled() {
self.thinking_repair.clone()
} else {
None
}
}
/// The pxpipe compression engine, but only when the live admin-toggleable
/// flag (`RuntimeConfig.pxpipe_compress`) is on. `None` both when the engine
/// is absent (non-Anthropic backend) and when present-but-disabled.
pub(crate) fn active_pxpipe(&self) -> Option<Arc<crate::pxpipe::PxpipeEngine>> {
let enabled = match self.route_options.as_ref().and_then(|o| o.pxpipe_compress) {
Some(v) => v,
None => {
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.pxpipe_compress
}
};
if enabled {
self.pxpipe.clone()
} else {
None
}
}
/// Live model-scope CSV for pxpipe. Route override wins over the global
/// `RuntimeConfig.pxpipe_models` value.
pub(crate) fn pxpipe_models(&self) -> String {
if let Some(csv) = self
.route_options
.as_ref()
.and_then(|o| o.pxpipe_models.clone())
{
return csv;
}
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.pxpipe_models
.clone()
}
/// Vision gate: if the catalog knows this model and says it is NOT
/// vision-capable, refuse (fail-closed). Unknown models fall back to the
/// scope list only — a Claude passthrough model is vision-capable in
/// practice, and the scope list is the operator's explicit control.
fn pxpipe_vision_ok(&self, model: &str) -> bool {
match self
.provider_id
.as_deref()
.and_then(|pid| self.provider_catalog.get_model(pid, model))
{
Some(def) => def.capabilities.vision,
None => true,
}
}
/// The pxpipe engine for `model`, or `None` if compression shouldn't run:
/// the master toggle is off, the engine is absent (non-Anthropic backend),
/// the model is out of the live scope CSV, or it isn't vision-capable.
/// Single accessor so `passthrough` collapses to
/// `if let Some(engine) = state.pxpipe_engine_for(model)`.
pub(crate) fn pxpipe_engine_for(
&self,
model: &str,
) -> Option<Arc<crate::pxpipe::PxpipeEngine>> {
let engine = self.active_pxpipe()?;
if crate::pxpipe::model_in_scope(model, &self.pxpipe_models())
&& self.pxpipe_vision_ok(model)
{
Some(engine)
} else {
None
}
}
/// The RTK engine for `model`, or `None` if compression shouldn't run: the
/// toggle is off, the engine is absent, or the model is out of scope. RTK is
/// not vision-gated, so there is no capability check.
///
/// Reads the toggle and scope from a single RwLock critical section for
/// consistency, and checks `route_options` first (matching the pxpipe pattern)
/// so per-route overrides take precedence over the global RuntimeConfig.
pub(crate) fn rtk_engine_for(&self, model: &str) -> Option<Arc<crate::rtk::RtkEngine>> {
let cfg = self
.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner());
let enabled = self
.route_options
.as_ref()
.and_then(|o| o.rtk_compress)
.unwrap_or(cfg.rtk_compress);
if !enabled {
return None;
}
let engine = self.rtk.clone()?;
let models_csv = self
.route_options
.as_ref()
.and_then(|o| o.rtk_models.as_deref())
.unwrap_or(&cfg.rtk_models);
if crate::rtk::model_in_scope(model, models_csv) {
Some(engine)
} else {
None
}
}
/// Effective FFEC prompt-compression engine for this request, or `None`
/// when optimization is unconfigured for this backend/mode (`self.optimizer`
/// is `None`). Precedence, mirroring `effective_tool_guardrails` /
/// `resolve_runtime_guardrails_locked`: (1) route override
/// (`RouteOptions.optimizer_mode`, if set) wins outright; (2) otherwise the
/// live `RuntimeConfig.optimizer_mode` admin toggle (no restart required);
/// (3) otherwise the static per-process engine baked with the
/// `OPTIMIZER_MODE`-env default at startup.
pub(crate) fn effective_optimizer(&self) -> Option<Arc<crate::optimizer::OptimizerEngine>> {
let engine = self.optimizer.as_ref()?;
if let Some(mode_str) = self
.route_options
.as_ref()
.and_then(|o| o.optimizer_mode.as_deref())
{
return Some(Arc::new(engine.with_mode_override(mode_str)));
}
Some(Arc::new(
crate::optimizer::resolve_runtime_optimizer_locked(&self.runtime_config, engine),
))
}
/// Apply RTK tool-output compression to an OpenAI-format request and record
/// metrics. Shared helper used by both the /v1/chat/completions and /v1/messages
/// translate paths (streaming and non-streaming). No-op when the engine is
/// unavailable, disabled, or no tool messages are present.
pub(crate) fn apply_rtk_to_openai(&self, req: &mut openai::ChatCompletionRequest, model: &str) {
let engine = match self.rtk_engine_for(model) {
Some(e) => e,
None => return,
};
// Pre-check: only serialize when there are tool messages to compress.
if !req
.messages
.iter()
.any(|m| m.role == openai::ChatRole::Tool)
{
return;
}
let mut v = match serde_json::to_value(&*req) {
Ok(v) => v,
Err(_) => return,
};
let Some((blocks, saved)) = engine.compress_openai_chat(&mut v) else {
return;
};
match serde_json::from_value::<openai::ChatCompletionRequest>(v) {
Ok(patched) => {
*req = patched;
self.metrics.record_rtk_compression(blocks, saved);
tracing::info!(
model,
blocks,
chars_saved = saved,
"rtk: compressed OpenAI request"
);
}
Err(e) => tracing::warn!(
error = %e,
"rtk: failed to re-deserialize compressed OpenAI request; forwarding original"
),
}
}
/// Apply FFEC prompt compression (`effective_optimizer()`) to an OpenAI-format
/// request at the parsed-body seam. Client-sent history only -- callers must
/// never invoke this on proxy-appended tool-loop turns (see
/// `crates/optimizer/CLAUDE.md` "Streaming & tool-loop decision"). `Shadow`
/// mode logs the `OptimizationReport` and leaves `req` unchanged; `Live` mode
/// applies the rendered body in place. No-op when optimization is
/// unconfigured or resolves to `Mode::Off` for this request.
pub(crate) fn apply_optimizer_to_openai(
&self,
req: &mut openai::ChatCompletionRequest,
route: &str,
) {
let Some(engine) = self.effective_optimizer() else {
return;
};
let mut v = match serde_json::to_value(&*req) {
Ok(v) => v,
Err(_) => return,
};
let report = engine.optimize_openai(&mut v, route);
if report.mode == anyllm_optimize_core::Mode::Shadow {
tracing::info!(
route,
removed_tokens_est = report.removed_tokens_est,
messages_compressed = report.messages_compressed,
failure = report.failure.as_deref().unwrap_or(""),
"optimizer: shadow report (not applied)"
);
}
if !report.applied {
return;
}
match serde_json::from_value::<openai::ChatCompletionRequest>(v) {
Ok(patched) => {
*req = patched;
self.metrics.record_optimization(
report.messages_compressed as u64,
report.removed_tokens_est,
);
tracing::info!(
route,
removed_tokens_est = report.removed_tokens_est,
messages_compressed = report.messages_compressed,
"optimizer: compressed OpenAI request"
);
}
Err(e) => tracing::warn!(
error = %e,
"optimizer: failed to re-deserialize compressed OpenAI request; forwarding original"
),
}
}
/// Apply FFEC prompt compression (`effective_optimizer()`) to an Anthropic
/// Messages request at the parsed-body seam. Same contract as
/// [`Self::apply_optimizer_to_openai`]: client-sent history only, fails open,
/// `Shadow` never mutates `req`.
pub(crate) fn apply_optimizer_to_anthropic(
&self,
req: &mut anthropic::MessageCreateRequest,
route: &str,
) {
let Some(engine) = self.effective_optimizer() else {
return;
};
let mut v = match serde_json::to_value(&*req) {
Ok(v) => v,
Err(_) => return,
};
let report = engine.optimize_anthropic(&mut v, route);
if report.mode == anyllm_optimize_core::Mode::Shadow {
tracing::info!(
route,
removed_tokens_est = report.removed_tokens_est,
messages_compressed = report.messages_compressed,
failure = report.failure.as_deref().unwrap_or(""),
"optimizer: shadow report (not applied)"
);
}
if !report.applied {
return;
}
match serde_json::from_value::<anthropic::MessageCreateRequest>(v) {
Ok(patched) => {
*req = patched;
self.metrics.record_optimization(
report.messages_compressed as u64,
report.removed_tokens_est,
);
tracing::info!(
route,
removed_tokens_est = report.removed_tokens_est,
messages_compressed = report.messages_compressed,
"optimizer: compressed Anthropic request"
);
}
Err(e) => tracing::warn!(
error = %e,
"optimizer: failed to re-deserialize compressed Anthropic request; forwarding original"
),
}
}
}
#[cfg(test)]
mod optimizer_seam_tests {
use super::*;
use crate::config::{
BackendAuth, BackendKind, Config, ModelMapping, OpenAIApiFormat, TlsConfig,
};
use anyllm_optimize_core::Mode;
/// Long enough that FFEC's min-length gate actually has something to compress.
fn long_text() -> String {
"The quick brown fox jumps over the lazy dog again and again across the wide \
green field toward the distant blue mountains far beyond the winding river."
.repeat(4)
}
fn minimal_state(optimizer_mode: Mode) -> AppState {
let config = Config {
backend: BackendKind::OpenAI,
openai_api_key: "test".into(),
openai_base_url: "https://api.openai.com".into(),
listen_port: 3000,
model_mapping: ModelMapping {
big_model: "gpt-4o".into(),
small_model: "gpt-4o-mini".into(),
},
tls: TlsConfig::default(),
backend_auth: BackendAuth::BearerToken("test".into()),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
expose_degradation_warnings: false,
openai_api_format: OpenAIApiFormat::Chat,
provider_id: None,
};
let backend = crate::backend::BackendClient::OpenAI(
crate::backend::openai_client::OpenAIClient::new(&config),
);
let runtime_config = Arc::new(RwLock::new(RuntimeConfig {
model_mappings: indexmap::IndexMap::new(),
log_level: "info".to_string(),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
pxpipe_models: String::new(),
rtk_compress: false,
rtk_models: String::new(),
forward_client_auth: false,
tool_guardrail_mode: "disabled".to_string(),
optimizer_mode: optimizer_mode.as_str().to_string(),
}));
AppState {
backend,
metrics: Metrics::new(),
runtime_config,
shared: None,
route_options: None,
backend_name: "openai".to_string(),
provider_id: None,
concurrency: Arc::new(Semaphore::new(64)),
omit_stream_options: false,
stream_timeout_secs: 0,
expose_degradation_warnings: false,
cache: None,
thinking_repair: None,
pxpipe: None,
rtk: None,
optimizer: Some(Arc::new(crate::optimizer::OptimizerEngine::new(
optimizer_mode,
))),
model_router: None,
provider_catalog: Arc::new(ProviderCatalog::bundled()),
all_backends: None,
tool_engine: None,
batch_engine: None,
}
}
fn long_openai_request() -> openai::ChatCompletionRequest {
let long = long_text();
let mut body = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "system", "content": "you are helpful"}],
});
let msgs = body["messages"].as_array_mut().unwrap();
for _ in 0..16 {
msgs.push(serde_json::json!({"role": "user", "content": long}));
msgs.push(serde_json::json!({"role": "assistant", "content": long}));
}
msgs.push(serde_json::json!({"role": "user", "content": "what is the latest?"}));
serde_json::from_value(body).expect("valid ChatCompletionRequest")
}
fn long_anthropic_request() -> anthropic::MessageCreateRequest {
let long = long_text();
let mut body = serde_json::json!({
"model": "claude-sonnet-5",
"max_tokens": 1024,
"messages": [],
});
let msgs = body["messages"].as_array_mut().unwrap();
for _ in 0..16 {
msgs.push(serde_json::json!({"role": "user", "content": long}));
msgs.push(serde_json::json!({"role": "assistant", "content": long}));
}
msgs.push(serde_json::json!({"role": "user", "content": "what is the latest?"}));
serde_json::from_value(body).expect("valid MessageCreateRequest")
}
#[test]
fn shadow_mode_forwards_openai_body_unchanged() {
let state = minimal_state(Mode::Shadow);
let mut req = long_openai_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_openai(&mut req, "chat_completions");
let after = serde_json::to_value(&req).unwrap();
assert_eq!(before, after, "shadow mode must forward the original body");
assert_eq!(
state.metrics.snapshot().optimizer_compressed_total,
0,
"shadow mode must never record a metrics-visible compression"
);
}
#[test]
fn shadow_mode_forwards_anthropic_body_unchanged() {
let state = minimal_state(Mode::Shadow);
let mut req = long_anthropic_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_anthropic(&mut req, "messages");
let after = serde_json::to_value(&req).unwrap();
assert_eq!(before, after, "shadow mode must forward the original body");
}
#[test]
fn live_mode_compresses_openai_history_and_preserves_latest() {
let state = minimal_state(Mode::Live);
let mut req = long_openai_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_openai(&mut req, "chat_completions");
let after = serde_json::to_value(&req).unwrap();
assert_eq!(
before["messages"].as_array().unwrap().last(),
after["messages"].as_array().unwrap().last(),
"the latest turn must never be rewritten"
);
assert_eq!(
state.metrics.snapshot().optimizer_compressed_total,
1,
"an applied Live compression must be recorded in metrics"
);
}
#[test]
fn live_mode_compresses_anthropic_history_and_preserves_latest() {
let state = minimal_state(Mode::Live);
let mut req = long_anthropic_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_anthropic(&mut req, "messages");
let after = serde_json::to_value(&req).unwrap();
assert_eq!(
before["messages"].as_array().unwrap().last(),
after["messages"].as_array().unwrap().last(),
"the latest turn must never be rewritten"
);
}
#[test]
fn off_mode_is_noop_and_engine_absent_is_noop() {
// Off mode: engine present, mode Off -> never applied.
let state = minimal_state(Mode::Off);
let mut req = long_openai_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_openai(&mut req, "chat_completions");
assert_eq!(before, serde_json::to_value(&req).unwrap());
// No engine at all (e.g. non-Anthropic/Translate mode backend): no panic, no-op.
let mut state_no_engine = minimal_state(Mode::Live);
state_no_engine.optimizer = None;
let mut req2 = long_openai_request();
let before2 = serde_json::to_value(&req2).unwrap();
state_no_engine.apply_optimizer_to_openai(&mut req2, "chat_completions");
assert_eq!(before2, serde_json::to_value(&req2).unwrap());
}
#[test]
fn short_history_below_min_len_gate_is_a_noop_not_a_panic() {
// A short request has nothing worth compressing (below FFEC's min-length
// gate) -- the seam must still round-trip cleanly without panicking or
// corrupting the body, i.e. it fails open when there's nothing to do.
let state = minimal_state(Mode::Live);
let mut req: openai::ChatCompletionRequest = serde_json::from_value(serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
}))
.unwrap();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_openai(&mut req, "chat_completions");
assert_eq!(before, serde_json::to_value(&req).unwrap());
}
}
#[cfg(test)]
mod rtk_seam_tests {
use super::*;
/// Build a minimal `AppState` with the RTK engine present and the runtime
/// `rtk_compress` toggle set to `enabled` (scope left empty = all models).
fn state_with_rtk(enabled: bool) -> AppState {
use crate::config::{
BackendAuth, BackendKind, Config, ModelMapping, OpenAIApiFormat, TlsConfig,
};
let config = Config {
backend: BackendKind::OpenAI,
openai_api_key: "test".into(),
openai_base_url: "https://api.openai.com".into(),
listen_port: 3000,
model_mapping: ModelMapping {
big_model: "gpt-4o".into(),
small_model: "gpt-4o-mini".into(),
},
tls: TlsConfig::default(),
backend_auth: BackendAuth::BearerToken("test".into()),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
expose_degradation_warnings: false,
openai_api_format: OpenAIApiFormat::Chat,
provider_id: None,
};
let backend = crate::backend::BackendClient::OpenAI(
crate::backend::openai_client::OpenAIClient::new(&config),
);
let runtime_config = Arc::new(RwLock::new(RuntimeConfig {
model_mappings: indexmap::IndexMap::new(),
log_level: "info".to_string(),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
pxpipe_models: String::new(),
rtk_compress: enabled,
rtk_models: String::new(),
forward_client_auth: false,
tool_guardrail_mode: "disabled".to_string(),
optimizer_mode: "off".to_string(),
}));
AppState {
backend,
metrics: Metrics::new(),
runtime_config,
shared: None,
route_options: None,
backend_name: "openai".to_string(),
provider_id: None,
concurrency: Arc::new(Semaphore::new(64)),
omit_stream_options: false,
stream_timeout_secs: 0,
expose_degradation_warnings: false,
cache: None,
thinking_repair: None,
pxpipe: None,
rtk: Some(Arc::new(crate::rtk::RtkEngine::new())),
optimizer: None,
model_router: None,
provider_catalog: Arc::new(ProviderCatalog::bundled()),
all_backends: None,
tool_engine: None,
batch_engine: None,
}
}
/// An OpenAI request whose `role: tool` message carries compressible git noise.
fn request_with_noisy_tool_output() -> openai::ChatCompletionRequest {
let mut noise = String::from("On branch main\nChanges not staged for commit:\n");
for i in 0..200 {
noise.push_str(&format!(" (use \"git add ...\" file {i})\n"));
}
serde_json::from_value(serde_json::json!({
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "run git status"},
{"role": "assistant", "content": null, "tool_calls": [
{"id": "t1", "type": "function",
"function": {"name": "bash", "arguments": "{\"cmd\":\"git status\"}"}}
]},
{"role": "tool", "tool_call_id": "t1", "content": noise},
],
}))
.expect("valid ChatCompletionRequest")
}
#[test]
fn enabled_compresses_tool_output_and_records_metrics() {
let state = state_with_rtk(true);
let mut req = request_with_noisy_tool_output();
let before = serde_json::to_value(&req).unwrap();
state.apply_rtk_to_openai(&mut req, "gpt-4o");
let after = serde_json::to_value(&req).unwrap();
assert_ne!(before, after, "tool output should have been compressed");
assert_eq!(state.metrics.snapshot().rtk_compressed_total, 1);
}
#[test]
fn disabled_is_a_noop() {
let state = state_with_rtk(false);
let mut req = request_with_noisy_tool_output();
let before = serde_json::to_value(&req).unwrap();
state.apply_rtk_to_openai(&mut req, "gpt-4o");
assert_eq!(before, serde_json::to_value(&req).unwrap());
assert_eq!(state.metrics.snapshot().rtk_compressed_total, 0);
}
#[test]
fn engine_absent_is_a_noop() {
let mut state = state_with_rtk(true);
state.rtk = None;
let mut req = request_with_noisy_tool_output();
let before = serde_json::to_value(&req).unwrap();
state.apply_rtk_to_openai(&mut req, "gpt-4o");
assert_eq!(before, serde_json::to_value(&req).unwrap());
assert_eq!(state.metrics.snapshot().rtk_compressed_total, 0);
}
}
/// Global state for the multi-backend metrics endpoint.
#[derive(Clone)]
pub(crate) struct GlobalState {
pub(crate) backend_metrics: Arc<HashMap<String, Metrics>>,
}
/// Wrapper so OwnedSemaphorePermit can be stored in request extensions.
/// The field is never read directly; it exists as an RAII guard to hold
/// the permit until the struct is dropped.
#[derive(Clone)]
pub(crate) struct ConcurrencyPermit(
#[allow(dead_code)] pub(crate) Arc<tokio::sync::OwnedSemaphorePermit>,
);
@@ -0,0 +1,33 @@
use anyllm_translate::{anthropic, mapping};
use axum::{
extract::{rejection::JsonRejection, FromRequest},
http::StatusCode,
response::{IntoResponse, Json, Response},
};
/// Custom JSON extractor that returns Anthropic-shaped error responses on
/// parse failure. Axum's built-in Json returns its own error format, which
/// would break clients expecting Anthropic error shapes.
pub(crate) struct AnthropicJson<T>(pub T);
impl<S, T> FromRequest<S> for AnthropicJson<T>
where
Json<T>: FromRequest<S, Rejection = JsonRejection>,
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
match Json::<T>::from_request(req, state).await {
Ok(Json(value)) => Ok(AnthropicJson(value)),
Err(rejection) => {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::InvalidRequestError,
rejection.body_text(),
None,
);
Err((StatusCode::BAD_REQUEST, Json(err)).into_response())
}
}
}
}
+307
View File
@@ -0,0 +1,307 @@
use crate::admin::state::{RuntimeConfig, SharedState};
use crate::backend::BackendClient;
use crate::metrics::Metrics;
use anyllm_providers::ProviderCatalog;
use anyllm_translate::{anthropic, mapping};
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio::sync::Semaphore;
use super::resolved_model::ResolvedModel;
use super::tool_engine::ToolEngineState;
/// Per-backend state shared across request handlers.
///
/// In single-backend mode, one `AppState` serves all routes. In multi-backend mode,
/// each backend gets its own `AppState` mounted under a prefix path (e.g., `/openai/v1/messages`).
#[derive(Clone)]
pub struct AppState {
pub backend: BackendClient,
pub metrics: Metrics,
/// Runtime config (model mappings, body logging, redaction) read on every request.
/// Shared with admin server so config changes take effect immediately.
pub runtime_config: Arc<RwLock<RuntimeConfig>>,
/// Shared admin state for request logging and live updates. None in tests.
pub shared: Option<SharedState>,
/// Per-route option overrides for the request that produced this (cloned)
/// state, set by `resolve_model_and_state` when a DB route was selected.
/// `None` means "no route override; use the global RuntimeConfig value".
/// Read by the option accessors (`redact_secrets`, `effective_tool_guardrails`,
/// `active_pxpipe`, `pxpipe_models`).
pub route_options: Option<Arc<crate::config::route_router::RouteOptions>>,
/// Backend name for logging purposes.
pub backend_name: String,
/// Canonical provider id used for provider/model policy decisions.
pub provider_id: Option<String>,
/// Concurrency limiter. Uses try_acquire (fail-fast) instead of queueing
/// to prevent cascading latency under load. Requests exceeding the limit
/// get 429 immediately, matching Anthropic's rate limiting behavior.
pub concurrency: Arc<Semaphore>,
/// Strip `stream_options` from streaming requests for local LLM compat.
pub omit_stream_options: bool,
/// Wall-clock cap for streaming responses in seconds. 0 = disabled.
/// Prevents resource exhaustion from stalled backends.
pub stream_timeout_secs: u64,
/// When true, set `x-anyllm-degradation` header on responses that silently drop features.
/// Mirrors Config::expose_degradation_warnings / MultiConfig::expose_degradation_warnings.
pub expose_degradation_warnings: bool,
/// Optional response cache for non-streaming requests.
pub cache: Option<Arc<crate::cache::memory::MemoryCache>>,
/// Anthropic thinking-block record-and-restore repair store. `None`
/// unless `backend` is `BackendClient::Anthropic`; only consulted by
/// `anthropic_passthrough`. Always `Some` for Anthropic backends
/// regardless of whether the feature is enabled -- use
/// `thinking_repair_enabled()` to check the live toggle before using it.
pub thinking_repair: Option<Arc<crate::thinking_repair::ThinkingRepairStore>>,
/// Text-to-image context compression engine (pxpipe). `None` unless
/// `backend` is `BackendClient::Anthropic`; only consulted by
/// `anthropic_passthrough`. Always `Some` for Anthropic backends regardless
/// of the live toggle -- use `active_pxpipe()`, which checks
/// `RuntimeConfig.pxpipe_compress`, before using it.
pub pxpipe: Option<Arc<crate::pxpipe::PxpipeEngine>>,
/// Command-aware tool-output compression engine (RTK). `Some` for Anthropic
/// and Translate modes; only consulted when `RuntimeConfig.rtk_compress` is
/// on -- use `rtk_engine_for(model)`.
pub rtk: Option<Arc<crate::rtk::RtkEngine>>,
/// FFEC prompt-compression engine (`OptimizerEngine`). `Some` for Anthropic
/// and Translate modes, mirroring `rtk`; baked with the static
/// `OPTIMIZER_MODE`-env default at startup -- use `effective_optimizer()`,
/// which applies the live `RouteOptions.optimizer_mode` override on top.
pub optimizer: Option<Arc<crate::optimizer::OptimizerEngine>>,
/// Model-level router for LiteLLM model_list configs. None for TOML/env configs.
/// Wrapped in RwLock for dynamic model management via admin API.
pub model_router: Option<Arc<RwLock<crate::config::model_router::ModelRouter>>>,
/// Immutable provider/model catalog used for runtime model metadata.
pub provider_catalog: Arc<ProviderCatalog>,
/// All backend states, for cross-backend model routing. None unless model_router is set.
pub all_backends: Option<Arc<HashMap<String, AppState>>>,
/// Tool execution engine state. None when tool execution is not configured.
pub tool_engine: Option<Arc<ToolEngineState>>,
/// Batch orchestration engine. None in test configs that don't need batch.
pub batch_engine: Option<
Arc<
anyllm_batch_engine::BatchEngine<
anyllm_batch_engine::queue::sqlite::SqliteQueue,
anyllm_batch_engine::webhook::sqlite::SqliteWebhookQueue,
>,
>,
>,
}
impl AppState {
/// Map a model name through the current runtime config for this backend.
pub(crate) fn map_model(&self, model: &str) -> String {
let config = self
.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner());
if let Some(mapping) = config.model_mappings.get(&self.backend_name) {
mapping.map_model(model)
} else {
model.to_string()
}
}
/// Resolve a model name to a backend.
///
/// Precedence: (1) admin-DB routes (`RouteRouter`), (2) LiteLLM model_router,
/// (3) legacy ModelMapping. An empty route router falls straight through so
/// installs without routes behave exactly as before.
pub(crate) fn resolve_model(&self, model: &str) -> ResolvedModel {
if let Some(shared) = self.shared.as_ref() {
if let Some(ref rr_lock) = shared.route_router {
use crate::config::route_router::RouteResolution;
let rr = rr_lock.read().unwrap_or_else(|e| e.into_inner());
if !rr.is_empty() {
match rr.resolve(model) {
RouteResolution::Routed(res) => {
return ResolvedModel::Routed {
backend_name: res.backend_name,
model: res.model,
deployment: res.deployment,
options: Some(res.options),
};
}
RouteResolution::AllAtLimit => return ResolvedModel::AllAtLimit,
// No route serves this model: fall through to the layers below.
RouteResolution::NoRoute => {}
}
}
}
}
if let Some(ref router_lock) = self.model_router {
let router = router_lock.read().unwrap_or_else(|e| e.into_inner());
if let Some(routed) = router.route(model) {
return ResolvedModel::Routed {
backend_name: routed.backend_name.to_string(),
model: routed.actual_model.to_string(),
deployment: routed.deployment.clone(),
options: None,
};
}
if router.has_model(model) {
return ResolvedModel::AllAtLimit;
}
return ResolvedModel::UnknownModel;
}
ResolvedModel::Legacy(self.map_model(model))
}
/// Resolve model and return (mapped_model, effective AppState, optional deployment).
/// If the model routes to a different backend, the returned state is cloned from
/// all_backends. Returns Err with a 429 response if all deployments are at limit.
/// The deployment Arc is returned so handlers can call record_start/record_finish.
#[allow(clippy::result_large_err)]
pub(crate) fn resolve_model_and_state(
&self,
model: &str,
) -> Result<
(
String,
AppState,
Option<Arc<crate::config::model_router::Deployment>>,
),
Response,
> {
match self.resolve_model(model) {
ResolvedModel::Routed {
backend_name,
model: mapped,
deployment,
options,
} => {
let mut effective = self
.all_backends
.as_ref()
.and_then(|m| m.get(&backend_name))
.cloned()
.or_else(|| {
// Check managed backends (SQLite-backed, zero-restart)
self.shared.as_ref().and_then(|s| {
let guard = s.managed_backends
.read()
.ok()
.or_else(|| {
tracing::warn!("managed_backends RwLock is poisoned; skipping managed backend lookup");
None
})?;
guard.get(&backend_name).map(|(row, client)| {
let mut state = self.clone();
state.backend = client.clone();
state.backend_name = backend_name.clone();
state.provider_id = Some(row.provider_id.clone());
state
})
})
})
.unwrap_or_else(|| self.clone());
// Carry the per-route option overrides onto the effective state so
// the option accessors resolve route-first, global-fallback.
effective.route_options = options;
Ok((mapped, effective, Some(deployment)))
}
ResolvedModel::AllAtLimit => {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::RateLimitError,
"all deployments for this model are at their RPM limit".to_string(),
None,
);
Err((StatusCode::TOO_MANY_REQUESTS, Json(err)).into_response())
}
ResolvedModel::UnknownModel => {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::InvalidRequestError,
format!("model '{model}' is not configured in model_list"),
None,
);
Err((StatusCode::BAD_REQUEST, Json(err)).into_response())
}
ResolvedModel::Legacy(mapped) => Ok((mapped, self.clone(), None)),
}
}
/// Whether request/response body logging is enabled.
pub(crate) fn log_bodies(&self) -> bool {
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.log_bodies
}
/// Whether upstream JSON/text request payloads should be redacted.
/// Route override (if set) wins over the global RuntimeConfig value.
pub(crate) fn redact_secrets(&self) -> bool {
if let Some(v) = self.route_options.as_ref().and_then(|o| o.redact_secrets) {
return v;
}
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.redact_secrets
}
/// Effective tool-call guardrail config for this request: the runtime,
/// admin-tunable override (`RuntimeConfig.tool_guardrail_mode`, no
/// restart required) applied on top of `engine.guardrails` (the static
/// preset built from YAML/env at startup). See
/// `crate::tools::resolve_runtime_guardrails`.
pub(crate) fn effective_tool_guardrails(
&self,
engine: &ToolEngineState,
) -> crate::tools::ToolGuardrailConfig {
// Route override (if set) wins over the live global RuntimeConfig mode.
if let Some(mode) = self
.route_options
.as_ref()
.and_then(|o| o.guardrail_mode.as_deref())
{
return crate::tools::resolve_runtime_guardrails(&engine.guardrails, mode);
}
crate::tools::resolve_runtime_guardrails_locked(&self.runtime_config, &engine.guardrails)
}
/// Whether Anthropic thinking-block repair (record + restore) is active.
/// `self.thinking_repair` may be `Some` even when this is `false` -- the
/// store is always constructed for Anthropic backends; only this flag
/// gates whether it's actually used.
pub(crate) fn thinking_repair_enabled(&self) -> bool {
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.anthropic_thinking_repair
}
/// Whether Anthropic passthrough forwards the client's own incoming
/// credential upstream instead of the operator's (`ANTHROPIC_FORWARD_CLIENT_AUTH`,
/// live-toggleable via `RuntimeConfig.forward_client_auth`). Read fresh on
/// every request -- unlike the old frozen `AppState` field this replaced,
/// this reflects an admin-UI change immediately without a restart, and
/// applies uniformly to every `BackendKind::Anthropic` backend since they
/// all share one `RuntimeConfig`.
pub(crate) fn forward_client_auth_enabled(&self) -> bool {
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.forward_client_auth
}
/// The thinking-repair store, but only when the live admin-toggleable
/// flag is actually on. `None` both when repair is entirely absent (non-
/// Anthropic backend) and when it's present-but-disabled -- single
/// accessor so call sites collapse to `if let Some(store) = ...` instead
/// of separately checking `thinking_repair_enabled()` and
/// `thinking_repair.is_some()`.
pub(crate) fn active_thinking_repair(
&self,
) -> Option<Arc<crate::thinking_repair::ThinkingRepairStore>> {
if self.thinking_repair_enabled() {
self.thinking_repair.clone()
} else {
None
}
}
}
@@ -0,0 +1,281 @@
use anyllm_translate::{anthropic, openai};
use std::sync::Arc;
use super::app_state::AppState;
impl AppState {
/// The pxpipe compression engine, but only when the live admin-toggleable
/// flag (`RuntimeConfig.pxpipe_compress`) is on. `None` both when the engine
/// is absent (non-Anthropic backend) and when present-but-disabled.
pub(crate) fn active_pxpipe(&self) -> Option<Arc<crate::pxpipe::PxpipeEngine>> {
let enabled = match self.route_options.as_ref().and_then(|o| o.pxpipe_compress) {
Some(v) => v,
None => {
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.pxpipe_compress
}
};
if enabled {
self.pxpipe.clone()
} else {
None
}
}
/// Live model-scope CSV for pxpipe. Route override wins over the global
/// `RuntimeConfig.pxpipe_models` value.
pub(crate) fn pxpipe_models(&self) -> String {
if let Some(csv) = self
.route_options
.as_ref()
.and_then(|o| o.pxpipe_models.clone())
{
return csv;
}
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.pxpipe_models
.clone()
}
/// Vision gate: if the catalog knows this model and says it is NOT
/// vision-capable, refuse (fail-closed). Unknown models fall back to the
/// scope list only — a Claude passthrough model is vision-capable in
/// practice, and the scope list is the operator's explicit control.
fn pxpipe_vision_ok(&self, model: &str) -> bool {
match self
.provider_id
.as_deref()
.and_then(|pid| self.provider_catalog.get_model(pid, model))
{
Some(def) => def.capabilities.vision,
None => true,
}
}
/// The pxpipe engine for `model`, or `None` if compression shouldn't run:
/// the master toggle is off, the engine is absent (non-Anthropic backend),
/// the model is out of the live scope CSV, or it isn't vision-capable.
/// Single accessor so `passthrough` collapses to
/// `if let Some(engine) = state.pxpipe_engine_for(model)`.
pub(crate) fn pxpipe_engine_for(
&self,
model: &str,
) -> Option<Arc<crate::pxpipe::PxpipeEngine>> {
let engine = self.active_pxpipe()?;
if crate::pxpipe::model_in_scope(model, &self.pxpipe_models())
&& self.pxpipe_vision_ok(model)
{
Some(engine)
} else {
None
}
}
/// The RTK engine for `model`, or `None` if compression shouldn't run: the
/// toggle is off, the engine is absent, or the model is out of scope. RTK is
/// not vision-gated, so there is no capability check.
///
/// Reads the toggle and scope from a single RwLock critical section for
/// consistency, and checks `route_options` first (matching the pxpipe pattern)
/// so per-route overrides take precedence over the global RuntimeConfig.
pub(crate) fn rtk_engine_for(&self, model: &str) -> Option<Arc<crate::rtk::RtkEngine>> {
let cfg = self
.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner());
let enabled = self
.route_options
.as_ref()
.and_then(|o| o.rtk_compress)
.unwrap_or(cfg.rtk_compress);
if !enabled {
return None;
}
let engine = self.rtk.clone()?;
let models_csv = self
.route_options
.as_ref()
.and_then(|o| o.rtk_models.as_deref())
.unwrap_or(&cfg.rtk_models);
if crate::rtk::model_in_scope(model, models_csv) {
Some(engine)
} else {
None
}
}
/// Effective FFEC prompt-compression engine for this request, or `None`
/// when optimization is unconfigured for this backend/mode (`self.optimizer`
/// is `None`). Precedence, mirroring `effective_tool_guardrails` /
/// `resolve_runtime_guardrails_locked`: (1) route override
/// (`RouteOptions.optimizer_mode`, if set) wins outright; (2) otherwise the
/// live `RuntimeConfig.optimizer_mode` admin toggle (no restart required);
/// (3) otherwise the static per-process engine baked with the
/// `OPTIMIZER_MODE`-env default at startup.
pub(crate) fn effective_optimizer(&self) -> Option<Arc<crate::optimizer::OptimizerEngine>> {
let engine = self.optimizer.as_ref()?;
if let Some(mode_str) = self
.route_options
.as_ref()
.and_then(|o| o.optimizer_mode.as_deref())
{
return Some(Arc::new(engine.with_mode_override(mode_str)));
}
Some(Arc::new(
crate::optimizer::resolve_runtime_optimizer_locked(&self.runtime_config, engine),
))
}
/// Apply RTK tool-output compression to an OpenAI-format request and record
/// metrics. Shared helper used by both the /v1/chat/completions and /v1/messages
/// translate paths (streaming and non-streaming). No-op when the engine is
/// unavailable, disabled, or no tool messages are present.
pub(crate) fn apply_rtk_to_openai(&self, req: &mut openai::ChatCompletionRequest, model: &str) {
let engine = match self.rtk_engine_for(model) {
Some(e) => e,
None => return,
};
// Pre-check: only serialize when there are tool messages to compress.
if !req
.messages
.iter()
.any(|m| m.role == openai::ChatRole::Tool)
{
return;
}
let mut v = match serde_json::to_value(&*req) {
Ok(v) => v,
Err(_) => return,
};
let Some((blocks, saved)) = engine.compress_openai_chat(&mut v) else {
return;
};
match serde_json::from_value::<openai::ChatCompletionRequest>(v) {
Ok(patched) => {
*req = patched;
self.metrics.record_rtk_compression(blocks, saved);
tracing::info!(
model,
blocks,
chars_saved = saved,
"rtk: compressed OpenAI request"
);
}
Err(e) => tracing::warn!(
error = %e,
"rtk: failed to re-deserialize compressed OpenAI request; forwarding original"
),
}
}
/// Apply FFEC prompt compression (`effective_optimizer()`) to an OpenAI-format
/// request at the parsed-body seam. Client-sent history only -- callers must
/// never invoke this on proxy-appended tool-loop turns (see
/// `crates/optimizer/CLAUDE.md` "Streaming & tool-loop decision"). `Shadow`
/// mode logs the `OptimizationReport` and leaves `req` unchanged; `Live` mode
/// applies the rendered body in place. No-op when optimization is
/// unconfigured or resolves to `Mode::Off` for this request.
pub(crate) fn apply_optimizer_to_openai(
&self,
req: &mut openai::ChatCompletionRequest,
route: &str,
) {
let Some(engine) = self.effective_optimizer() else {
return;
};
let mut v = match serde_json::to_value(&*req) {
Ok(v) => v,
Err(_) => return,
};
let report = engine.optimize_openai(&mut v, route);
if report.mode == anyllm_optimize_core::Mode::Shadow {
tracing::info!(
route,
removed_tokens_est = report.removed_tokens_est,
messages_compressed = report.messages_compressed,
failure = report.failure.as_deref().unwrap_or(""),
"optimizer: shadow report (not applied)"
);
}
if !report.applied {
return;
}
match serde_json::from_value::<openai::ChatCompletionRequest>(v) {
Ok(patched) => {
*req = patched;
self.metrics.record_optimization(
report.messages_compressed as u64,
report.removed_tokens_est,
);
tracing::info!(
route,
removed_tokens_est = report.removed_tokens_est,
messages_compressed = report.messages_compressed,
"optimizer: compressed OpenAI request"
);
}
Err(e) => tracing::warn!(
error = %e,
"optimizer: failed to re-deserialize compressed OpenAI request; forwarding original"
),
}
}
/// Apply FFEC prompt compression (`effective_optimizer()`) to an Anthropic
/// Messages request at the parsed-body seam. Same contract as
/// [`Self::apply_optimizer_to_openai`]: client-sent history only, fails open,
/// `Shadow` never mutates `req`.
pub(crate) fn apply_optimizer_to_anthropic(
&self,
req: &mut anthropic::MessageCreateRequest,
route: &str,
) {
let Some(engine) = self.effective_optimizer() else {
return;
};
let mut v = match serde_json::to_value(&*req) {
Ok(v) => v,
Err(_) => return,
};
let report = engine.optimize_anthropic(&mut v, route);
if report.mode == anyllm_optimize_core::Mode::Shadow {
tracing::info!(
route,
removed_tokens_est = report.removed_tokens_est,
messages_compressed = report.messages_compressed,
failure = report.failure.as_deref().unwrap_or(""),
"optimizer: shadow report (not applied)"
);
}
if !report.applied {
return;
}
match serde_json::from_value::<anthropic::MessageCreateRequest>(v) {
Ok(patched) => {
*req = patched;
self.metrics.record_optimization(
report.messages_compressed as u64,
report.removed_tokens_est,
);
tracing::info!(
route,
removed_tokens_est = report.removed_tokens_est,
messages_compressed = report.messages_compressed,
"optimizer: compressed Anthropic request"
);
}
Err(e) => tracing::warn!(
error = %e,
"optimizer: failed to re-deserialize compressed Anthropic request; forwarding original"
),
}
}
}
#[cfg(test)]
#[path = "compression/tests.rs"]
mod tests;
@@ -0,0 +1,315 @@
use super::*;
use crate::admin::state::RuntimeConfig;
use crate::config::{BackendAuth, BackendKind, Config, ModelMapping, OpenAIApiFormat, TlsConfig};
use crate::metrics::Metrics;
use anyllm_optimize_core::Mode;
use anyllm_providers::ProviderCatalog;
use std::sync::{Arc, RwLock};
use tokio::sync::Semaphore;
/// Long enough that FFEC's min-length gate actually has something to compress.
fn long_text() -> String {
"The quick brown fox jumps over the lazy dog again and again across the wide \
green field toward the distant blue mountains far beyond the winding river."
.repeat(4)
}
fn minimal_state(optimizer_mode: Mode) -> AppState {
let config = Config {
backend: BackendKind::OpenAI,
openai_api_key: "test".into(),
openai_base_url: "https://api.openai.com".into(),
listen_port: 3000,
model_mapping: ModelMapping {
big_model: "gpt-4o".into(),
small_model: "gpt-4o-mini".into(),
},
tls: TlsConfig::default(),
backend_auth: BackendAuth::BearerToken("test".into()),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
expose_degradation_warnings: false,
openai_api_format: OpenAIApiFormat::Chat,
provider_id: None,
};
let backend = crate::backend::BackendClient::OpenAI(
crate::backend::openai_client::OpenAIClient::new(&config),
);
let runtime_config = Arc::new(RwLock::new(RuntimeConfig {
model_mappings: indexmap::IndexMap::new(),
log_level: "info".to_string(),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
pxpipe_models: String::new(),
rtk_compress: false,
rtk_models: String::new(),
forward_client_auth: false,
tool_guardrail_mode: "disabled".to_string(),
optimizer_mode: optimizer_mode.as_str().to_string(),
}));
AppState {
backend,
metrics: Metrics::new(),
runtime_config,
shared: None,
route_options: None,
backend_name: "openai".to_string(),
provider_id: None,
concurrency: Arc::new(Semaphore::new(64)),
omit_stream_options: false,
stream_timeout_secs: 0,
expose_degradation_warnings: false,
cache: None,
thinking_repair: None,
pxpipe: None,
rtk: None,
optimizer: Some(Arc::new(crate::optimizer::OptimizerEngine::new(
optimizer_mode,
))),
model_router: None,
provider_catalog: Arc::new(ProviderCatalog::bundled()),
all_backends: None,
tool_engine: None,
batch_engine: None,
}
}
fn long_openai_request() -> openai::ChatCompletionRequest {
let long = long_text();
let mut body = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "system", "content": "you are helpful"}],
});
let msgs = body["messages"].as_array_mut().unwrap();
for _ in 0..16 {
msgs.push(serde_json::json!({"role": "user", "content": long}));
msgs.push(serde_json::json!({"role": "assistant", "content": long}));
}
msgs.push(serde_json::json!({"role": "user", "content": "what is the latest?"}));
serde_json::from_value(body).expect("valid ChatCompletionRequest")
}
fn long_anthropic_request() -> anthropic::MessageCreateRequest {
let long = long_text();
let mut body = serde_json::json!({
"model": "claude-sonnet-5",
"max_tokens": 1024,
"messages": [],
});
let msgs = body["messages"].as_array_mut().unwrap();
for _ in 0..16 {
msgs.push(serde_json::json!({"role": "user", "content": long}));
msgs.push(serde_json::json!({"role": "assistant", "content": long}));
}
msgs.push(serde_json::json!({"role": "user", "content": "what is the latest?"}));
serde_json::from_value(body).expect("valid MessageCreateRequest")
}
#[test]
fn shadow_mode_forwards_openai_body_unchanged() {
let state = minimal_state(Mode::Shadow);
let mut req = long_openai_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_openai(&mut req, "chat_completions");
let after = serde_json::to_value(&req).unwrap();
assert_eq!(before, after, "shadow mode must forward the original body");
assert_eq!(
state.metrics.snapshot().optimizer_compressed_total,
0,
"shadow mode must never record a metrics-visible compression"
);
}
#[test]
fn shadow_mode_forwards_anthropic_body_unchanged() {
let state = minimal_state(Mode::Shadow);
let mut req = long_anthropic_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_anthropic(&mut req, "messages");
let after = serde_json::to_value(&req).unwrap();
assert_eq!(before, after, "shadow mode must forward the original body");
}
#[test]
fn live_mode_compresses_openai_history_and_preserves_latest() {
let state = minimal_state(Mode::Live);
let mut req = long_openai_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_openai(&mut req, "chat_completions");
let after = serde_json::to_value(&req).unwrap();
assert_eq!(
before["messages"].as_array().unwrap().last(),
after["messages"].as_array().unwrap().last(),
"the latest turn must never be rewritten"
);
assert_eq!(
state.metrics.snapshot().optimizer_compressed_total,
1,
"an applied Live compression must be recorded in metrics"
);
}
#[test]
fn live_mode_compresses_anthropic_history_and_preserves_latest() {
let state = minimal_state(Mode::Live);
let mut req = long_anthropic_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_anthropic(&mut req, "messages");
let after = serde_json::to_value(&req).unwrap();
assert_eq!(
before["messages"].as_array().unwrap().last(),
after["messages"].as_array().unwrap().last(),
"the latest turn must never be rewritten"
);
}
#[test]
fn off_mode_is_noop_and_engine_absent_is_noop() {
// Off mode: engine present, mode Off -> never applied.
let state = minimal_state(Mode::Off);
let mut req = long_openai_request();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_openai(&mut req, "chat_completions");
assert_eq!(before, serde_json::to_value(&req).unwrap());
// No engine at all (e.g. non-Anthropic/Translate mode backend): no panic, no-op.
let mut state_no_engine = minimal_state(Mode::Live);
state_no_engine.optimizer = None;
let mut req2 = long_openai_request();
let before2 = serde_json::to_value(&req2).unwrap();
state_no_engine.apply_optimizer_to_openai(&mut req2, "chat_completions");
assert_eq!(before2, serde_json::to_value(&req2).unwrap());
}
#[test]
fn short_history_below_min_len_gate_is_a_noop_not_a_panic() {
// A short request has nothing worth compressing (below FFEC's min-length
// gate) -- the seam must still round-trip cleanly without panicking or
// corrupting the body, i.e. it fails open when there's nothing to do.
let state = minimal_state(Mode::Live);
let mut req: openai::ChatCompletionRequest = serde_json::from_value(serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
}))
.unwrap();
let before = serde_json::to_value(&req).unwrap();
state.apply_optimizer_to_openai(&mut req, "chat_completions");
assert_eq!(before, serde_json::to_value(&req).unwrap());
}
fn state_with_rtk(enabled: bool) -> AppState {
let config = Config {
backend: BackendKind::OpenAI,
openai_api_key: "test".into(),
openai_base_url: "https://api.openai.com".into(),
listen_port: 3000,
model_mapping: ModelMapping {
big_model: "gpt-4o".into(),
small_model: "gpt-4o-mini".into(),
},
tls: TlsConfig::default(),
backend_auth: BackendAuth::BearerToken("test".into()),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
expose_degradation_warnings: false,
openai_api_format: OpenAIApiFormat::Chat,
provider_id: None,
};
let backend = crate::backend::BackendClient::OpenAI(
crate::backend::openai_client::OpenAIClient::new(&config),
);
let runtime_config = Arc::new(RwLock::new(RuntimeConfig {
model_mappings: indexmap::IndexMap::new(),
log_level: "info".to_string(),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
pxpipe_models: String::new(),
rtk_compress: enabled,
rtk_models: String::new(),
forward_client_auth: false,
tool_guardrail_mode: "disabled".to_string(),
optimizer_mode: "off".to_string(),
}));
AppState {
backend,
metrics: Metrics::new(),
runtime_config,
shared: None,
route_options: None,
backend_name: "openai".to_string(),
provider_id: None,
concurrency: Arc::new(Semaphore::new(64)),
omit_stream_options: false,
stream_timeout_secs: 0,
expose_degradation_warnings: false,
cache: None,
thinking_repair: None,
pxpipe: None,
rtk: Some(Arc::new(crate::rtk::RtkEngine::new())),
optimizer: None,
model_router: None,
provider_catalog: Arc::new(ProviderCatalog::bundled()),
all_backends: None,
tool_engine: None,
batch_engine: None,
}
}
fn request_with_noisy_tool_output() -> openai::ChatCompletionRequest {
let mut noise = String::from("On branch main\nChanges not staged for commit:\n");
for i in 0..200 {
noise.push_str(&format!(" (use \"git add ...\" file {i})\n"));
}
serde_json::from_value(serde_json::json!({
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "run git status"},
{"role": "assistant", "content": null, "tool_calls": [
{"id": "t1", "type": "function",
"function": {"name": "bash", "arguments": "{\"cmd\":\"git status\"}"}}
]},
{"role": "tool", "tool_call_id": "t1", "content": noise},
],
}))
.expect("valid ChatCompletionRequest")
}
#[test]
fn enabled_compresses_tool_output_and_records_metrics() {
let state = state_with_rtk(true);
let mut req = request_with_noisy_tool_output();
let before = serde_json::to_value(&req).unwrap();
state.apply_rtk_to_openai(&mut req, "gpt-4o");
let after = serde_json::to_value(&req).unwrap();
assert_ne!(before, after, "tool output should have been compressed");
assert_eq!(state.metrics.snapshot().rtk_compressed_total, 1);
}
#[test]
fn disabled_is_a_noop() {
let state = state_with_rtk(false);
let mut req = request_with_noisy_tool_output();
let before = serde_json::to_value(&req).unwrap();
state.apply_rtk_to_openai(&mut req, "gpt-4o");
assert_eq!(before, serde_json::to_value(&req).unwrap());
assert_eq!(state.metrics.snapshot().rtk_compressed_total, 0);
}
#[test]
fn engine_absent_is_a_noop() {
let mut state = state_with_rtk(true);
state.rtk = None;
let mut req = request_with_noisy_tool_output();
let before = serde_json::to_value(&req).unwrap();
state.apply_rtk_to_openai(&mut req, "gpt-4o");
assert_eq!(before, serde_json::to_value(&req).unwrap());
assert_eq!(state.metrics.snapshot().rtk_compressed_total, 0);
}
@@ -0,0 +1,17 @@
use crate::metrics::Metrics;
use std::collections::HashMap;
use std::sync::Arc;
/// Global state for the multi-backend metrics endpoint.
#[derive(Clone)]
pub(crate) struct GlobalState {
pub(crate) backend_metrics: Arc<HashMap<String, Metrics>>,
}
/// Wrapper so OwnedSemaphorePermit can be stored in request extensions.
/// The field is never read directly; it exists as an RAII guard to hold
/// the permit until the struct is dropped.
#[derive(Clone)]
pub(crate) struct ConcurrencyPermit(
#[allow(dead_code)] pub(crate) Arc<tokio::sync::OwnedSemaphorePermit>,
);
+15
View File
@@ -0,0 +1,15 @@
// Shared state types for request handlers: AppState, AnthropicJson, ResolvedModel, etc.
// Extracted from routes.rs so consumers can import state independently of the router setup.
pub(crate) mod anthropic_json;
pub(crate) mod app_state;
pub(crate) mod compression;
pub(crate) mod concurrency;
pub(crate) mod resolved_model;
pub(crate) mod tool_engine;
pub(crate) use anthropic_json::AnthropicJson;
pub use app_state::AppState;
pub(crate) use concurrency::{ConcurrencyPermit, GlobalState};
pub(crate) use resolved_model::ResolvedModel;
pub use tool_engine::ToolEngineState;
@@ -0,0 +1,21 @@
use std::sync::Arc;
/// Result of resolving a model name through the model router.
pub(crate) enum ResolvedModel {
/// Routed via model_list to a specific backend and actual model name.
Routed {
backend_name: String,
model: String,
/// The deployment Arc for recording in-flight/latency stats.
deployment: Arc<crate::config::model_router::Deployment>,
/// Per-route option overrides when routed via a DB route; `None` when
/// routed via the LiteLLM model_router (inherit global config).
options: Option<Arc<crate::config::route_router::RouteOptions>>,
},
/// Model is known but all deployments are at their RPM limit.
AllAtLimit,
/// Model router is active but the model alias is not configured.
UnknownModel,
/// No model router, or model not in router. Used legacy ModelMapping.
Legacy(String),
}
@@ -0,0 +1,11 @@
use std::sync::Arc;
/// Shared state for tool execution, stored in AppState.
#[derive(Clone)]
pub struct ToolEngineState {
pub registry: Arc<crate::tools::ToolRegistry>,
pub policy: Arc<crate::tools::ToolExecutionPolicy>,
pub loop_config: crate::tools::LoopConfig,
pub guardrails: crate::tools::ToolGuardrailConfig,
pub mcp_manager: Option<Arc<crate::tools::McpServerManager>>,
}
+2 -332
View File
@@ -248,335 +248,5 @@ fn thinking_eq(a: &ContentBlock, b: &ContentBlock) -> bool {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::thinking_repair::store::ThinkingRepairStore;
use anyllm_translate::anthropic::{Content, InputMessage, MessageCreateRequest};
use serde_json::json;
fn thinking(text: &str, sig: &str) -> ContentBlock {
ContentBlock::Thinking {
thinking: text.to_string(),
signature: Some(sig.to_string()),
}
}
fn tool_use(id: &str) -> ContentBlock {
ContentBlock::ToolUse {
id: id.to_string(),
name: "get_weather".to_string(),
input: json!({"city": "nyc"}),
}
}
fn text(s: &str) -> ContentBlock {
ContentBlock::Text {
text: s.to_string(),
}
}
fn req_with_last_assistant(blocks: Vec<ContentBlock>) -> MessageCreateRequest {
MessageCreateRequest {
model: "claude-opus-4-5".to_string(),
max_tokens: 1024,
messages: vec![
InputMessage {
role: Role::User,
content: Content::Text("hi".to_string()),
},
InputMessage {
role: Role::Assistant,
content: Content::Blocks(blocks),
},
],
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
stream: None,
extra: serde_json::Map::new(),
}
}
fn last_blocks(req: &MessageCreateRequest) -> &Vec<ContentBlock> {
match &req.messages.last().unwrap().content {
Content::Blocks(b) => b,
Content::Text(_) => panic!("expected blocks"),
}
}
#[tokio::test]
async fn tier0_byte_identical_passthrough() {
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("hmm", "sig_1"), tool_use("toolu_1")],
)
.await;
let mut req = req_with_last_assistant(vec![thinking("hmm", "sig_1"), tool_use("toolu_1")]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(
result.is_none(),
"byte-identical replay should not be touched"
);
assert_eq!(last_blocks(&req).len(), 2);
}
#[tokio::test]
async fn tier1_restores_mutated_text_under_known_signature() {
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("original thought", "sig_1"), tool_use("toolu_1")],
)
.await;
let mut req = req_with_last_assistant(vec![
thinking("merged garbled thought", "sig_1"),
tool_use("toolu_1"),
]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(result.unwrap().contains("restored 1"));
match &last_blocks(&req)[0] {
ContentBlock::Thinking { thinking, .. } => assert_eq!(thinking, "original thought"),
other => panic!("expected restored Thinking block, got {other:?}"),
}
}
#[tokio::test]
async fn tier2_drops_intruder_block_from_different_message() {
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("owner thought", "sig_owner"), tool_use("toolu_1")],
)
.await;
store
.commit("ns1", "msg_2", vec![thinking("other thought", "sig_other")])
.await;
// Replay claims to be msg_1's turn (tool_use toolu_1) but carries an
// intruder thinking block whose signature belongs to msg_2.
let mut req = req_with_last_assistant(vec![
thinking("other thought", "sig_other"),
thinking("owner thought", "sig_owner"),
tool_use("toolu_1"),
]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(result.unwrap().contains("dropped 1"));
let blocks = last_blocks(&req);
assert_eq!(blocks.len(), 2);
assert!(
matches!(&blocks[0], ContentBlock::Thinking { signature: Some(s), .. } if s == "sig_owner")
);
}
#[tokio::test]
async fn unknown_signature_with_known_owner_is_dropped() {
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("owner thought", "sig_owner"), tool_use("toolu_1")],
)
.await;
let mut req = req_with_last_assistant(vec![
thinking("garbage, no matching record", "sig_unknown"),
thinking("owner thought", "sig_owner"),
tool_use("toolu_1"),
]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(result.unwrap().contains("dropped 1"));
assert_eq!(last_blocks(&req).len(), 2);
}
#[tokio::test]
async fn unknown_signature_with_no_owner_fails_open() {
let store = ThinkingRepairStore::new();
// No tool_use in this turn, so no owner is resolvable.
let mut req = req_with_last_assistant(vec![
thinking("standalone thought", "sig_unknown"),
text("done"),
]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(
result.is_none(),
"no owner evidence -> fail open, pass through"
);
assert_eq!(last_blocks(&req).len(), 2);
}
#[tokio::test]
async fn reinserts_lost_redacted_thinking_block() {
let store = ThinkingRepairStore::new();
let redacted = ContentBlock::RedactedThinking {
data: "encrypted-blob".to_string(),
};
store
.commit("ns1", "msg_1", vec![redacted.clone(), tool_use("toolu_1")])
.await;
// Client replay is missing the redacted_thinking block entirely
// (Claude Code doesn't persist it to JSONL).
let mut req = req_with_last_assistant(vec![tool_use("toolu_1")]);
let result = repair_request(&store, "ns1", &mut req).await;
let msg = result.unwrap();
assert!(msg.contains("reinserted"), "got: {msg}");
let blocks = last_blocks(&req);
assert_eq!(blocks.len(), 2);
assert!(
matches!(&blocks[0], ContentBlock::RedactedThinking { data } if data == "encrypted-blob")
);
}
#[tokio::test]
async fn surplus_current_nonthinking_block_disables_reinsert() {
// rec has [thinking, tool_use_1]; current turn replays tool_use_1
// AND adds an extra tool_use_2 that was never recorded, while also
// dropping the thinking block. Non-thinking counts don't line up
// (2 vs. 1), so positional reinsertion would either drop toolu_2 or
// misalign it against rec -- must fail open instead.
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("hmm", "sig_1"), tool_use("toolu_1")],
)
.await;
let mut req = req_with_last_assistant(vec![tool_use("toolu_1"), tool_use("toolu_2")]);
let result = repair_request(&store, "ns1", &mut req).await;
let blocks = last_blocks(&req);
assert!(result.is_none(), "count mismatch should fail open");
assert_eq!(
blocks.len(),
2,
"current turn's blocks must be preserved as-is"
);
assert!(
blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { id, .. } if id == "toolu_2")),
"toolu_2 must not be dropped"
);
}
#[tokio::test]
async fn missing_current_nonthinking_block_disables_reinsert() {
// rec has [thinking, tool_use_1, tool_use_2]; current turn only
// replays tool_use_1 (dropped tool_use_2 AND the thinking block).
// Non-thinking counts don't line up (1 vs. 2), so reinserting
// positionally would resurrect stale toolu_2 -- must fail open.
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![
thinking("hmm", "sig_1"),
tool_use("toolu_1"),
tool_use("toolu_2"),
],
)
.await;
let mut req = req_with_last_assistant(vec![tool_use("toolu_1")]);
let result = repair_request(&store, "ns1", &mut req).await;
let blocks = last_blocks(&req);
assert!(result.is_none(), "count mismatch should fail open");
assert_eq!(
blocks.len(),
1,
"current turn's blocks must be preserved as-is"
);
assert!(
!blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { id, .. } if id == "toolu_2")),
"stale recorded toolu_2 must not be reinserted"
);
}
#[tokio::test]
async fn messages_before_last_assistant_are_never_touched() {
let store = ThinkingRepairStore::new();
store
.commit("ns1", "msg_2", vec![thinking("turn 2", "sig_2")])
.await;
let mut req = MessageCreateRequest {
model: "claude-opus-4-5".to_string(),
max_tokens: 1024,
messages: vec![
InputMessage {
role: Role::User,
content: Content::Text("first".to_string()),
},
InputMessage {
role: Role::Assistant,
// Deliberately mutated/corrupt — must be left alone
// because it is NOT the last assistant message.
content: Content::Blocks(vec![thinking("mutated turn 1", "sig_1_unknown")]),
},
InputMessage {
role: Role::User,
content: Content::Text("second".to_string()),
},
InputMessage {
role: Role::Assistant,
content: Content::Blocks(vec![thinking("turn 2", "sig_2")]),
},
],
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
stream: None,
extra: serde_json::Map::new(),
};
repair_request(&store, "ns1", &mut req).await;
match &req.messages[1].content {
Content::Blocks(b) => match &b[0] {
ContentBlock::Thinking {
thinking,
signature,
} => {
assert_eq!(thinking, "mutated turn 1");
assert_eq!(signature.as_deref(), Some("sig_1_unknown"));
}
other => panic!("unexpected block: {other:?}"),
},
Content::Text(_) => panic!("expected blocks"),
}
}
}
#[path = "repair/tests.rs"]
mod tests;
@@ -0,0 +1,330 @@
use super::*;
use crate::thinking_repair::store::ThinkingRepairStore;
use anyllm_translate::anthropic::{Content, InputMessage, MessageCreateRequest};
use serde_json::json;
fn thinking(text: &str, sig: &str) -> ContentBlock {
ContentBlock::Thinking {
thinking: text.to_string(),
signature: Some(sig.to_string()),
}
}
fn tool_use(id: &str) -> ContentBlock {
ContentBlock::ToolUse {
id: id.to_string(),
name: "get_weather".to_string(),
input: json!({"city": "nyc"}),
}
}
fn text(s: &str) -> ContentBlock {
ContentBlock::Text {
text: s.to_string(),
}
}
fn req_with_last_assistant(blocks: Vec<ContentBlock>) -> MessageCreateRequest {
MessageCreateRequest {
model: "claude-opus-4-5".to_string(),
max_tokens: 1024,
messages: vec![
InputMessage {
role: Role::User,
content: Content::Text("hi".to_string()),
},
InputMessage {
role: Role::Assistant,
content: Content::Blocks(blocks),
},
],
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
stream: None,
extra: serde_json::Map::new(),
}
}
fn last_blocks(req: &MessageCreateRequest) -> &Vec<ContentBlock> {
match &req.messages.last().unwrap().content {
Content::Blocks(b) => b,
Content::Text(_) => panic!("expected blocks"),
}
}
#[tokio::test]
async fn tier0_byte_identical_passthrough() {
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("hmm", "sig_1"), tool_use("toolu_1")],
)
.await;
let mut req = req_with_last_assistant(vec![thinking("hmm", "sig_1"), tool_use("toolu_1")]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(
result.is_none(),
"byte-identical replay should not be touched"
);
assert_eq!(last_blocks(&req).len(), 2);
}
#[tokio::test]
async fn tier1_restores_mutated_text_under_known_signature() {
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("original thought", "sig_1"), tool_use("toolu_1")],
)
.await;
let mut req = req_with_last_assistant(vec![
thinking("merged garbled thought", "sig_1"),
tool_use("toolu_1"),
]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(result.unwrap().contains("restored 1"));
match &last_blocks(&req)[0] {
ContentBlock::Thinking { thinking, .. } => assert_eq!(thinking, "original thought"),
other => panic!("expected restored Thinking block, got {other:?}"),
}
}
#[tokio::test]
async fn tier2_drops_intruder_block_from_different_message() {
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("owner thought", "sig_owner"), tool_use("toolu_1")],
)
.await;
store
.commit("ns1", "msg_2", vec![thinking("other thought", "sig_other")])
.await;
// Replay claims to be msg_1's turn (tool_use toolu_1) but carries an
// intruder thinking block whose signature belongs to msg_2.
let mut req = req_with_last_assistant(vec![
thinking("other thought", "sig_other"),
thinking("owner thought", "sig_owner"),
tool_use("toolu_1"),
]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(result.unwrap().contains("dropped 1"));
let blocks = last_blocks(&req);
assert_eq!(blocks.len(), 2);
assert!(
matches!(&blocks[0], ContentBlock::Thinking { signature: Some(s), .. } if s == "sig_owner")
);
}
#[tokio::test]
async fn unknown_signature_with_known_owner_is_dropped() {
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("owner thought", "sig_owner"), tool_use("toolu_1")],
)
.await;
let mut req = req_with_last_assistant(vec![
thinking("garbage, no matching record", "sig_unknown"),
thinking("owner thought", "sig_owner"),
tool_use("toolu_1"),
]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(result.unwrap().contains("dropped 1"));
assert_eq!(last_blocks(&req).len(), 2);
}
#[tokio::test]
async fn unknown_signature_with_no_owner_fails_open() {
let store = ThinkingRepairStore::new();
// No tool_use in this turn, so no owner is resolvable.
let mut req = req_with_last_assistant(vec![
thinking("standalone thought", "sig_unknown"),
text("done"),
]);
let result = repair_request(&store, "ns1", &mut req).await;
assert!(
result.is_none(),
"no owner evidence -> fail open, pass through"
);
assert_eq!(last_blocks(&req).len(), 2);
}
#[tokio::test]
async fn reinserts_lost_redacted_thinking_block() {
let store = ThinkingRepairStore::new();
let redacted = ContentBlock::RedactedThinking {
data: "encrypted-blob".to_string(),
};
store
.commit("ns1", "msg_1", vec![redacted.clone(), tool_use("toolu_1")])
.await;
// Client replay is missing the redacted_thinking block entirely
// (Claude Code doesn't persist it to JSONL).
let mut req = req_with_last_assistant(vec![tool_use("toolu_1")]);
let result = repair_request(&store, "ns1", &mut req).await;
let msg = result.unwrap();
assert!(msg.contains("reinserted"), "got: {msg}");
let blocks = last_blocks(&req);
assert_eq!(blocks.len(), 2);
assert!(
matches!(&blocks[0], ContentBlock::RedactedThinking { data } if data == "encrypted-blob")
);
}
#[tokio::test]
async fn surplus_current_nonthinking_block_disables_reinsert() {
// rec has [thinking, tool_use_1]; current turn replays tool_use_1
// AND adds an extra tool_use_2 that was never recorded, while also
// dropping the thinking block. Non-thinking counts don't line up
// (2 vs. 1), so positional reinsertion would either drop toolu_2 or
// misalign it against rec -- must fail open instead.
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![thinking("hmm", "sig_1"), tool_use("toolu_1")],
)
.await;
let mut req = req_with_last_assistant(vec![tool_use("toolu_1"), tool_use("toolu_2")]);
let result = repair_request(&store, "ns1", &mut req).await;
let blocks = last_blocks(&req);
assert!(result.is_none(), "count mismatch should fail open");
assert_eq!(
blocks.len(),
2,
"current turn's blocks must be preserved as-is"
);
assert!(
blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { id, .. } if id == "toolu_2")),
"toolu_2 must not be dropped"
);
}
#[tokio::test]
async fn missing_current_nonthinking_block_disables_reinsert() {
// rec has [thinking, tool_use_1, tool_use_2]; current turn only
// replays tool_use_1 (dropped tool_use_2 AND the thinking block).
// Non-thinking counts don't line up (1 vs. 2), so reinserting
// positionally would resurrect stale toolu_2 -- must fail open.
let store = ThinkingRepairStore::new();
store
.commit(
"ns1",
"msg_1",
vec![
thinking("hmm", "sig_1"),
tool_use("toolu_1"),
tool_use("toolu_2"),
],
)
.await;
let mut req = req_with_last_assistant(vec![tool_use("toolu_1")]);
let result = repair_request(&store, "ns1", &mut req).await;
let blocks = last_blocks(&req);
assert!(result.is_none(), "count mismatch should fail open");
assert_eq!(
blocks.len(),
1,
"current turn's blocks must be preserved as-is"
);
assert!(
!blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { id, .. } if id == "toolu_2")),
"stale recorded toolu_2 must not be reinserted"
);
}
#[tokio::test]
async fn messages_before_last_assistant_are_never_touched() {
let store = ThinkingRepairStore::new();
store
.commit("ns1", "msg_2", vec![thinking("turn 2", "sig_2")])
.await;
let mut req = MessageCreateRequest {
model: "claude-opus-4-5".to_string(),
max_tokens: 1024,
messages: vec![
InputMessage {
role: Role::User,
content: Content::Text("first".to_string()),
},
InputMessage {
role: Role::Assistant,
// Deliberately mutated/corrupt — must be left alone
// because it is NOT the last assistant message.
content: Content::Blocks(vec![thinking("mutated turn 1", "sig_1_unknown")]),
},
InputMessage {
role: Role::User,
content: Content::Text("second".to_string()),
},
InputMessage {
role: Role::Assistant,
content: Content::Blocks(vec![thinking("turn 2", "sig_2")]),
},
],
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
stream: None,
extra: serde_json::Map::new(),
};
repair_request(&store, "ns1", &mut req).await;
match &req.messages[1].content {
Content::Blocks(b) => match &b[0] {
ContentBlock::Thinking {
thinking,
signature,
} => {
assert_eq!(thinking, "mutated turn 1");
assert_eq!(signature.as_deref(), Some("sig_1_unknown"));
}
other => panic!("unexpected block: {other:?}"),
},
Content::Text(_) => panic!("expected blocks"),
}
}
@@ -0,0 +1,131 @@
//! Tool guardrail logic: partition tool calls into auto-execute / pass-through /
//! denied buckets, build denial + nudge results, and the combined
//! `partition_and_nudge` shared by both tool loops.
use std::collections::HashSet;
use super::{ToolCall, ToolResult};
use crate::tools::policy::{PolicyAction, ToolExecutionPolicy};
use crate::tools::registry::ToolRegistry;
use crate::tools::trace::ToolOutcome;
use crate::tools::{evaluate_tool_guardrails, ToolGuardrailNudge, ToolGuardrailRequestState};
/// Partition tool calls into three categories.
///
/// A tool call is only eligible for server-side policy evaluation when the
/// proxy advertised that exact tool name for this request. This prevents
/// client-supplied tool schemas from reusing privileged server tool names.
///
/// - `auto_execute`: proxy-advertised AND in registry AND policy says Allow
/// - `pass_through`: not proxy-advertised, not in registry, OR policy says PassThrough
/// - `denied`: proxy-advertised AND policy says Deny
pub fn partition_tool_calls<'a>(
tool_calls: &'a [ToolCall],
registry: &ToolRegistry,
policy: &ToolExecutionPolicy,
server_advertised_tool_names: &HashSet<String>,
) -> (Vec<&'a ToolCall>, Vec<&'a ToolCall>, Vec<&'a ToolCall>) {
let mut auto_execute = Vec::new();
let mut pass_through = Vec::new();
let mut denied = Vec::new();
for call in tool_calls {
if !server_advertised_tool_names.contains(&call.name) {
pass_through.push(call);
continue;
}
match policy.resolve(&call.name) {
PolicyAction::Deny => denied.push(call),
PolicyAction::Allow if registry.contains(&call.name) => auto_execute.push(call),
// Allow but not in registry, or PassThrough
_ => pass_through.push(call),
}
}
(auto_execute, pass_through, denied)
}
/// Build error ToolResults for denied tool calls.
pub fn denied_tool_results(denied: &[&ToolCall]) -> Vec<ToolResult> {
denied
.iter()
.map(|call| ToolResult {
tool_use_id: call.id.clone(),
tool_name: call.name.clone(),
outcome: ToolOutcome::Error {
message: format!("Tool '{}' is denied by policy", call.name),
retryable: false,
},
})
.collect()
}
/// Build retryable ToolResults for guardrail nudges.
///
/// Only the tool calls a nudge targets get a result; every other call in the
/// batch is left for normal execution/pass-through. `calls` supplies the tool
/// name for each nudged `call_id`.
pub fn guardrail_nudge_results(
calls: &[ToolCall],
nudges: &[ToolGuardrailNudge],
) -> Vec<ToolResult> {
nudges
.iter()
.filter_map(|nudge| {
let call = calls.iter().find(|c| c.id == nudge.call_id)?;
Some(ToolResult {
tool_use_id: call.id.clone(),
tool_name: call.name.clone(),
outcome: ToolOutcome::Error {
message: format!("[ToolCallPolicyNudge] {}", nudge.content),
retryable: true,
},
})
})
.collect()
}
/// Partition `tool_calls`, then evaluate guardrail nudges only against the
/// calls that would otherwise be auto-executed by this proxy.
///
/// A nudge target must be something the proxy actually owns: guardrails are
/// an advisory retry mechanism that answers the model on the proxy's behalf
/// (a synthetic tool_result + a follow-up backend call), so applying it to a
/// pass-through call (not in the registry / not proxy-advertised, meaning the
/// *caller* owns and expects to execute it -- e.g. a Claude-Code-style client's
/// own Bash/Grep/Edit/Write tool) would silently swallow that call's real
/// tool_use turn instead of returning it to the caller. Restricting
/// `evaluate_tool_guardrails` to the post-partition `auto_exec` set keeps
/// nudges impossible to apply to anything the proxy doesn't own, and
/// `denied` never needs nudge-filtering since nudges only ever originate
/// from `auto_exec` now (the two sets are disjoint by construction).
///
/// Shared by the streaming and non-streaming tool loops so a fix here can't
/// drift between the two copies.
pub fn partition_and_nudge<'a>(
tool_calls: &'a [ToolCall],
tool_specs: &[anyllm_translate::anthropic::Tool],
registry: &ToolRegistry,
policy: &ToolExecutionPolicy,
server_advertised_tool_names: &HashSet<String>,
guardrails: &crate::tools::ToolGuardrailConfig,
guardrail_state: &mut ToolGuardrailRequestState,
) -> (Vec<&'a ToolCall>, Vec<ToolResult>, Vec<ToolResult>) {
let (auto_exec, _pass_through, denied) =
partition_tool_calls(tool_calls, registry, policy, server_advertised_tool_names);
let auto_exec_owned: Vec<ToolCall> = auto_exec.iter().map(|c| (*c).clone()).collect();
let nudges =
evaluate_tool_guardrails(&auto_exec_owned, tool_specs, guardrails, guardrail_state);
let nudged_ids: HashSet<&str> = nudges.iter().map(|n| n.call_id.as_str()).collect();
let nudge_results = guardrail_nudge_results(&auto_exec_owned, &nudges);
let auto_exec: Vec<&ToolCall> = auto_exec
.into_iter()
.filter(|c| !nudged_ids.contains(c.id.as_str()))
.collect();
let denied_results = denied_tool_results(&denied);
(auto_exec, nudge_results, denied_results)
}
@@ -0,0 +1,63 @@
//! Parsers/converters between Anthropic message types and `ToolCall`/`ToolResult`.
use super::{ToolCall, ToolResult};
use crate::tools::trace::ToolOutcome;
/// Extract ToolCall structs from an Anthropic MessageResponse.
pub fn extract_tool_calls(
response: &anyllm_translate::anthropic::MessageResponse,
) -> Vec<ToolCall> {
response
.content
.iter()
.filter_map(|block| {
if let anyllm_translate::anthropic::ContentBlock::ToolUse { id, name, input } = block {
Some(ToolCall {
id: id.clone(),
name: name.clone(),
input: input.clone(),
})
} else {
None
}
})
.collect()
}
/// Convert tool execution results to an Anthropic user message with ToolResult blocks.
pub fn tool_results_to_user_message(
results: &[ToolResult],
) -> anyllm_translate::anthropic::InputMessage {
let blocks: Vec<anyllm_translate::anthropic::ContentBlock> = results
.iter()
.map(|r| {
let (content_text, is_error) = match &r.outcome {
ToolOutcome::Success(v) => (serde_json::to_string(v).unwrap_or_default(), false),
ToolOutcome::Error { message, .. } => (message.clone(), true),
ToolOutcome::Timeout => ("Tool execution timed out".to_string(), true),
};
anyllm_translate::anthropic::ContentBlock::ToolResult {
tool_use_id: r.tool_use_id.clone(),
content: Some(anyllm_translate::anthropic::ToolResultContent::Text(
content_text,
)),
is_error: Some(is_error),
}
})
.collect();
anyllm_translate::anthropic::InputMessage {
role: anyllm_translate::anthropic::Role::User,
content: anyllm_translate::anthropic::Content::Blocks(blocks),
}
}
/// Convert a MessageResponse's content into an assistant InputMessage for conversation history.
pub fn response_to_assistant_message(
response: &anyllm_translate::anthropic::MessageResponse,
) -> anyllm_translate::anthropic::InputMessage {
anyllm_translate::anthropic::InputMessage {
role: anyllm_translate::anthropic::Role::Assistant,
content: anyllm_translate::anthropic::Content::Blocks(response.content.clone()),
}
}
+21 -557
View File
@@ -1,14 +1,28 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
//! Tool execution: the shared types plus the guardrail, runner, and helper
//! submodules that implement partitioning, parallel execution, and the bounded
//! non-streaming tool loop.
use std::time::Duration;
use serde_json::Value;
use tokio::task::JoinSet;
use crate::tools::policy::{PolicyAction, ToolExecutionPolicy};
use crate::tools::registry::ToolRegistry;
use crate::tools::trace::ToolOutcome;
use crate::tools::{evaluate_tool_guardrails, ToolGuardrailNudge, ToolGuardrailRequestState};
mod guardrail;
mod helpers;
mod runner;
pub use guardrail::{
denied_tool_results, guardrail_nudge_results, partition_and_nudge, partition_tool_calls,
};
pub use helpers::{
extract_tool_calls, response_to_assistant_message, tool_results_to_user_message,
};
pub use runner::{execute_tool_calls, execute_tool_calls_timed, is_duplicate, maybe_execute_tools};
/// Engine state needed by `maybe_execute_tools`. Re-exported alias so callers
/// do not need to reach into `server::state`.
pub use crate::server::state::ToolEngineState;
/// A tool call extracted from an LLM response.
#[derive(Debug, Clone)]
@@ -46,555 +60,5 @@ impl Default for LoopConfig {
}
}
/// Partition tool calls into three categories.
///
/// A tool call is only eligible for server-side policy evaluation when the
/// proxy advertised that exact tool name for this request. This prevents
/// client-supplied tool schemas from reusing privileged server tool names.
///
/// - `auto_execute`: proxy-advertised AND in registry AND policy says Allow
/// - `pass_through`: not proxy-advertised, not in registry, OR policy says PassThrough
/// - `denied`: proxy-advertised AND policy says Deny
pub fn partition_tool_calls<'a>(
tool_calls: &'a [ToolCall],
registry: &ToolRegistry,
policy: &ToolExecutionPolicy,
server_advertised_tool_names: &HashSet<String>,
) -> (Vec<&'a ToolCall>, Vec<&'a ToolCall>, Vec<&'a ToolCall>) {
let mut auto_execute = Vec::new();
let mut pass_through = Vec::new();
let mut denied = Vec::new();
for call in tool_calls {
if !server_advertised_tool_names.contains(&call.name) {
pass_through.push(call);
continue;
}
match policy.resolve(&call.name) {
PolicyAction::Deny => denied.push(call),
PolicyAction::Allow if registry.contains(&call.name) => auto_execute.push(call),
// Allow but not in registry, or PassThrough
_ => pass_through.push(call),
}
}
(auto_execute, pass_through, denied)
}
/// Build error ToolResults for denied tool calls.
pub fn denied_tool_results(denied: &[&ToolCall]) -> Vec<ToolResult> {
denied
.iter()
.map(|call| ToolResult {
tool_use_id: call.id.clone(),
tool_name: call.name.clone(),
outcome: ToolOutcome::Error {
message: format!("Tool '{}' is denied by policy", call.name),
retryable: false,
},
})
.collect()
}
/// Build retryable ToolResults for guardrail nudges.
///
/// Only the tool calls a nudge targets get a result; every other call in the
/// batch is left for normal execution/pass-through. `calls` supplies the tool
/// name for each nudged `call_id`.
pub fn guardrail_nudge_results(
calls: &[ToolCall],
nudges: &[ToolGuardrailNudge],
) -> Vec<ToolResult> {
nudges
.iter()
.filter_map(|nudge| {
let call = calls.iter().find(|c| c.id == nudge.call_id)?;
Some(ToolResult {
tool_use_id: call.id.clone(),
tool_name: call.name.clone(),
outcome: ToolOutcome::Error {
message: format!("[ToolCallPolicyNudge] {}", nudge.content),
retryable: true,
},
})
})
.collect()
}
/// Partition `tool_calls`, then evaluate guardrail nudges only against the
/// calls that would otherwise be auto-executed by this proxy.
///
/// A nudge target must be something the proxy actually owns: guardrails are
/// an advisory retry mechanism that answers the model on the proxy's behalf
/// (a synthetic tool_result + a follow-up backend call), so applying it to a
/// pass-through call (not in the registry / not proxy-advertised, meaning the
/// *caller* owns and expects to execute it — e.g. a Claude-Code-style client's
/// own Bash/Grep/Edit/Write tool) would silently swallow that call's real
/// tool_use turn instead of returning it to the caller. Restricting
/// `evaluate_tool_guardrails` to the post-partition `auto_exec` set keeps
/// nudges impossible to apply to anything the proxy doesn't own, and
/// `denied` never needs nudge-filtering since nudges only ever originate
/// from `auto_exec` now (the two sets are disjoint by construction).
///
/// Shared by the streaming and non-streaming tool loops so a fix here can't
/// drift between the two copies.
pub fn partition_and_nudge<'a>(
tool_calls: &'a [ToolCall],
tool_specs: &[anyllm_translate::anthropic::Tool],
registry: &ToolRegistry,
policy: &ToolExecutionPolicy,
server_advertised_tool_names: &HashSet<String>,
guardrails: &crate::tools::ToolGuardrailConfig,
guardrail_state: &mut ToolGuardrailRequestState,
) -> (Vec<&'a ToolCall>, Vec<ToolResult>, Vec<ToolResult>) {
let (auto_exec, _pass_through, denied) =
partition_tool_calls(tool_calls, registry, policy, server_advertised_tool_names);
let auto_exec_owned: Vec<ToolCall> = auto_exec.iter().map(|c| (*c).clone()).collect();
let nudges =
evaluate_tool_guardrails(&auto_exec_owned, tool_specs, guardrails, guardrail_state);
let nudged_ids: HashSet<&str> = nudges.iter().map(|n| n.call_id.as_str()).collect();
let nudge_results = guardrail_nudge_results(&auto_exec_owned, &nudges);
let auto_exec: Vec<&ToolCall> = auto_exec
.into_iter()
.filter(|c| !nudged_ids.contains(c.id.as_str()))
.collect();
let denied_results = denied_tool_results(&denied);
(auto_exec, nudge_results, denied_results)
}
/// Execute tool calls in parallel, respecting per-tool timeouts.
///
/// Results are returned in the same order as `calls`.
pub async fn execute_tool_calls(
calls: &[&ToolCall],
registry: Arc<ToolRegistry>,
policy: &ToolExecutionPolicy,
config: &LoopConfig,
) -> Vec<ToolResult> {
let capped = &calls[..calls.len().min(config.max_tool_calls_per_turn)];
// Collect (original_index, ToolCall) to restore order after parallel execution.
let indexed: Vec<(usize, &ToolCall)> = capped.iter().copied().enumerate().collect();
let mut join_set: JoinSet<(usize, ToolResult)> = JoinSet::new();
for (idx, call) in indexed {
let timeout = policy
.find_rule(&call.name)
.and_then(|r| r.timeout)
.unwrap_or(config.tool_timeout);
let registry = Arc::clone(&registry);
let id = call.id.clone();
let name = call.name.clone();
let input = call.input.clone();
join_set.spawn(async move {
let result =
tokio::time::timeout(timeout, execute_single(&registry, &name, input)).await;
let outcome = match result {
Ok(Ok(value)) => ToolOutcome::Success(value),
Ok(Err(msg)) => ToolOutcome::Error {
message: msg,
retryable: false,
},
Err(_elapsed) => ToolOutcome::Timeout,
};
(
idx,
ToolResult {
tool_use_id: id,
tool_name: name,
outcome,
},
)
});
}
let mut collected: Vec<(usize, ToolResult)> = Vec::with_capacity(capped.len());
while let Some(res) = join_set.join_next().await {
match res {
Ok(pair) => collected.push(pair),
Err(e) => {
// JoinError means the task panicked; treat as an error outcome.
// We don't have the index here, so we skip (shouldn't happen in practice).
tracing::error!("tool execution task panicked: {e}");
}
}
}
collected.sort_by_key(|(idx, _)| *idx);
collected.into_iter().map(|(_, r)| r).collect()
}
/// Check whether two slices of ToolCall represent the same logical calls.
///
/// Same length, same multiset of (name, input) pairs. IDs are ignored.
pub fn is_duplicate(a: &[ToolCall], b: &[ToolCall]) -> bool {
if a.len() != b.len() {
return false;
}
let mut a_pairs: Vec<(&str, &Value)> = a.iter().map(|c| (c.name.as_str(), &c.input)).collect();
let mut b_pairs: Vec<(&str, &Value)> = b.iter().map(|c| (c.name.as_str(), &c.input)).collect();
// Sort by name so comparison is order-independent.
a_pairs.sort_by_key(|(name, _)| *name);
b_pairs.sort_by_key(|(name, _)| *name);
a_pairs == b_pairs
}
/// Execute a single tool by name, looking it up in the registry.
async fn execute_single(
registry: &ToolRegistry,
tool_name: &str,
input: Value,
) -> Result<Value, String> {
match registry.get(tool_name) {
Some(tool) => tool.execute(input).await,
None => Err(format!("tool '{}' not found in registry", tool_name)),
}
}
// ---------------------------------------------------------------------------
// Helper functions for extracting tool calls and building follow-up messages
// ---------------------------------------------------------------------------
/// Extract ToolCall structs from an Anthropic MessageResponse.
pub fn extract_tool_calls(
response: &anyllm_translate::anthropic::MessageResponse,
) -> Vec<ToolCall> {
response
.content
.iter()
.filter_map(|block| {
if let anyllm_translate::anthropic::ContentBlock::ToolUse { id, name, input } = block {
Some(ToolCall {
id: id.clone(),
name: name.clone(),
input: input.clone(),
})
} else {
None
}
})
.collect()
}
/// Convert tool execution results to an Anthropic user message with ToolResult blocks.
pub fn tool_results_to_user_message(
results: &[ToolResult],
) -> anyllm_translate::anthropic::InputMessage {
let blocks: Vec<anyllm_translate::anthropic::ContentBlock> = results
.iter()
.map(|r| {
let (content_text, is_error) = match &r.outcome {
ToolOutcome::Success(v) => (serde_json::to_string(v).unwrap_or_default(), false),
ToolOutcome::Error { message, .. } => (message.clone(), true),
ToolOutcome::Timeout => ("Tool execution timed out".to_string(), true),
};
anyllm_translate::anthropic::ContentBlock::ToolResult {
tool_use_id: r.tool_use_id.clone(),
content: Some(anyllm_translate::anthropic::ToolResultContent::Text(
content_text,
)),
is_error: Some(is_error),
}
})
.collect();
anyllm_translate::anthropic::InputMessage {
role: anyllm_translate::anthropic::Role::User,
content: anyllm_translate::anthropic::Content::Blocks(blocks),
}
}
/// Convert a MessageResponse's content into an assistant InputMessage for conversation history.
pub fn response_to_assistant_message(
response: &anyllm_translate::anthropic::MessageResponse,
) -> anyllm_translate::anthropic::InputMessage {
anyllm_translate::anthropic::InputMessage {
role: anyllm_translate::anthropic::Role::Assistant,
content: anyllm_translate::anthropic::Content::Blocks(response.content.clone()),
}
}
// ---------------------------------------------------------------------------
// Timing wrapper used in the execution loop (available to callers)
// ---------------------------------------------------------------------------
/// Run `execute_tool_calls` and record wall-clock duration per call.
/// Returns (results, elapsed_per_call). Exposed for loop-level tracing.
pub async fn execute_tool_calls_timed(
calls: &[&ToolCall],
registry: Arc<ToolRegistry>,
policy: &ToolExecutionPolicy,
config: &LoopConfig,
) -> (Vec<ToolResult>, Duration) {
let start = Instant::now();
let results = execute_tool_calls(calls, registry, policy, config).await;
(results, start.elapsed())
}
// ---------------------------------------------------------------------------
// Centralized bounded tool-execution loop for non-streaming requests
// ---------------------------------------------------------------------------
use crate::tools::trace::{IterationTrace, LoopTrace, TerminationReason, ToolCallTrace};
/// Engine state needed by `maybe_execute_tools`. Re-exported alias so callers
/// do not need to reach into `server::routes`.
pub use crate::server::state::ToolEngineState;
/// Process an LLM response for tool execution. If auto-executable tool calls
/// are found, executes them and makes follow-up backend calls in a bounded loop.
///
/// Returns the final response (original if no tools were auto-executed) and a
/// `LoopTrace` recording what happened.
///
/// `backend_call` is a closure the caller provides. It takes a
/// `MessageCreateRequest` and returns the translated `MessageResponse`.
/// This keeps the loop backend-agnostic: the handler knows how to translate
/// and call its specific backend; this function only knows about Anthropic types.
///
/// `guardrails` is the effective guardrail config for this request (the
/// runtime-tunable override applied on top of `engine.guardrails`, the
/// static per-process preset -- see `crate::tools::resolve_runtime_guardrails`
/// and `AppState::effective_tool_guardrails`). Callers that don't need the
/// runtime override can pass `&engine.guardrails` directly.
pub async fn maybe_execute_tools<F, Fut>(
engine: &ToolEngineState,
original_req: &anyllm_translate::anthropic::MessageCreateRequest,
server_advertised_tool_names: &HashSet<String>,
initial_response: anyllm_translate::anthropic::MessageResponse,
guardrails: &crate::tools::ToolGuardrailConfig,
backend_call: F,
) -> (anyllm_translate::anthropic::MessageResponse, LoopTrace)
where
F: Fn(anyllm_translate::anthropic::MessageCreateRequest) -> Fut,
Fut: std::future::Future<Output = Result<anyllm_translate::anthropic::MessageResponse, String>>,
{
let loop_start = Instant::now();
let mut iterations: Vec<IterationTrace> = Vec::new();
let mut current_response = initial_response;
let mut current_messages = original_req.messages.clone();
let mut prev_tool_calls: Option<Vec<ToolCall>> = None;
let mut guardrail_state = ToolGuardrailRequestState::new();
for _iteration in 0..engine.loop_config.max_iterations {
// Guard: total timeout
if loop_start.elapsed() > engine.loop_config.total_timeout {
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::Timeout,
},
);
}
let tool_calls = extract_tool_calls(&current_response);
// Advisory guardrails: each nudge targets one offending call and comes
// back as a retryable error result. Nudged calls are skipped this turn;
// every other call is partitioned and executed as usual. Each distinct
// decision nudges only once per request (across iterations), so a
// model that ignores a nudge and repeats the call lets it proceed
// instead of spinning the loop to max_iterations. Nudges only ever
// apply to calls this proxy would actually execute -- see
// `partition_and_nudge`'s doc comment.
let (auto_exec, nudge_results, denied_results) = partition_and_nudge(
&tool_calls,
original_req.tools.as_deref().unwrap_or(&[]),
&engine.registry,
&engine.policy,
server_advertised_tool_names,
guardrails,
&mut guardrail_state,
);
if auto_exec.is_empty() && denied_results.is_empty() && nudge_results.is_empty() {
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::NoToolCalls,
},
);
}
// If there is nothing to execute (only nudges and/or denials), send the
// advisory/error results back to the LLM immediately without running a tool.
if auto_exec.is_empty() {
let mut results = nudge_results;
results.extend(denied_results);
current_messages.push(response_to_assistant_message(&current_response));
current_messages.push(tool_results_to_user_message(&results));
let mut follow_up_req = original_req.clone();
follow_up_req.messages = current_messages.clone();
let llm_start = Instant::now();
let traces: Vec<ToolCallTrace> = results
.iter()
.map(|r| ToolCallTrace {
tool_name: r.tool_name.clone(),
duration: Duration::ZERO,
outcome: r.outcome.clone(),
})
.collect();
iterations.push(IterationTrace {
tool_calls: traces,
llm_latency: Duration::ZERO,
});
match backend_call(follow_up_req).await {
Ok(resp) => {
if let Some(last) = iterations.last_mut() {
last.llm_latency = llm_start.elapsed();
}
prev_tool_calls = None;
current_response = resp;
continue;
}
Err(e) => {
tracing::warn!(error = %e, "follow-up backend call failed after nudge/deny");
if let Some(last) = iterations.last_mut() {
last.llm_latency = llm_start.elapsed();
}
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::NoToolCalls,
},
);
}
}
}
// Guard: duplicate detection (same tool calls as previous iteration)
let auto_calls: Vec<ToolCall> = auto_exec.iter().map(|c| (*c).clone()).collect();
if let Some(ref prev) = prev_tool_calls {
if is_duplicate(prev, &auto_calls) {
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::DuplicateDetected,
},
);
}
}
// Execute auto-allowed tools in parallel
let exec_start = Instant::now();
let mut results = execute_tool_calls(
&auto_exec,
engine.registry.clone(),
&engine.policy,
&engine.loop_config,
)
.await;
let exec_duration = exec_start.elapsed();
// Append nudge + denied-tool error results so the LLM sees all outcomes.
results.extend(nudge_results);
results.extend(denied_results);
// Build per-tool traces
let tool_traces: Vec<ToolCallTrace> = results
.iter()
.map(|r| ToolCallTrace {
tool_name: r.tool_name.clone(),
duration: exec_duration,
outcome: r.outcome.clone(),
})
.collect();
// Guard: all tools failed (includes deny errors, which are non-retryable)
let all_failed = results
.iter()
.all(|r| !matches!(r.outcome, ToolOutcome::Success(_)));
iterations.push(IterationTrace {
tool_calls: tool_traces,
llm_latency: Duration::ZERO, // filled after backend call below
});
if all_failed {
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::AllToolsFailed,
},
);
}
// Build follow-up: append assistant response + tool results to conversation
current_messages.push(response_to_assistant_message(&current_response));
current_messages.push(tool_results_to_user_message(&results));
let mut follow_up_req = original_req.clone();
follow_up_req.messages = current_messages.clone();
// Call backend via caller-provided closure
let llm_start = Instant::now();
match backend_call(follow_up_req).await {
Ok(resp) => {
if let Some(last) = iterations.last_mut() {
last.llm_latency = llm_start.elapsed();
}
tracing::info!(
tools_executed = results.len(),
iteration = _iteration + 1,
"tool execution loop: iteration complete"
);
prev_tool_calls = Some(auto_calls);
current_response = resp;
}
Err(e) => {
tracing::warn!(
error = %e,
"follow-up backend call failed, returning last good response"
);
if let Some(last) = iterations.last_mut() {
last.llm_latency = llm_start.elapsed();
}
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
// Backend error is not a clean termination; closest match is NoToolCalls
// since we are stopping the loop and returning what we have.
termination_reason: TerminationReason::NoToolCalls,
},
);
}
}
}
// Exhausted max_iterations
(
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::MaxIterations,
},
)
}
#[cfg(test)]
mod tests;
+373
View File
@@ -0,0 +1,373 @@
//! Core async tool-execution runners and the centralized bounded tool loop
//! for non-streaming requests.
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::Value;
use tokio::task::JoinSet;
use super::{
extract_tool_calls, partition_and_nudge, response_to_assistant_message,
tool_results_to_user_message, LoopConfig, ToolCall, ToolEngineState, ToolResult,
};
use crate::tools::policy::ToolExecutionPolicy;
use crate::tools::registry::ToolRegistry;
use crate::tools::trace::{
IterationTrace, LoopTrace, TerminationReason, ToolCallTrace, ToolOutcome,
};
use crate::tools::ToolGuardrailRequestState;
/// Execute tool calls in parallel, respecting per-tool timeouts.
///
/// Results are returned in the same order as `calls`.
pub async fn execute_tool_calls(
calls: &[&ToolCall],
registry: Arc<ToolRegistry>,
policy: &ToolExecutionPolicy,
config: &LoopConfig,
) -> Vec<ToolResult> {
let capped = &calls[..calls.len().min(config.max_tool_calls_per_turn)];
// Collect (original_index, ToolCall) to restore order after parallel execution.
let indexed: Vec<(usize, &ToolCall)> = capped.iter().copied().enumerate().collect();
let mut join_set: JoinSet<(usize, ToolResult)> = JoinSet::new();
for (idx, call) in indexed {
let timeout = policy
.find_rule(&call.name)
.and_then(|r| r.timeout)
.unwrap_or(config.tool_timeout);
let registry = Arc::clone(&registry);
let id = call.id.clone();
let name = call.name.clone();
let input = call.input.clone();
join_set.spawn(async move {
let result =
tokio::time::timeout(timeout, execute_single(&registry, &name, input)).await;
let outcome = match result {
Ok(Ok(value)) => ToolOutcome::Success(value),
Ok(Err(msg)) => ToolOutcome::Error {
message: msg,
retryable: false,
},
Err(_elapsed) => ToolOutcome::Timeout,
};
(
idx,
ToolResult {
tool_use_id: id,
tool_name: name,
outcome,
},
)
});
}
let mut collected: Vec<(usize, ToolResult)> = Vec::with_capacity(capped.len());
while let Some(res) = join_set.join_next().await {
match res {
Ok(pair) => collected.push(pair),
Err(e) => {
// JoinError means the task panicked; treat as an error outcome.
// We don't have the index here, so we skip (shouldn't happen in practice).
tracing::error!("tool execution task panicked: {e}");
}
}
}
collected.sort_by_key(|(idx, _)| *idx);
collected.into_iter().map(|(_, r)| r).collect()
}
/// Check whether two slices of ToolCall represent the same logical calls.
///
/// Same length, same multiset of (name, input) pairs. IDs are ignored.
pub fn is_duplicate(a: &[ToolCall], b: &[ToolCall]) -> bool {
if a.len() != b.len() {
return false;
}
let mut a_pairs: Vec<(&str, &Value)> = a.iter().map(|c| (c.name.as_str(), &c.input)).collect();
let mut b_pairs: Vec<(&str, &Value)> = b.iter().map(|c| (c.name.as_str(), &c.input)).collect();
// Sort by name so comparison is order-independent.
a_pairs.sort_by_key(|(name, _)| *name);
b_pairs.sort_by_key(|(name, _)| *name);
a_pairs == b_pairs
}
/// Execute a single tool by name, looking it up in the registry.
async fn execute_single(
registry: &ToolRegistry,
tool_name: &str,
input: Value,
) -> Result<Value, String> {
match registry.get(tool_name) {
Some(tool) => tool.execute(input).await,
None => Err(format!("tool '{}' not found in registry", tool_name)),
}
}
/// Run `execute_tool_calls` and record wall-clock duration per call.
/// Returns (results, elapsed_per_call). Exposed for loop-level tracing.
pub async fn execute_tool_calls_timed(
calls: &[&ToolCall],
registry: Arc<ToolRegistry>,
policy: &ToolExecutionPolicy,
config: &LoopConfig,
) -> (Vec<ToolResult>, Duration) {
let start = Instant::now();
let results = execute_tool_calls(calls, registry, policy, config).await;
(results, start.elapsed())
}
/// Process an LLM response for tool execution. If auto-executable tool calls
/// are found, executes them and makes follow-up backend calls in a bounded loop.
///
/// Returns the final response (original if no tools were auto-executed) and a
/// `LoopTrace` recording what happened.
///
/// `backend_call` is a closure the caller provides. It takes a
/// `MessageCreateRequest` and returns the translated `MessageResponse`.
/// This keeps the loop backend-agnostic: the handler knows how to translate
/// and call its specific backend; this function only knows about Anthropic types.
///
/// `guardrails` is the effective guardrail config for this request (the
/// runtime-tunable override applied on top of `engine.guardrails`, the
/// static per-process preset -- see `crate::tools::resolve_runtime_guardrails`
/// and `AppState::effective_tool_guardrails`). Callers that don't need the
/// runtime override can pass `&engine.guardrails` directly.
pub async fn maybe_execute_tools<F, Fut>(
engine: &ToolEngineState,
original_req: &anyllm_translate::anthropic::MessageCreateRequest,
server_advertised_tool_names: &HashSet<String>,
initial_response: anyllm_translate::anthropic::MessageResponse,
guardrails: &crate::tools::ToolGuardrailConfig,
backend_call: F,
) -> (anyllm_translate::anthropic::MessageResponse, LoopTrace)
where
F: Fn(anyllm_translate::anthropic::MessageCreateRequest) -> Fut,
Fut: std::future::Future<Output = Result<anyllm_translate::anthropic::MessageResponse, String>>,
{
let loop_start = Instant::now();
let mut iterations: Vec<IterationTrace> = Vec::new();
let mut current_response = initial_response;
let mut current_messages = original_req.messages.clone();
let mut prev_tool_calls: Option<Vec<ToolCall>> = None;
let mut guardrail_state = ToolGuardrailRequestState::new();
for _iteration in 0..engine.loop_config.max_iterations {
// Guard: total timeout
if loop_start.elapsed() > engine.loop_config.total_timeout {
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::Timeout,
},
);
}
let tool_calls = extract_tool_calls(&current_response);
// Advisory guardrails: each nudge targets one offending call and comes
// back as a retryable error result. Nudged calls are skipped this turn;
// every other call is partitioned and executed as usual. Each distinct
// decision nudges only once per request (across iterations), so a
// model that ignores a nudge and repeats the call lets it proceed
// instead of spinning the loop to max_iterations. Nudges only ever
// apply to calls this proxy would actually execute -- see
// `partition_and_nudge`'s doc comment.
let (auto_exec, nudge_results, denied_results) = partition_and_nudge(
&tool_calls,
original_req.tools.as_deref().unwrap_or(&[]),
&engine.registry,
&engine.policy,
server_advertised_tool_names,
guardrails,
&mut guardrail_state,
);
if auto_exec.is_empty() && denied_results.is_empty() && nudge_results.is_empty() {
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::NoToolCalls,
},
);
}
// If there is nothing to execute (only nudges and/or denials), send the
// advisory/error results back to the LLM immediately without running a tool.
if auto_exec.is_empty() {
let mut results = nudge_results;
results.extend(denied_results);
current_messages.push(response_to_assistant_message(&current_response));
current_messages.push(tool_results_to_user_message(&results));
let mut follow_up_req = original_req.clone();
follow_up_req.messages = current_messages.clone();
let llm_start = Instant::now();
let traces: Vec<ToolCallTrace> = results
.iter()
.map(|r| ToolCallTrace {
tool_name: r.tool_name.clone(),
duration: Duration::ZERO,
outcome: r.outcome.clone(),
})
.collect();
iterations.push(IterationTrace {
tool_calls: traces,
llm_latency: Duration::ZERO,
});
match backend_call(follow_up_req).await {
Ok(resp) => {
if let Some(last) = iterations.last_mut() {
last.llm_latency = llm_start.elapsed();
}
prev_tool_calls = None;
current_response = resp;
continue;
}
Err(e) => {
tracing::warn!(error = %e, "follow-up backend call failed after nudge/deny");
if let Some(last) = iterations.last_mut() {
last.llm_latency = llm_start.elapsed();
}
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::NoToolCalls,
},
);
}
}
}
// Guard: duplicate detection (same tool calls as previous iteration)
let auto_calls: Vec<ToolCall> = auto_exec.iter().map(|c| (*c).clone()).collect();
if let Some(ref prev) = prev_tool_calls {
if is_duplicate(prev, &auto_calls) {
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::DuplicateDetected,
},
);
}
}
// Execute auto-allowed tools in parallel
let exec_start = Instant::now();
let mut results = execute_tool_calls(
&auto_exec,
engine.registry.clone(),
&engine.policy,
&engine.loop_config,
)
.await;
let exec_duration = exec_start.elapsed();
// Append nudge + denied-tool error results so the LLM sees all outcomes.
results.extend(nudge_results);
results.extend(denied_results);
// Build per-tool traces
let tool_traces: Vec<ToolCallTrace> = results
.iter()
.map(|r| ToolCallTrace {
tool_name: r.tool_name.clone(),
duration: exec_duration,
outcome: r.outcome.clone(),
})
.collect();
// Guard: all tools failed (includes deny errors, which are non-retryable)
let all_failed = results
.iter()
.all(|r| !matches!(r.outcome, ToolOutcome::Success(_)));
iterations.push(IterationTrace {
tool_calls: tool_traces,
llm_latency: Duration::ZERO, // filled after backend call below
});
if all_failed {
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::AllToolsFailed,
},
);
}
// Build follow-up: append assistant response + tool results to conversation
current_messages.push(response_to_assistant_message(&current_response));
current_messages.push(tool_results_to_user_message(&results));
let mut follow_up_req = original_req.clone();
follow_up_req.messages = current_messages.clone();
// Call backend via caller-provided closure
let llm_start = Instant::now();
match backend_call(follow_up_req).await {
Ok(resp) => {
if let Some(last) = iterations.last_mut() {
last.llm_latency = llm_start.elapsed();
}
tracing::info!(
tools_executed = results.len(),
iteration = _iteration + 1,
"tool execution loop: iteration complete"
);
prev_tool_calls = Some(auto_calls);
current_response = resp;
}
Err(e) => {
tracing::warn!(
error = %e,
"follow-up backend call failed, returning last good response"
);
if let Some(last) = iterations.last_mut() {
last.llm_latency = llm_start.elapsed();
}
return (
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
// Backend error is not a clean termination; closest match is NoToolCalls
// since we are stopping the loop and returning what we have.
termination_reason: TerminationReason::NoToolCalls,
},
);
}
}
}
// Exhausted max_iterations
(
current_response,
LoopTrace {
iterations,
total_duration: loop_start.elapsed(),
termination_reason: TerminationReason::MaxIterations,
},
)
}
@@ -2,6 +2,7 @@ use super::*;
use crate::tools::policy::{PolicyAction, PolicyRule, ToolExecutionPolicy};
use crate::tools::registry::ToolRegistry;
use serde_json::json;
use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
+13 -517
View File
@@ -6,9 +6,15 @@
use crate::tools::execution::ToolCall;
use anyllm_translate::anthropic::Tool;
use serde_json::{Map, Value};
use std::str::FromStr;
mod lsp;
mod quiet;
#[cfg(test)]
mod tests;
mod utils;
mod write_cap;
/// Default maximum string payload size for write/edit policy nudges.
pub const DEFAULT_MAX_WRITE_PAYLOAD_BYTES: usize = 64 * 1024;
@@ -207,7 +213,7 @@ pub fn evaluate_tool_guardrails(
}
let lsp_tools = if config.lsp_first {
available_lsp_tools(tool_specs)
lsp::available_lsp_tools(tool_specs)
} else {
Vec::new()
};
@@ -216,18 +222,20 @@ pub fn evaluate_tool_guardrails(
let mut nudged_fingerprints_this_batch: indexmap::IndexSet<String> = indexmap::IndexSet::new();
for call in tool_calls {
let candidate = (!lsp_tools.is_empty())
.then(|| lsp_first_nudge(call, &lsp_tools))
.then(|| lsp::lsp_first_nudge(call, &lsp_tools))
.flatten()
.or_else(|| {
config
.write_payload_caps
.then(|| write_payload_cap_nudge(call, config.max_write_payload_bytes))
.then(|| {
write_cap::write_payload_cap_nudge(call, config.max_write_payload_bytes)
})
.flatten()
})
.or_else(|| {
config
.quiet_commands
.then(|| quiet_command_nudge(call))
.then(|| quiet::quiet_command_nudge(call))
.flatten()
});
@@ -246,515 +254,3 @@ pub fn evaluate_tool_guardrails(
nudges
}
fn available_lsp_tools(tool_specs: &[Tool]) -> Vec<String> {
let supported = [
"find_definition",
"find_references",
"get_hover",
"document_symbols",
"workspace_symbols",
];
tool_specs
.iter()
.filter(|tool| supported.contains(&tool.name.as_str()))
.map(|tool| tool.name.clone())
.collect()
}
fn lsp_first_nudge(call: &ToolCall, lsp_tools: &[String]) -> Option<ToolGuardrailNudge> {
let tool_name = call.name.to_ascii_lowercase();
let args = object_args(&call.input);
let symbol = if is_shell_tool(&tool_name) {
shell_grep_symbol(command_arg(args?)?)?
} else if is_grep_tool(&tool_name) {
string_arg(
args?,
&["symbol", "name", "pattern", "query", "regex", "needle"],
)
.and_then(symbol_from_search_value)?
} else if is_glob_tool(&tool_name) {
string_arg(args?, &["pattern", "query", "glob"]).and_then(symbol_from_search_value)?
} else {
return None;
};
let tools = lsp_tools.join(", ");
let fingerprint = format!("lsp_first:{}:{symbol}", call.name);
Some(ToolGuardrailNudge {
call_id: call.id.clone(),
kind: "lsp_first",
content: format!(
"Use available LSP tools for symbol lookup instead of grep/glob/shell search. Available LSP tools: {tools}. Retry with the best matching LSP tool for `{symbol}`."
),
fingerprint,
})
}
fn quiet_command_nudge(call: &ToolCall) -> Option<ToolGuardrailNudge> {
let tool_name = call.name.to_ascii_lowercase();
if !is_shell_tool(&tool_name) {
return None;
}
let command = command_arg(object_args(&call.input)?)?.trim();
let suggestion = quiet_command_suggestion(command)?;
let fingerprint = format!("quiet:{}:{command}:{suggestion}", call.name);
Some(ToolGuardrailNudge {
call_id: call.id.clone(),
kind: "quiet_command",
content: format!(
"The requested shell command is likely to produce noisy output. Prefer `{suggestion}`. Repeat the original command only if verbose output is required."
),
fingerprint,
})
}
fn write_payload_cap_nudge(call: &ToolCall, max_bytes: usize) -> Option<ToolGuardrailNudge> {
// A cap of 0 disables the nudge rather than firing on every non-empty write.
if max_bytes == 0 {
return None;
}
let tool_name = call.name.to_ascii_lowercase();
if !is_write_or_edit_tool(&tool_name) {
return None;
}
let bytes = object_args(&call.input)
.map(write_payload_bytes)
.unwrap_or(0);
if bytes <= max_bytes {
return None;
}
Some(ToolGuardrailNudge {
call_id: call.id.clone(),
kind: "write_payload_cap",
content: format!(
"The requested write/edit payload is too large for this proxy policy ({bytes} bytes > {max_bytes} bytes). Retry with a smaller targeted edit or split the change."
),
// Include a hash of the actual payload, not just its byte length, so
// two unrelated oversized writes that happen to be the same size
// (e.g. same-size templated files) don't collide and suppress each
// other's nudge. Identical repeated calls (same target, same
// content) still dedupe as intended.
fingerprint: format!(
"write_payload_cap:{}:{:x}:{bytes}:{max_bytes}",
call.name,
payload_content_hash(&call.input)
),
})
}
fn payload_content_hash(value: &Value) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.to_string().hash(&mut hasher);
hasher.finish()
}
fn is_shell_tool(name: &str) -> bool {
matches!(
name,
"bash" | "shell" | "run_command" | "execute_command" | "terminal" | "exec" | "execute_bash"
)
}
fn is_grep_tool(name: &str) -> bool {
matches!(name, "grep" | "rg" | "ripgrep" | "st")
}
fn is_glob_tool(name: &str) -> bool {
matches!(name, "glob" | "find_files" | "file_glob")
}
fn is_write_or_edit_tool(name: &str) -> bool {
matches!(
name,
"write"
| "write_file"
| "edit"
| "edit_file"
| "replace"
| "apply_patch"
| "create_file"
| "update_file"
)
}
fn object_args(value: &Value) -> Option<&Map<String, Value>> {
value.as_object()
}
fn command_arg(args: &Map<String, Value>) -> Option<&str> {
string_arg(args, &["command", "cmd", "shell_command", "input"])
}
fn string_arg<'a>(args: &'a Map<String, Value>, keys: &[&str]) -> Option<&'a str> {
// Only the named keys, in order. Do NOT fall back to an arbitrary string
// value: that would let an unrelated field be misread as the search symbol
// or command for an advisory nudge.
keys.iter()
.find_map(|key| args.get(*key).and_then(Value::as_str))
}
fn shell_grep_symbol(command: &str) -> Option<String> {
let mut parts = command.split_whitespace();
let binary = strip_shell_quotes(parts.next()?)
.rsplit('/')
.next()?
.to_string();
if !matches!(binary.as_str(), "rg" | "ripgrep" | "grep" | "st") {
return None;
}
let mut previous_took_value = false;
for part in parts {
let token = strip_shell_quotes(part);
if previous_took_value {
previous_took_value = false;
continue;
}
if token.starts_with("--") {
previous_took_value = matches!(
token.as_str(),
"--glob" | "--type" | "--context" | "--after-context" | "--before-context"
);
continue;
}
if token.starts_with('-') {
continue;
}
return symbol_from_search_value(&token);
}
None
}
fn symbol_from_search_value(value: &str) -> Option<String> {
let trimmed = strip_shell_quotes(value)
.trim_matches('/')
.replace("\\b", "")
.replace(['^', '$'], "");
for token in trimmed.split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_')) {
if looks_like_symbol_token(token) {
return Some(token.to_string());
}
}
None
}
fn looks_like_symbol_token(token: &str) -> bool {
if token.len() < 3 {
return false;
}
let mut chars = token.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
return false;
}
let lower = token.to_ascii_lowercase();
if matches!(
lower.as_str(),
"todo" | "fixme" | "error" | "warning" | "debug" | "test" | "src" | "main"
) {
return false;
}
token.contains('_') || token.chars().any(|ch| ch.is_ascii_uppercase())
}
fn strip_shell_quotes(value: &str) -> String {
value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
}
fn quiet_command_suggestion(command: &str) -> Option<String> {
if command_has_prefix(command, "git log") && !contains_word(command, "--oneline") {
return Some(insert_after_prefix(command, "git log", "--oneline"));
}
for prefix in ["cargo build", "cargo check", "cargo clippy", "cargo test"] {
if command_has_prefix(command, prefix) && !contains_word(command, "--quiet") {
return Some(insert_after_prefix(command, prefix, "--quiet"));
}
}
if command_has_prefix(command, "pytest") && !contains_word(command, "-q") {
return Some(insert_after_prefix(command, "pytest", "-q"));
}
if command_has_prefix(command, "npm install") && !contains_word(command, "--silent") {
return Some(insert_after_prefix(command, "npm install", "--silent"));
}
if command_has_prefix(command, "pip install") && !contains_word(command, "--quiet") {
return Some(insert_after_prefix(command, "pip install", "--quiet"));
}
if command_has_prefix(command, "docker build") && !contains_word(command, "--progress=quiet") {
return Some(insert_after_prefix(
command,
"docker build",
"--progress=quiet",
));
}
if command_has_prefix(command, "curl") && !contains_word(command, "-s") {
return Some(insert_after_prefix(command, "curl", "-s"));
}
if command_has_prefix(command, "make") && !contains_word(command, "-s") {
return Some(insert_after_prefix(command, "make", "-s"));
}
if command_has_prefix(command, "tree") && !contains_word(command, "-I") {
return Some(insert_after_prefix(
command,
"tree",
"-I \"node_modules|.git|target|dist|build\"",
));
}
None
}
fn command_has_prefix(command: &str, prefix: &str) -> bool {
command == prefix || command.starts_with(&format!("{prefix} "))
}
fn contains_word(command: &str, word: &str) -> bool {
command.split_whitespace().any(|part| part == word)
}
fn insert_after_prefix(command: &str, prefix: &str, insertion: &str) -> String {
let rest = command[prefix.len()..].trim_start();
if rest.is_empty() {
format!("{prefix} {insertion}")
} else {
format!("{prefix} {insertion} {rest}")
}
}
/// Depth cap for `value_payload_bytes`'s recursion. Model-controlled JSON has
/// no natural nesting limit; a real write/edit payload never needs anywhere
/// near this depth, so hitting it is itself a signal the payload is oversized.
const MAX_PAYLOAD_DEPTH: usize = 32;
fn write_payload_bytes(args: &Map<String, Value>) -> usize {
let payload_keys = [
"content",
"text",
"new_content",
"patch",
"diff",
"replacement",
"data",
"old_string",
"new_string",
];
let known = args
.iter()
.filter(|(key, _)| payload_keys.contains(&key.as_str()))
.fold(0usize, |acc, (_, value)| {
acc.saturating_add(value_payload_bytes(value, 0))
});
if known > 0 {
return known;
}
// None of the known field names matched (a tool with a non-standard
// schema, e.g. `body`/`value`) -- fall back to summing every value in
// the payload rather than silently reporting zero and never capping it.
args.values().fold(0usize, |acc, value| {
acc.saturating_add(value_payload_bytes(value, 0))
})
}
fn value_payload_bytes(value: &Value, depth: usize) -> usize {
if depth >= MAX_PAYLOAD_DEPTH {
// Treat implausibly deep nesting as oversized rather than recursing
// further; a fixed large sentinel (not usize::MAX) keeps the
// saturating sum below safe from overflowing back to a small value.
return 1_000_000_000;
}
match value {
Value::String(value) => value.len(),
Value::Array(values) => values.iter().fold(0usize, |acc, v| {
acc.saturating_add(value_payload_bytes(v, depth + 1))
}),
Value::Object(values) => values.values().fold(0usize, |acc, v| {
acc.saturating_add(value_payload_bytes(v, depth + 1))
}),
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tool_spec(name: &str) -> Tool {
Tool {
name: name.to_string(),
description: None,
input_schema: json!({"type": "object"}),
}
}
fn call(name: &str, input: Value) -> ToolCall {
ToolCall {
id: "toolu_1".to_string(),
name: name.to_string(),
input,
}
}
#[test]
fn lsp_nudge_requires_replacement_tool() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
lsp_first: true,
..ToolGuardrailConfig::disabled()
};
let calls = vec![call("grep", json!({"pattern": "UserService"}))];
assert!(
evaluate_tool_guardrails(&calls, &[tool_spec("grep")], &config, &mut state).is_empty()
);
let nudges = evaluate_tool_guardrails(
&calls,
&[tool_spec("grep"), tool_spec("find_definition")],
&config,
&mut state,
);
assert_eq!(nudges.len(), 1);
let nudge = &nudges[0];
assert_eq!(nudge.kind, "lsp_first");
assert_eq!(nudge.call_id, "toolu_1");
assert!(nudge.content.contains("find_definition"));
assert!(nudge.content.contains("UserService"));
}
#[test]
fn quiet_command_nudges_once() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
quiet_commands: true,
..ToolGuardrailConfig::disabled()
};
let calls = vec![call("bash", json!({"command": "cargo test"}))];
let first = evaluate_tool_guardrails(&calls, &[], &config, &mut state);
assert_eq!(first.len(), 1);
assert_eq!(first[0].kind, "quiet_command");
assert!(first[0].content.contains("cargo test --quiet"));
assert!(evaluate_tool_guardrails(&calls, &[], &config, &mut state).is_empty());
}
#[test]
fn only_offending_call_is_nudged() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
lsp_first: true,
..ToolGuardrailConfig::disabled()
};
let calls = vec![
ToolCall {
id: "toolu_grep".into(),
name: "grep".into(),
input: json!({"pattern": "UserService"}),
},
ToolCall {
id: "toolu_write".into(),
name: "write_file".into(),
input: json!({"content": "ok"}),
},
];
let nudges = evaluate_tool_guardrails(
&calls,
&[tool_spec("grep"), tool_spec("find_definition")],
&config,
&mut state,
);
assert_eq!(nudges.len(), 1);
assert_eq!(nudges[0].call_id, "toolu_grep");
}
#[test]
fn write_payload_cap_detects_oversized_payload() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
write_payload_caps: true,
max_write_payload_bytes: 4,
..ToolGuardrailConfig::disabled()
};
let calls = vec![call("write_file", json!({"content": "12345"}))];
let nudges = evaluate_tool_guardrails(&calls, &[], &config, &mut state);
assert_eq!(nudges.len(), 1);
assert_eq!(nudges[0].kind, "write_payload_cap");
assert!(nudges[0].content.contains("5 bytes > 4 bytes"));
}
#[test]
fn write_payload_cap_zero_disables_nudge() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
write_payload_caps: true,
max_write_payload_bytes: 0,
..ToolGuardrailConfig::disabled()
};
let calls = vec![call("write_file", json!({"content": "12345"}))];
assert!(evaluate_tool_guardrails(&calls, &[], &config, &mut state).is_empty());
}
#[test]
fn write_payload_cap_does_not_collide_on_equal_length_different_content() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
write_payload_caps: true,
max_write_payload_bytes: 4,
..ToolGuardrailConfig::disabled()
};
// Same byte length (5), different content/target -- both must nudge.
let first = vec![call("write_file", json!({"content": "aaaaa"}))];
let second = vec![call("write_file", json!({"content": "bbbbb"}))];
assert_eq!(
evaluate_tool_guardrails(&first, &[], &config, &mut state).len(),
1
);
assert_eq!(
evaluate_tool_guardrails(&second, &[], &config, &mut state).len(),
1,
"an unrelated oversized write of equal byte length must still be nudged"
);
}
#[test]
fn write_payload_bytes_falls_back_to_unrecognized_field_names() {
let args = json!({"body": "12345"});
assert_eq!(write_payload_bytes(args.as_object().unwrap()), 5);
}
#[test]
fn resolve_runtime_guardrails_prefers_runtime_override() {
let static_config = ToolGuardrailConfig::disabled();
let resolved = resolve_runtime_guardrails(&static_config, "standard");
assert_eq!(resolved, ToolGuardrailConfig::standard());
}
#[test]
fn resolve_runtime_guardrails_keeps_static_when_modes_match() {
let static_config = ToolGuardrailConfig {
max_write_payload_bytes: 123,
..ToolGuardrailConfig::standard()
};
let resolved = resolve_runtime_guardrails(&static_config, "standard");
// Same mode as the static preset -- the static config (with its
// custom max_write_payload_bytes) must be preserved, not rebuilt
// from the bare preset.
assert_eq!(resolved, static_config);
}
#[test]
fn resolve_runtime_guardrails_falls_back_on_unparseable_value() {
let static_config = ToolGuardrailConfig::standard();
let resolved = resolve_runtime_guardrails(&static_config, "not-a-real-mode");
assert_eq!(resolved, static_config);
}
}
+48
View File
@@ -0,0 +1,48 @@
use super::utils::*;
use super::ToolGuardrailNudge;
use crate::tools::execution::ToolCall;
use anyllm_translate::anthropic::Tool;
pub(super) fn available_lsp_tools(tool_specs: &[Tool]) -> Vec<String> {
let supported = [
"find_definition",
"find_references",
"get_hover",
"document_symbols",
"workspace_symbols",
];
tool_specs
.iter()
.filter(|tool| supported.contains(&tool.name.as_str()))
.map(|tool| tool.name.clone())
.collect()
}
pub(super) fn lsp_first_nudge(call: &ToolCall, lsp_tools: &[String]) -> Option<ToolGuardrailNudge> {
let tool_name = call.name.to_ascii_lowercase();
let args = object_args(&call.input);
let symbol = if is_shell_tool(&tool_name) {
shell_grep_symbol(command_arg(args?)?)?
} else if is_grep_tool(&tool_name) {
string_arg(
args?,
&["symbol", "name", "pattern", "query", "regex", "needle"],
)
.and_then(symbol_from_search_value)?
} else if is_glob_tool(&tool_name) {
string_arg(args?, &["pattern", "query", "glob"]).and_then(symbol_from_search_value)?
} else {
return None;
};
let tools = lsp_tools.join(", ");
let fingerprint = format!("lsp_first:{}:{symbol}", call.name);
Some(ToolGuardrailNudge {
call_id: call.id.clone(),
kind: "lsp_first",
content: format!(
"Use available LSP tools for symbol lookup instead of grep/glob/shell search. Available LSP tools: {tools}. Retry with the best matching LSP tool for `{symbol}`."
),
fingerprint,
})
}
@@ -0,0 +1,79 @@
use super::utils::*;
use super::ToolGuardrailNudge;
use crate::tools::execution::ToolCall;
pub(super) fn quiet_command_nudge(call: &ToolCall) -> Option<ToolGuardrailNudge> {
let tool_name = call.name.to_ascii_lowercase();
if !is_shell_tool(&tool_name) {
return None;
}
let command = command_arg(object_args(&call.input)?)?.trim();
let suggestion = quiet_command_suggestion(command)?;
let fingerprint = format!("quiet:{}:{command}:{suggestion}", call.name);
Some(ToolGuardrailNudge {
call_id: call.id.clone(),
kind: "quiet_command",
content: format!(
"The requested shell command is likely to produce noisy output. Prefer `{suggestion}`. Repeat the original command only if verbose output is required."
),
fingerprint,
})
}
fn quiet_command_suggestion(command: &str) -> Option<String> {
if command_has_prefix(command, "git log") && !contains_word(command, "--oneline") {
return Some(insert_after_prefix(command, "git log", "--oneline"));
}
for prefix in ["cargo build", "cargo check", "cargo clippy", "cargo test"] {
if command_has_prefix(command, prefix) && !contains_word(command, "--quiet") {
return Some(insert_after_prefix(command, prefix, "--quiet"));
}
}
if command_has_prefix(command, "pytest") && !contains_word(command, "-q") {
return Some(insert_after_prefix(command, "pytest", "-q"));
}
if command_has_prefix(command, "npm install") && !contains_word(command, "--silent") {
return Some(insert_after_prefix(command, "npm install", "--silent"));
}
if command_has_prefix(command, "pip install") && !contains_word(command, "--quiet") {
return Some(insert_after_prefix(command, "pip install", "--quiet"));
}
if command_has_prefix(command, "docker build") && !contains_word(command, "--progress=quiet") {
return Some(insert_after_prefix(
command,
"docker build",
"--progress=quiet",
));
}
if command_has_prefix(command, "curl") && !contains_word(command, "-s") {
return Some(insert_after_prefix(command, "curl", "-s"));
}
if command_has_prefix(command, "make") && !contains_word(command, "-s") {
return Some(insert_after_prefix(command, "make", "-s"));
}
if command_has_prefix(command, "tree") && !contains_word(command, "-I") {
return Some(insert_after_prefix(
command,
"tree",
"-I \"node_modules|.git|target|dist|build\"",
));
}
None
}
fn command_has_prefix(command: &str, prefix: &str) -> bool {
command == prefix || command.starts_with(&format!("{prefix} "))
}
fn contains_word(command: &str, word: &str) -> bool {
command.split_whitespace().any(|part| part == word)
}
fn insert_after_prefix(command: &str, prefix: &str, insertion: &str) -> String {
let rest = command[prefix.len()..].trim_start();
if rest.is_empty() {
format!("{prefix} {insertion}")
} else {
format!("{prefix} {insertion} {rest}")
}
}
+171
View File
@@ -0,0 +1,171 @@
use super::*;
use crate::tools::execution::ToolCall;
use anyllm_translate::anthropic::Tool;
use serde_json::{json, Value};
fn tool_spec(name: &str) -> Tool {
Tool {
name: name.to_string(),
description: None,
input_schema: json!({"type": "object"}),
}
}
fn call(name: &str, input: Value) -> ToolCall {
ToolCall {
id: "toolu_1".to_string(),
name: name.to_string(),
input,
}
}
#[test]
fn lsp_nudge_requires_replacement_tool() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
lsp_first: true,
..ToolGuardrailConfig::disabled()
};
let calls = vec![call("grep", json!({"pattern": "UserService"}))];
assert!(evaluate_tool_guardrails(&calls, &[tool_spec("grep")], &config, &mut state).is_empty());
let nudges = evaluate_tool_guardrails(
&calls,
&[tool_spec("grep"), tool_spec("find_definition")],
&config,
&mut state,
);
assert_eq!(nudges.len(), 1);
let nudge = &nudges[0];
assert_eq!(nudge.kind, "lsp_first");
assert_eq!(nudge.call_id, "toolu_1");
assert!(nudge.content.contains("find_definition"));
assert!(nudge.content.contains("UserService"));
}
#[test]
fn quiet_command_nudges_once() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
quiet_commands: true,
..ToolGuardrailConfig::disabled()
};
let calls = vec![call("bash", json!({"command": "cargo test"}))];
let first = evaluate_tool_guardrails(&calls, &[], &config, &mut state);
assert_eq!(first.len(), 1);
assert_eq!(first[0].kind, "quiet_command");
assert!(first[0].content.contains("cargo test --quiet"));
assert!(evaluate_tool_guardrails(&calls, &[], &config, &mut state).is_empty());
}
#[test]
fn only_offending_call_is_nudged() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
lsp_first: true,
..ToolGuardrailConfig::disabled()
};
let calls = vec![
ToolCall {
id: "toolu_grep".into(),
name: "grep".into(),
input: json!({"pattern": "UserService"}),
},
ToolCall {
id: "toolu_write".into(),
name: "write_file".into(),
input: json!({"content": "ok"}),
},
];
let nudges = evaluate_tool_guardrails(
&calls,
&[tool_spec("grep"), tool_spec("find_definition")],
&config,
&mut state,
);
assert_eq!(nudges.len(), 1);
assert_eq!(nudges[0].call_id, "toolu_grep");
}
#[test]
fn write_payload_cap_detects_oversized_payload() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
write_payload_caps: true,
max_write_payload_bytes: 4,
..ToolGuardrailConfig::disabled()
};
let calls = vec![call("write_file", json!({"content": "12345"}))];
let nudges = evaluate_tool_guardrails(&calls, &[], &config, &mut state);
assert_eq!(nudges.len(), 1);
assert_eq!(nudges[0].kind, "write_payload_cap");
assert!(nudges[0].content.contains("5 bytes > 4 bytes"));
}
#[test]
fn write_payload_cap_zero_disables_nudge() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
write_payload_caps: true,
max_write_payload_bytes: 0,
..ToolGuardrailConfig::disabled()
};
let calls = vec![call("write_file", json!({"content": "12345"}))];
assert!(evaluate_tool_guardrails(&calls, &[], &config, &mut state).is_empty());
}
#[test]
fn write_payload_cap_does_not_collide_on_equal_length_different_content() {
let mut state = ToolGuardrailRequestState::new();
let config = ToolGuardrailConfig {
write_payload_caps: true,
max_write_payload_bytes: 4,
..ToolGuardrailConfig::disabled()
};
// Same byte length (5), different content/target -- both must nudge.
let first = vec![call("write_file", json!({"content": "aaaaa"}))];
let second = vec![call("write_file", json!({"content": "bbbbb"}))];
assert_eq!(
evaluate_tool_guardrails(&first, &[], &config, &mut state).len(),
1
);
assert_eq!(
evaluate_tool_guardrails(&second, &[], &config, &mut state).len(),
1,
"an unrelated oversized write of equal byte length must still be nudged"
);
}
#[test]
fn write_payload_bytes_falls_back_to_unrecognized_field_names() {
let args = json!({"body": "12345"});
assert_eq!(write_cap::write_payload_bytes(args.as_object().unwrap()), 5);
}
#[test]
fn resolve_runtime_guardrails_prefers_runtime_override() {
let static_config = ToolGuardrailConfig::disabled();
let resolved = resolve_runtime_guardrails(&static_config, "standard");
assert_eq!(resolved, ToolGuardrailConfig::standard());
}
#[test]
fn resolve_runtime_guardrails_keeps_static_when_modes_match() {
let static_config = ToolGuardrailConfig {
max_write_payload_bytes: 123,
..ToolGuardrailConfig::standard()
};
let resolved = resolve_runtime_guardrails(&static_config, "standard");
// Same mode as the static preset -- the static config (with its
// custom max_write_payload_bytes) must be preserved, not rebuilt
// from the bare preset.
assert_eq!(resolved, static_config);
}
#[test]
fn resolve_runtime_guardrails_falls_back_on_unparseable_value() {
let static_config = ToolGuardrailConfig::standard();
let resolved = resolve_runtime_guardrails(&static_config, "not-a-real-mode");
assert_eq!(resolved, static_config);
}
+124
View File
@@ -0,0 +1,124 @@
use serde_json::{Map, Value};
pub(super) fn is_shell_tool(name: &str) -> bool {
matches!(
name,
"bash" | "shell" | "run_command" | "execute_command" | "terminal" | "exec" | "execute_bash"
)
}
pub(super) fn is_grep_tool(name: &str) -> bool {
matches!(name, "grep" | "rg" | "ripgrep" | "st")
}
pub(super) fn is_glob_tool(name: &str) -> bool {
matches!(name, "glob" | "find_files" | "file_glob")
}
pub(super) fn is_write_or_edit_tool(name: &str) -> bool {
matches!(
name,
"write"
| "write_file"
| "edit"
| "edit_file"
| "replace"
| "apply_patch"
| "create_file"
| "update_file"
)
}
pub(super) fn object_args(value: &Value) -> Option<&Map<String, Value>> {
value.as_object()
}
pub(super) fn command_arg(args: &Map<String, Value>) -> Option<&str> {
string_arg(args, &["command", "cmd", "shell_command", "input"])
}
pub(super) fn string_arg<'a>(args: &'a Map<String, Value>, keys: &[&str]) -> Option<&'a str> {
// Only the named keys, in order. Do NOT fall back to an arbitrary string
// value: that would let an unrelated field be misread as the search symbol
// or command for an advisory nudge.
keys.iter()
.find_map(|key| args.get(*key).and_then(Value::as_str))
}
pub(super) fn shell_grep_symbol(command: &str) -> Option<String> {
let mut parts = command.split_whitespace();
let binary = strip_shell_quotes(parts.next()?)
.rsplit('/')
.next()?
.to_string();
if !matches!(binary.as_str(), "rg" | "ripgrep" | "grep" | "st") {
return None;
}
let mut previous_took_value = false;
for part in parts {
let token = strip_shell_quotes(part);
if previous_took_value {
previous_took_value = false;
continue;
}
if token.starts_with("--") {
previous_took_value = matches!(
token.as_str(),
"--glob" | "--type" | "--context" | "--after-context" | "--before-context"
);
continue;
}
if token.starts_with('-') {
continue;
}
return symbol_from_search_value(&token);
}
None
}
pub(super) fn symbol_from_search_value(value: &str) -> Option<String> {
let trimmed = strip_shell_quotes(value)
.trim_matches('/')
.replace("\\b", "")
.replace(['^', '$'], "");
for token in trimmed.split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_')) {
if looks_like_symbol_token(token) {
return Some(token.to_string());
}
}
None
}
fn looks_like_symbol_token(token: &str) -> bool {
if token.len() < 3 {
return false;
}
let mut chars = token.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
return false;
}
let lower = token.to_ascii_lowercase();
if matches!(
lower.as_str(),
"todo" | "fixme" | "error" | "warning" | "debug" | "test" | "src" | "main"
) {
return false;
}
token.contains('_') || token.chars().any(|ch| ch.is_ascii_uppercase())
}
pub(super) fn strip_shell_quotes(value: &str) -> String {
value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
}
@@ -0,0 +1,101 @@
use super::utils::*;
use super::ToolGuardrailNudge;
use crate::tools::execution::ToolCall;
use serde_json::{Map, Value};
/// Depth cap for `value_payload_bytes`'s recursion. Model-controlled JSON has
/// no natural nesting limit; a real write/edit payload never needs anywhere
/// near this depth, so hitting it is itself a signal the payload is oversized.
const MAX_PAYLOAD_DEPTH: usize = 32;
pub(super) fn write_payload_cap_nudge(
call: &ToolCall,
max_bytes: usize,
) -> Option<ToolGuardrailNudge> {
// A cap of 0 disables the nudge rather than firing on every non-empty write.
if max_bytes == 0 {
return None;
}
let tool_name = call.name.to_ascii_lowercase();
if !is_write_or_edit_tool(&tool_name) {
return None;
}
let bytes = object_args(&call.input)
.map(write_payload_bytes)
.unwrap_or(0);
if bytes <= max_bytes {
return None;
}
Some(ToolGuardrailNudge {
call_id: call.id.clone(),
kind: "write_payload_cap",
content: format!(
"The requested write/edit payload is too large for this proxy policy ({bytes} bytes > {max_bytes} bytes). Retry with a smaller targeted edit or split the change."
),
// Include a hash of the actual payload, not just its byte length, so
// two unrelated oversized writes that happen to be the same size
// (e.g. same-size templated files) don't collide and suppress each
// other's nudge. Identical repeated calls (same target, same
// content) still dedupe as intended.
fingerprint: format!(
"write_payload_cap:{}:{:x}:{bytes}:{max_bytes}",
call.name,
payload_content_hash(&call.input)
),
})
}
fn payload_content_hash(value: &Value) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.to_string().hash(&mut hasher);
hasher.finish()
}
pub(super) fn write_payload_bytes(args: &Map<String, Value>) -> usize {
let payload_keys = [
"content",
"text",
"new_content",
"patch",
"diff",
"replacement",
"data",
"old_string",
"new_string",
];
let known = args
.iter()
.filter(|(key, _)| payload_keys.contains(&key.as_str()))
.fold(0usize, |acc, (_, value)| {
acc.saturating_add(value_payload_bytes(value, 0))
});
if known > 0 {
return known;
}
// None of the known field names matched (a tool with a non-standard
// schema, e.g. `body`/`value`) -- fall back to summing every value in
// the payload rather than silently reporting zero and never capping it.
args.values().fold(0usize, |acc, value| {
acc.saturating_add(value_payload_bytes(value, 0))
})
}
fn value_payload_bytes(value: &Value, depth: usize) -> usize {
if depth >= MAX_PAYLOAD_DEPTH {
// Treat implausibly deep nesting as oversized rather than recursing
// further; a fixed large sentinel (not usize::MAX) keeps the
// saturating sum below safe from overflowing back to a small value.
return 1_000_000_000;
}
match value {
Value::String(value) => value.len(),
Value::Array(values) => values.iter().fold(0usize, |acc, v| {
acc.saturating_add(value_payload_bytes(v, depth + 1))
}),
Value::Object(values) => values.values().fold(0usize, |acc, v| {
acc.saturating_add(value_payload_bytes(v, depth + 1))
}),
_ => 0,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,340 @@
use crate::render::{render_text, RenderOpts};
use crate::transform::factsheet;
use crate::transform::gate;
use base64::Engine;
use serde_json::{json, Map, Value};
#[derive(Clone, Copy, Debug)]
pub struct AnthropicOpts {
pub cols: usize,
pub max_height_px: usize,
pub chars_per_token: f64,
/// Skip entirely below this many slab chars (per-image cost dominates).
pub min_compress_chars: usize,
/// Move tool descriptions/schema annotations into the imaged Tool Reference.
pub compress_tools: bool,
/// Image large `<system-reminder>` text blocks in the first user message.
pub compress_reminders: bool,
/// Image large `tool_result` text content across all user messages.
pub compress_tool_results: bool,
/// Per-block char floor for reminder/tool_result imaging.
pub min_live_block_chars: usize,
/// Cap on images per tool_result; source is truncated (with a paging marker)
/// above this so one giant result can't blow Anthropic's 100-image/request cap.
pub max_images_per_tool_result: usize,
/// Aggregate ceiling on TOTAL image blocks in the outgoing request
/// (client-supplied + every pxpipe pass combined). Anthropic rejects requests
/// past ~100 images, so imaging stops once this budget is exhausted and the
/// remaining regions pass through as text. The per-tool_result cap only bounds
/// one result; this bounds the sum.
pub max_total_images: usize,
/// Collapse the OLD closed-tool-call conversation prefix into history image(s),
/// keeping the recent tail as text. Default OFF — highest cache-stability risk
/// (see `apply_history`); gate on live validation before enabling by default.
pub compress_history: bool,
/// Trailing messages kept as live text (never collapsed).
pub keep_tail_messages: usize,
/// Minimum collapsible messages; below this the cache-amortization math doesn't pay.
pub min_collapse_prefix_messages: usize,
/// Snap the collapse boundary to this message-grid so the rendered history PNG
/// stays byte-identical across turns and keeps hitting the prompt cache.
pub history_collapse_chunk: usize,
}
impl Default for AnthropicOpts {
fn default() -> Self {
Self {
cols: crate::render::RenderOpts::default().cols,
max_height_px: crate::render::RenderOpts::default().max_height_px,
// Slab is dense; 2.0 is conservative vs the real ~1.9 (see pxpipe
// SLAB_CHARS_PER_TOKEN) so the gate biases toward pass-through.
chars_per_token: 2.0,
min_compress_chars: 6_000,
compress_tools: true,
compress_reminders: true,
compress_tool_results: true,
min_live_block_chars: 2_000,
max_images_per_tool_result: 10,
// Leave headroom under Anthropic's 100-image/request cap for a few
// client-supplied images the counter may miss on unusual shapes.
max_total_images: 95,
compress_history: false,
keep_tail_messages: 8,
min_collapse_prefix_messages: 20,
history_collapse_chunk: 20,
}
}
}
/// Neutral framing header co-rendered into the slab image. Deliberately avoids
/// "system prompt"/"authoritative" wording — pxpipe found that phrasing trips
/// Anthropic's reasoning_extraction refusal (reads as a replayed/extracted
/// prompt). First-party, matter-of-fact framing keeps the model reading it as
/// this session's own reference material.
pub(crate) const SLAB_HEADER: &str =
"Reference context for this session, rendered as an image. Read it as text.\n\n";
/// Chars-per-token for the live-region gate (reminders, tool_results). Higher
/// than the slab's 2.0 because that content is prose/log, not dense config —
/// pxpipe uses 4 here, which is conservative (biases toward pass-through).
pub(crate) const LIVE_CHARS_PER_TOKEN: f64 = 4.0;
/// Banner text bracketing the collapsed-history image(s). Constant (byte-stable).
pub(crate) const HISTORY_INTRO: &str = "Transcript of EARLIER conversation turns, rendered as images below. \
Attribute each turn strictly by its <user>/<assistant> tag. This is PAST context, not the live request.\n";
pub(crate) const HISTORY_OUTRO: &str =
"\n[End of earlier transcript. The live request follows in the messages below.]";
/// Concat the text of a system field (string or block array) and return
/// `(text, last_cache_control)`. The cache_control is the caller's prefix
/// breakpoint we will relocate onto the image.
pub(crate) fn read_system(system: Option<&Value>) -> (String, Option<Value>) {
match system {
Some(Value::String(s)) => (s.clone(), None),
Some(Value::Array(blocks)) => {
let mut text = String::new();
let mut cc = None;
for b in blocks {
if let Some(t) = b.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
text.push('\n');
}
text.push_str(t);
}
if let Some(c) = b.get("cache_control") {
cc = Some(c.clone()); // last one wins (latest in prefix)
}
}
(text, cc)
}
_ => (String::new(), None),
}
}
/// Render one tool's full doc (prose + compact schema) for the imaged reference.
pub(crate) fn render_tool_doc(tool: &Value) -> String {
let name = tool.get("name").and_then(|n| n.as_str()).unwrap_or("");
let desc = tool
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("");
let schema = tool
.get("input_schema")
.map(|s| serde_json::to_string(s).unwrap_or_default())
.unwrap_or_default();
format!("## Tool: {name}\n{desc}\n```json\n{schema}\n```\n")
}
/// Build a base64 image content block from PNG bytes.
pub(crate) fn image_block(png: &[u8]) -> Value {
let data = base64::engine::general_purpose::STANDARD.encode(png);
json!({
"type": "image",
"source": { "type": "base64", "media_type": "image/png", "data": data }
})
}
/// Count image blocks already present in the request (client-supplied, incl.
/// images nested inside `tool_result` content), so the added-image budget is
/// measured against the true outgoing total under Anthropic's ~100-image cap.
pub(crate) fn count_existing_images(root: &Value) -> usize {
let Some(msgs) = root.get("messages").and_then(|m| m.as_array()) else {
return 0;
};
let is_image = |b: &Value| b.get("type").and_then(|t| t.as_str()) == Some("image");
let mut n = 0;
for m in msgs {
let Some(blocks) = m.get("content").and_then(|c| c.as_array()) else {
continue;
};
for b in blocks {
match b.get("type").and_then(|t| t.as_str()) {
Some("image") => n += 1,
Some("tool_result") => {
if let Some(inner) = b.get("content").and_then(|c| c.as_array()) {
n += inner.iter().filter(|x| is_image(x)).count();
}
}
_ => {}
}
}
}
n
}
/// Prepend `images` to the first user message's content. Converts string
/// content to a text block first; inserts a fresh user message if none exists.
pub(crate) fn prepend_images_to_first_user(obj: &mut Map<String, Value>, images: &mut Vec<Value>) {
let msgs = obj
.entry("messages")
.or_insert_with(|| Value::Array(vec![]));
let Some(arr) = msgs.as_array_mut() else {
return;
};
let first_user = arr
.iter()
.position(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"));
match first_user {
Some(idx) => {
let content = arr[idx].as_object_mut().and_then(|m| m.get_mut("content"));
match content {
Some(Value::String(s)) => {
let text = std::mem::take(s);
let mut blocks = vec![json!({ "type": "text", "text": text })];
let mut new = std::mem::take(images);
new.append(&mut blocks);
arr[idx]
.as_object_mut()
.unwrap()
.insert("content".into(), Value::Array(new));
}
Some(Value::Array(existing)) => {
let mut new = std::mem::take(images);
new.append(existing);
*existing = new;
}
_ => {
arr[idx]
.as_object_mut()
.unwrap()
.insert("content".into(), Value::Array(std::mem::take(images)));
}
}
}
None => {
arr.insert(
0,
json!({ "role": "user", "content": std::mem::take(images) }),
);
}
}
}
/// Rendered-live-block accumulators, folded into `TransformInfo` by the caller.
pub(crate) struct LiveRender {
pub(crate) blocks: Vec<Value>,
pub(crate) bytes: usize,
pub(crate) pixels: usize,
pub(crate) dropped: usize,
pub(crate) img_count: usize,
}
/// Render `text` to image blocks for a live region (reminder / tool_result),
/// gated on profitability AND the remaining `budget` of new images. Moves `cc`
/// (the block's cache_control, if any) onto the last image and optionally
/// appends a verbatim fact-sheet text block. Returns `None` (leave the block as
/// text) when empty, not profitable, or over budget.
pub(crate) fn render_live_block(
text: &str,
opts: &AnthropicOpts,
cc: Option<Value>,
append_factsheet: bool,
budget: usize,
) -> Option<LiveRender> {
let images = render_text(
text,
RenderOpts {
cols: opts.cols,
max_height_px: opts.max_height_px,
},
);
let chars = text.chars().count();
if images.len() > budget {
return None;
}
if !gate::is_profitable(&images, chars, LIVE_CHARS_PER_TOKEN) {
return None;
}
let mut blocks: Vec<Value> = images.iter().map(|im| image_block(&im.png)).collect();
if let (Some(cc), Some(last)) = (cc, blocks.last_mut()) {
if let Some(o) = last.as_object_mut() {
o.insert("cache_control".into(), cc);
}
}
if append_factsheet {
let fact = factsheet::fact_sheet_text(text);
if !fact.is_empty() {
blocks.push(json!({ "type": "text", "text": fact }));
}
}
Some(LiveRender {
blocks,
bytes: images.iter().map(|im| im.png.len()).sum(),
pixels: images
.iter()
.map(|im| im.width as usize * im.height as usize)
.sum(),
dropped: images.iter().map(|im| im.dropped).sum(),
img_count: images.len(),
})
}
/// Flatten a tool_result `content` (string, or an array of text/other blocks)
/// to a single text string for rendering.
pub(crate) fn tool_result_text(content: Option<&Value>) -> String {
match content {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(blocks)) => {
let mut out = String::new();
for b in blocks {
if let Some(t) = b.get("text").and_then(|t| t.as_str()) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(t);
}
}
out
}
_ => String::new(),
}
}
/// True when a tool_result `content` array already holds an image block. Imaging
/// such a result would drop that image (only text sub-blocks are flattened), so
/// the caller skips it.
pub(crate) fn tool_result_has_image(content: Option<&Value>) -> bool {
matches!(content, Some(Value::Array(blocks))
if blocks
.iter()
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("image")))
}
/// Truncate `text` so it renders to at most `max_images` pages, keeping a 60/40
/// head/tail split with a paging marker in the middle. Returns the (possibly
/// truncated) text and the count of chars elided. Renders once to measure — the
/// oversized path is rare, so the extra render is acceptable.
pub(crate) fn truncate_for_budget(
text: &str,
max_images: usize,
opts: &AnthropicOpts,
) -> (String, usize) {
let render_opts = RenderOpts {
cols: opts.cols,
max_height_px: opts.max_height_px,
};
let full = render_text(text, render_opts);
let cap = max_images.max(1);
if full.len() <= cap {
return (text.to_string(), 0);
}
let chars: Vec<char> = text.chars().collect();
// Start from the linear estimate, then shrink and RE-RENDER until it truly
// fits `cap` pages — the paging marker and heavy line-wrapping can push a
// linear estimate over, so the cap must be verified, not assumed.
let mut ratio = cap as f64 / full.len() as f64;
loop {
let keep = (((chars.len() as f64) * ratio) as usize).min(chars.len());
let head_len = keep * 6 / 10;
let tail_len = keep - head_len;
let head: String = chars[..head_len].iter().collect();
let tail: String = chars[chars.len() - tail_len..].iter().collect();
let omitted = chars.len() - head_len - tail_len;
let out = format!("{head}\n\n[... {omitted} chars omitted for length ...]\n\n{tail}");
if render_text(&out, render_opts).len() <= cap || ratio < 0.05 {
return (out, omitted);
}
ratio *= 0.8;
}
}
@@ -0,0 +1,199 @@
use super::common::{image_block, tool_result_text, AnthropicOpts, HISTORY_INTRO, HISTORY_OUTRO};
use crate::render::{render_text, RenderOpts};
use crate::transform::gate;
use crate::transform::info::TransformInfo;
use serde_json::{json, Value};
/// Collapse the OLD closed-tool-call message prefix into ONE synthetic user
/// message holding history image(s); keep the recent tail as text.
///
/// **Cache stability** is the whole risk here. Two guarantees keep the rendered
/// PNG byte-identical across turns so Anthropic prompt-caches it instead of
/// re-creating it every turn: (1) the collapse boundary is snapped DOWN to a
/// `history_collapse_chunk` message grid, so it only advances in steps and the
/// serialized text is stable for a whole window; (2) the serializer and renderer
/// are pure functions of the message bytes (no timestamps/rng, thinking blocks
/// dropped deterministically). If either breaks, this NET-LOSES money — hence
/// default-off until validated live.
///
/// **Correctness**: only a tool-CLOSED prefix is collapsed (every `tool_use` has
/// its matching `tool_result` within the range), so no tool call is ever
/// orphaned. The first user message (which carries the slab images) is protected.
///
/// NOTE: the synthetic message is role `user`, which can place it adjacent to the
/// protected first user message. The Anthropic Messages API accepts consecutive
/// same-role messages (pxpipe relies on this in production); images require the
/// user role, so this is unavoidable for a history-image message.
pub(crate) fn apply_history(
root: &mut Value,
opts: &AnthropicOpts,
info: &mut TransformInfo,
budget: &mut usize,
) {
let Some(messages) = root.get("messages").and_then(|m| m.as_array()) else {
return;
};
let len = messages.len();
// Protect the slab-bearing first user message: collapse starts after it.
let Some(first_user) = messages
.iter()
.position(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
else {
return;
};
let protected = first_user + 1;
let cutoff = len.saturating_sub(opts.keep_tail_messages);
if cutoff <= protected {
return;
}
let Some(boundary) = find_closed_boundary(messages, cutoff, protected) else {
return;
};
// Snap DOWN to the chunk grid (relative to `protected`) for byte-stability.
let chunk = opts.history_collapse_chunk.max(1);
let grid = protected + ((boundary - protected) / chunk) * chunk;
// The grid line is NOT guaranteed tool-closed (parity shifts from text-only
// turns or parallel tool spans can leave it mid-open-span). Re-snap to the
// largest CLOSED boundary <= the grid line so no tool_use is orphaned into
// the history image; correctness beats the grid's cache-stability here.
let Some(snapped) = find_closed_boundary(messages, grid, protected) else {
return;
};
if snapped.saturating_sub(protected) < opts.min_collapse_prefix_messages {
return;
}
let text = messages_to_history_text(messages, protected, snapped);
if text.trim().is_empty() {
return;
}
let images = render_text(
&text,
RenderOpts {
cols: opts.cols,
max_height_px: opts.max_height_px,
},
);
if !gate::is_profitable(&images, text.chars().count(), opts.chars_per_token) {
return;
}
if images.len() > *budget {
return;
}
*budget -= images.len();
// Build the synthetic message content: intro, images, outro.
let mut content: Vec<Value> = Vec::with_capacity(images.len() + 2);
content.push(json!({ "type": "text", "text": HISTORY_INTRO }));
content.extend(images.iter().map(|im| image_block(&im.png)));
content.push(json!({ "type": "text", "text": HISTORY_OUTRO }));
let synthetic = json!({ "role": "user", "content": content });
let collapsed_turns = snapped - protected;
info.collapsed_turns = collapsed_turns;
info.collapsed_chars = text.len();
info.collapsed_images = images.len();
info.image_count += images.len();
info.image_bytes += images.iter().map(|im| im.png.len()).sum::<usize>();
info.image_pixels += images
.iter()
.map(|im| im.width as usize * im.height as usize)
.sum::<usize>();
info.dropped_chars += images.iter().map(|im| im.dropped).sum::<usize>();
info.compressed_chars += text.len();
// Splice: [0..protected] + synthetic + [snapped..].
let arr = root
.get_mut("messages")
.and_then(|m| m.as_array_mut())
.expect("messages was an array above");
arr.splice(protected..snapped, std::iter::once(synthetic));
}
/// Largest exclusive end `e` in `(from, cutoff]` where messages `[from..e)` open
/// no tool call they don't also close. Returns `None` if none exists. Robust to
/// interleaved/parallel tool calls via the open-id set.
fn find_closed_boundary(messages: &[Value], cutoff: usize, from: usize) -> Option<usize> {
let mut open: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut last_closed = None;
for (i, m) in messages.iter().enumerate().take(cutoff).skip(from) {
if let Some(blocks) = m.get("content").and_then(|c| c.as_array()) {
for b in blocks {
match b.get("type").and_then(|t| t.as_str()) {
Some("tool_use") => {
if let Some(id) = b.get("id").and_then(|i| i.as_str()) {
open.insert(id.to_string());
}
}
Some("tool_result") => {
if let Some(id) = b.get("tool_use_id").and_then(|i| i.as_str()) {
open.remove(id);
}
}
_ => {}
}
}
}
if open.is_empty() {
last_closed = Some(i + 1);
}
}
last_closed
}
/// Serialize messages `[from..to)` to `<role>…</role>` XML text. thinking blocks
/// dropped; tool_use/tool_result flattened; inline images become `[image]`.
fn messages_to_history_text(messages: &[Value], from: usize, to: usize) -> String {
let mut out = String::new();
for m in &messages[from..to] {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let body = flatten_content(m.get("content"));
out.push('<');
out.push_str(role);
out.push_str(">\n");
out.push_str(&body);
out.push_str("\n</");
out.push_str(role);
out.push_str(">\n");
}
out
}
/// Flatten one message's content to text: text verbatim, tool_use/tool_result to
/// a compact marker, thinking dropped, inline images to `[image]`.
fn flatten_content(content: Option<&Value>) -> String {
match content {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(blocks)) => {
let mut parts: Vec<String> = Vec::new();
for b in blocks {
match b.get("type").and_then(|t| t.as_str()) {
Some("text") => {
if let Some(t) = b.get("text").and_then(|t| t.as_str()) {
parts.push(t.to_string());
}
}
Some("tool_use") => {
let name = b.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = b
.get("input")
.map(|v| serde_json::to_string(v).unwrap_or_default())
.unwrap_or_default();
parts.push(format!("[tool_use {name} {input}]"));
}
Some("tool_result") => {
parts.push(format!(
"[tool_result {}]",
tool_result_text(b.get("content"))
));
}
Some("image") => parts.push("[image]".to_string()),
// thinking / redacted_thinking / unknown: dropped.
_ => {}
}
}
parts.join("\n")
}
_ => String::new(),
}
}
@@ -0,0 +1,53 @@
pub mod common;
pub mod history;
pub mod reminders;
pub mod slab;
pub mod tool_results;
#[cfg(test)]
mod tests;
use crate::transform::info::TransformInfo;
use serde_json::Value;
pub use common::AnthropicOpts;
/// Transform `root` (a parsed Anthropic Messages body) in place. Returns info;
/// on any full skip `root` is left byte-identical to the input. Three
/// independent passes — static slab, `<system-reminder>` blocks, and
/// `tool_result` content — so a request below the slab floor can still get its
/// live regions imaged (and vice-versa).
pub fn transform(root: &mut Value, opts: &AnthropicOpts) -> TransformInfo {
if !root.is_object() {
return TransformInfo::skipped("parse_error");
}
let mut info = TransformInfo::default();
// NEW-image budget: total ceiling minus images the client already sent, so
// the combined passes never push the request past Anthropic's ~100-image cap.
let mut budget = opts
.max_total_images
.saturating_sub(common::count_existing_images(root));
let slab_reason = slab::apply_slab(root, opts, &mut info, &mut budget);
// History runs BEFORE the live-region passes: it serializes the OLD message
// prefix to text, so tool_result imaging must not have already replaced that
// content with `[image]` placeholders. Reminders/tool_results then image only
// what survives (the protected first message + the live tail).
if opts.compress_history {
history::apply_history(root, opts, &mut info, &mut budget);
}
if opts.compress_reminders {
reminders::apply_reminders(root, opts, &mut info, &mut budget);
}
if opts.compress_tool_results {
tool_results::apply_tool_results(root, opts, &mut info, &mut budget);
}
info.compressed = info.image_count > 0;
// "applied" when anything imaged; otherwise report why the slab (the primary
// path) declined.
info.reason = if info.compressed {
"applied"
} else {
slab_reason
};
info
}
@@ -0,0 +1,58 @@
use super::common::{render_live_block, AnthropicOpts};
use crate::transform::info::TransformInfo;
use serde_json::Value;
/// Image large `<system-reminder>` text blocks in the first user message. These
/// are per-turn injected context (env, hints) that Claude Code ships as separate
/// text blocks; the big ones are pure token cost the model rarely needs to quote.
pub(crate) fn apply_reminders(
root: &mut Value,
opts: &AnthropicOpts,
info: &mut TransformInfo,
budget: &mut usize,
) {
let Some(arr) = root.get_mut("messages").and_then(|m| m.as_array_mut()) else {
return;
};
let Some(msg) = arr
.iter_mut()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
else {
return;
};
let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) else {
return; // string content: reminders arrive only as array blocks
};
let mut out: Vec<Value> = Vec::with_capacity(content.len());
for block in std::mem::take(content) {
let is_reminder_text = block.get("type").and_then(|t| t.as_str()) == Some("text")
&& block.get("text").and_then(|t| t.as_str()).is_some_and(|t| {
t.contains("<system-reminder>") && t.len() >= opts.min_live_block_chars
});
if !is_reminder_text {
out.push(block);
continue;
}
let text = block
.get("text")
.and_then(|t| t.as_str())
.unwrap_or_default()
.to_string();
let cc = block.get("cache_control").cloned();
match render_live_block(&text, opts, cc, false, *budget) {
Some(r) => {
*budget -= r.img_count;
info.reminder_imgs += r.img_count;
info.image_count += r.img_count;
info.image_bytes += r.bytes;
info.image_pixels += r.pixels;
info.dropped_chars += r.dropped;
info.compressed_chars += text.len();
out.extend(r.blocks);
}
None => out.push(block),
}
}
*content = out;
}
@@ -0,0 +1,151 @@
use super::common::{
image_block, prepend_images_to_first_user, read_system, render_tool_doc, AnthropicOpts,
SLAB_HEADER,
};
use crate::render::{render_text, RenderOpts};
use crate::transform::factsheet;
use crate::transform::gate;
use crate::transform::info::TransformInfo;
use crate::transform::schema_strip;
use serde_json::Value;
/// Static system+tools slab pass. Returns the skip reason (or "applied"); only
/// mutates `root` / fills `info` when profitable.
pub(crate) fn apply_slab(
root: &mut Value,
opts: &AnthropicOpts,
info: &mut TransformInfo,
budget: &mut usize,
) -> &'static str {
let (system_text, system_cc) = read_system(root.get("system"));
// Capture a tool cache_control as a fallback anchor (tools sit before system
// in the cache prefix, so system's wins when both exist).
let tools_present = opts.compress_tools
&& root
.get("tools")
.and_then(|t| t.as_array())
.is_some_and(|a| !a.is_empty());
let mut tool_cc = None;
let mut tool_ref = String::new();
if tools_present {
if let Some(arr) = root.get("tools").and_then(|t| t.as_array()) {
for t in arr {
tool_ref.push_str(&render_tool_doc(t));
if let Some(c) = t.get("cache_control") {
tool_cc = Some(c.clone());
}
}
}
}
// Assemble the slab: header + system + tool reference.
let mut slab = String::with_capacity(system_text.len() + tool_ref.len() + 64);
slab.push_str(SLAB_HEADER);
slab.push_str(&system_text);
if !tool_ref.is_empty() {
slab.push_str("\n\n# Tool Reference\n");
slab.push_str(&tool_ref);
}
let slab_chars = slab.chars().count();
// Nothing meaningful to image (no system text and no tools).
if system_text.trim().is_empty() && tool_ref.is_empty() {
return "no_slab";
}
if slab_chars < opts.min_compress_chars {
return "below_min_chars";
}
let images = render_text(
&slab,
RenderOpts {
cols: opts.cols,
max_height_px: opts.max_height_px,
},
);
if !gate::is_profitable(&images, slab_chars, opts.chars_per_token) {
return "not_profitable";
}
if images.len() > *budget {
return "image_budget";
}
// ---- commit: mutate root ------------------------------------------------
let anchor = system_cc.or(tool_cc); // relocate this onto the last image
let fact = factsheet::fact_sheet_text(&slab);
let mut image_blocks: Vec<Value> = images.iter().map(|im| image_block(&im.png)).collect();
let image_bytes: usize = images.iter().map(|im| im.png.len()).sum();
let image_pixels: usize = images
.iter()
.map(|im| im.width as usize * im.height as usize)
.sum();
let dropped: usize = images.iter().map(|im| im.dropped).sum();
// Relocate the caller's cache breakpoint onto the LAST image so the whole
// imaged prefix caches as one stable segment. pxpipe never *adds* a marker.
let relocated = if let (Some(cc), Some(last)) = (anchor, image_blocks.last_mut()) {
if let Some(obj) = last.as_object_mut() {
obj.insert("cache_control".into(), cc);
}
true
} else {
false
};
let obj = root.as_object_mut().expect("checked is_object above");
// 1. system -> short pointer (+ factsheet). Removes original cache_control
// (relocated onto the image) since we're replacing the whole field.
let mut pointer = String::from(
"[Your reference context for this session is provided as an image in the first user message. Read it there.]",
);
if !fact.is_empty() {
pointer.push('\n');
pointer.push_str(&fact);
}
obj.insert("system".into(), Value::String(pointer));
// 2. tools -> stub description + annotation-stripped schema, drop the
// relocated cache_control. Keep name + structural schema so Anthropic's
// tool-use validator still accepts calls.
if tools_present {
if let Some(arr) = obj.get_mut("tools").and_then(|t| t.as_array_mut()) {
for t in arr.iter_mut() {
let Some(tm) = t.as_object_mut() else {
continue;
};
let name = tm
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
tm.insert(
"description".into(),
Value::String(format!("See \"## Tool: {name}\" in the reference image.")),
);
if let Some(schema) = tm.get("input_schema") {
let stripped = schema_strip::strip(schema);
if schema_strip::has_structure(&stripped) {
tm.insert("input_schema".into(), stripped);
}
// else keep original: a bare {type:object} stub causes 400s.
}
tm.remove("cache_control");
}
}
}
// 3. Prepend image blocks to the first user message (system rejects images).
prepend_images_to_first_user(obj, &mut image_blocks);
info.compressed_chars += slab.len();
info.image_count += images.len();
info.image_bytes += image_bytes;
info.image_pixels += image_pixels;
info.dropped_chars += dropped;
info.relocated_cache_anchor = relocated;
*budget -= images.len();
"applied"
}
@@ -0,0 +1,409 @@
use super::common::AnthropicOpts;
use super::transform;
use serde_json::json;
fn big_system() -> String {
// ~14k chars with some notable identifiers for the factsheet.
let mut s = String::from("Operating rules. See src/main.rs and CONFIG_PATH env.\n");
s.push_str(&"Do the thing carefully and precisely. ".repeat(320));
s.push_str(" commit deadbeef1 flag --verbose");
s
}
#[test]
fn images_system_and_relocates_anchor() {
let mut root = json!({
"model": "claude-fable-5",
"system": [
{ "type": "text", "text": big_system(), "cache_control": { "type": "ephemeral" } }
],
"messages": [ { "role": "user", "content": "hi there" } ]
});
let info = transform(&mut root, &AnthropicOpts::default());
assert!(info.compressed, "reason={}", info.reason);
assert!(info.relocated_cache_anchor);
// system is now a short pointer string.
assert!(root["system"].is_string());
assert!(root["system"]
.as_str()
.unwrap()
.contains("first user message"));
// first user message leads with an image block carrying the anchor.
let content = root["messages"][0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "image");
assert_eq!(content.last().unwrap()["type"], "text"); // original "hi there"
assert_eq!(content.last().unwrap()["text"], "hi there");
// anchor is on exactly the last image, nowhere in system.
let last_img = content.iter().rev().find(|b| b["type"] == "image").unwrap();
assert!(last_img.get("cache_control").is_some());
}
#[test]
fn tiny_system_passes_through_unchanged() {
let mut root = json!({
"model": "claude-fable-5",
"system": "be helpful",
"messages": [ { "role": "user", "content": "hi" } ]
});
let before = root.clone();
let info = transform(&mut root, &AnthropicOpts::default());
assert!(!info.compressed);
assert_eq!(info.reason, "below_min_chars");
assert_eq!(root, before, "skip path must not mutate the body");
}
#[test]
fn same_input_same_output_cache_stable() {
let make = || {
json!({
"model": "claude-fable-5",
"system": big_system(),
"messages": [ { "role": "user", "content": "go" } ]
})
};
let mut a = make();
let mut b = make();
transform(&mut a, &AnthropicOpts::default());
transform(&mut b, &AnthropicOpts::default());
assert_eq!(a, b, "transform must be deterministic (cache stability)");
}
#[test]
fn tools_stubbed_and_schema_stripped() {
let mut root = json!({
"model": "claude-fable-5",
"system": big_system(),
"tools": [{
"name": "edit_file",
"description": "very long tool description ".repeat(50),
"input_schema": {
"type": "object",
"description": "annotation",
"properties": { "path": { "type": "string", "description": "p" } },
"required": ["path"]
},
"cache_control": { "type": "ephemeral" }
}],
"messages": [ { "role": "user", "content": "go" } ]
});
let info = transform(&mut root, &AnthropicOpts::default());
assert!(info.compressed);
let tool = &root["tools"][0];
assert!(tool["description"]
.as_str()
.unwrap()
.contains("## Tool: edit_file"));
assert!(tool["input_schema"].get("description").is_none());
assert_eq!(tool["input_schema"]["required"], json!(["path"]));
assert!(tool.get("cache_control").is_none(), "tool anchor relocated");
}
/// Dense filler text large enough to clear the live-block gate.
fn big_text() -> String {
"log line with detail /var/run/app.sock port 8080 CODE_X 12345 ".repeat(300)
}
#[test]
fn images_large_reminder_block() {
let mut root = json!({
"model": "claude-fable-5",
"system": "small",
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": format!("<system-reminder>{}</system-reminder>", big_text()) },
{ "type": "text", "text": "the actual question" }
]
}]
});
let info = transform(&mut root, &AnthropicOpts::default());
assert!(info.compressed);
assert!(info.reminder_imgs >= 1);
let content = root["messages"][0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "image", "reminder replaced by image");
// The real question text survives untouched.
assert!(content.iter().any(|b| b["text"] == "the actual question"));
}
#[test]
fn images_large_tool_result_with_factsheet() {
let mut root = json!({
"model": "claude-fable-5",
"system": "small",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "tu_1",
"content": big_text(),
"cache_control": { "type": "ephemeral" }
}]
}]
});
let info = transform(&mut root, &AnthropicOpts::default());
assert!(info.compressed);
assert!(info.tool_result_imgs >= 1);
let tr = &root["messages"][0]["content"][0];
assert!(
tr.get("cache_control").is_none(),
"anchor relocated onto image"
);
let inner = tr["content"].as_array().unwrap();
assert_eq!(inner[0]["type"], "image");
// fact-sheet text block rides alongside the image (paths/ids survive OCR).
assert!(inner
.iter()
.any(|b| b["type"] == "text" && b["text"].as_str().unwrap().contains("/var/run/app.sock")));
}
#[test]
fn skips_error_tool_result() {
let mut root = json!({
"model": "claude-fable-5",
"system": "small",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "tu_1",
"is_error": true,
"content": big_text()
}]
}]
});
let before = root.clone();
let info = transform(&mut root, &AnthropicOpts::default());
assert!(!info.compressed, "error tool_results must not be imaged");
assert_eq!(root, before);
}
#[test]
fn pages_oversized_tool_result() {
// Force a tiny image cap so a modest result trips the paging path.
let opts = AnthropicOpts {
max_images_per_tool_result: 1,
max_height_px: 80, // ~9 rows/page → many pages before truncation
..AnthropicOpts::default()
};
let mut root = json!({
"model": "claude-fable-5",
"system": "small",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "tu_1",
"content": big_text()
}]
}]
});
let info = transform(&mut root, &opts);
assert!(info.compressed);
assert_eq!(info.truncated_tool_results, 1);
assert!(info.omitted_chars > 0);
}
/// A conversation with `pairs` closed assistant(tool_use)/user(tool_result) turns.
fn convo(pairs: usize) -> serde_json::Value {
let mut msgs = vec![json!({ "role": "user", "content": "start the task" })];
for i in 0..pairs {
msgs.push(json!({
"role": "assistant",
"content": [
{ "type": "text", "text": format!("step {i}") },
{ "type": "tool_use", "id": format!("tu_{i}"), "name": "run", "input": { "cmd": format!("do {i}") } }
]
}));
msgs.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": format!("tu_{i}"),
"content": format!("output /path/file{i}.rs {}", "detail ".repeat(60))
}]
}));
}
json!({ "model": "claude-fable-5", "system": "small", "messages": msgs })
}
fn history_opts() -> AnthropicOpts {
AnthropicOpts {
compress_history: true,
// Isolate the history pass so tail tool_results don't also image.
compress_tool_results: false,
compress_reminders: false,
..AnthropicOpts::default()
}
}
#[test]
fn collapses_closed_history_prefix() {
let mut root = convo(18); // 37 messages
let before_len = root["messages"].as_array().unwrap().len();
let info = transform(&mut root, &history_opts());
assert!(info.compressed, "reason={}", info.reason);
assert_eq!(info.collapsed_turns, 20, "snapped to the 20-message grid");
assert!(info.collapsed_images >= 1);
let msgs = root["messages"].as_array().unwrap();
assert!(msgs.len() < before_len, "prefix collapsed");
// First user message is protected (untouched).
assert_eq!(msgs[0]["content"], "start the task");
// Synthetic history message: intro text, then an image.
let syn = msgs[1]["content"].as_array().unwrap();
assert!(syn[0]["text"]
.as_str()
.unwrap()
.contains("EARLIER conversation"));
assert!(syn.iter().any(|b| b["type"] == "image"));
assert!(syn.last().unwrap()["text"]
.as_str()
.unwrap()
.contains("live request follows"));
}
#[test]
fn history_render_is_cache_stable() {
let mut a = convo(18);
let mut b = convo(18);
transform(&mut a, &history_opts());
transform(&mut b, &history_opts());
assert_eq!(a, b, "same conversation must collapse to identical bytes");
}
#[test]
fn short_history_not_collapsed() {
// Below min_collapse_prefix_messages (20) → left as text.
let mut root = convo(5); // 11 messages
let before = root.clone();
let info = transform(&mut root, &history_opts());
assert!(!info.compressed);
assert_eq!(root, before);
}
#[test]
fn total_image_budget_is_enforced() {
// Many large tool_results but a tiny total budget: imaging stops at the
// cap so the request can't exceed Anthropic's per-request image limit.
let mut msgs = vec![json!({ "role": "user", "content": "start" })];
for i in 0..10 {
msgs.push(json!({
"role": "user",
"content": [{ "type": "tool_result", "tool_use_id": format!("tu_{i}"), "content": big_text() }]
}));
}
let mut root = json!({ "model": "claude-fable-5", "system": "small", "messages": msgs });
let opts = AnthropicOpts {
max_total_images: 3,
..AnthropicOpts::default()
};
let info = transform(&mut root, &opts);
assert!(info.compressed);
assert!(
info.image_count <= 3,
"aggregate image budget exceeded: {}",
info.image_count
);
}
#[test]
fn tool_result_carrying_an_image_is_left_alone() {
// A screenshot + long log in one tool_result: imaging the log would drop
// the screenshot, so the whole result must pass through untouched.
let mut root = json!({
"model": "claude-fable-5",
"system": "small",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "tu_1",
"content": [
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AAAA" } },
{ "type": "text", "text": big_text() }
]
}]
}]
});
let before = root.clone();
let info = transform(&mut root, &AnthropicOpts::default());
assert!(
!info.compressed,
"tool_result with an image must not be imaged"
);
assert_eq!(root, before);
}
/// True unless a tool_result references a tool_use id not present as a real
/// block earlier in the message list (an orphan the API rejects).
fn no_orphaned_tool_results(root: &serde_json::Value) -> bool {
let mut seen = std::collections::HashSet::new();
for m in root["messages"].as_array().unwrap() {
let Some(blocks) = m.get("content").and_then(|c| c.as_array()) else {
continue;
};
for b in blocks {
match b.get("type").and_then(|t| t.as_str()) {
Some("tool_use") => {
if let Some(id) = b.get("id").and_then(|i| i.as_str()) {
seen.insert(id.to_string());
}
}
Some("tool_result") => {
if let Some(id) = b.get("tool_use_id").and_then(|i| i.as_str()) {
if !seen.contains(id) {
return false;
}
}
}
_ => {}
}
}
}
true
}
#[test]
fn history_snap_never_orphans_a_tool_result() {
// A text-only assistant turn right after the first user message shifts
// parity so the chunk-grid line (61) lands mid-pair on an OPEN tool_use;
// the snap must back off to the nearest closed boundary (60) instead of
// orphaning tu_29's tool_result into the tail.
let mut msgs = vec![json!({ "role": "user", "content": "start" })];
msgs.push(json!({ "role": "assistant", "content": [{ "type": "text", "text": "thinking out loud" }] }));
for i in 0..40 {
msgs.push(json!({
"role": "assistant",
"content": [
{ "type": "text", "text": format!("step {i}") },
{ "type": "tool_use", "id": format!("tu_{i}"), "name": "run", "input": { "cmd": format!("do {i}") } }
]
}));
msgs.push(json!({
"role": "user",
"content": [{ "type": "tool_result", "tool_use_id": format!("tu_{i}"), "content": format!("out /p/f{i}.rs {}", "detail ".repeat(60)) }]
}));
}
let mut root = json!({ "model": "claude-fable-5", "system": "small", "messages": msgs });
let info = transform(&mut root, &history_opts());
assert!(info.compressed, "reason={}", info.reason);
assert!(
no_orphaned_tool_results(&root),
"history collapse orphaned a tool_result"
);
}
#[test]
fn open_tool_call_not_crossed() {
// Last prefix turn leaves a tool_use unmatched (open); the boundary must
// stop before it so no tool call is orphaned into the image.
let mut root = convo(18);
// Drop the tool_result of an early pair to open a call at message 4.
root["messages"][4] = json!({ "role": "user", "content": "no tool result here" });
let info = transform(&mut root, &history_opts());
// With an open call at msg 3 (tu_1) never closed, the closed boundary
// can't advance past msg 2, so the 20-message grid step never fills.
assert!(!info.compressed || info.collapsed_turns == 0);
}
@@ -0,0 +1,72 @@
use super::common::{
render_live_block, tool_result_has_image, tool_result_text, truncate_for_budget, AnthropicOpts,
};
use crate::transform::info::TransformInfo;
use serde_json::Value;
/// Image large `tool_result` text content across every user message. tool_result
/// output (find trees, file dumps, logs) is the bulk of agentic input. Skips
/// `is_error` results (Anthropic rejects images inside those) and pages oversized
/// content down to the image cap.
pub(crate) fn apply_tool_results(
root: &mut Value,
opts: &AnthropicOpts,
info: &mut TransformInfo,
budget: &mut usize,
) {
let Some(msgs) = root.get_mut("messages").and_then(|m| m.as_array_mut()) else {
return;
};
for msg in msgs.iter_mut() {
if *budget == 0 {
break;
}
let Some(blocks) = msg.get_mut("content").and_then(|c| c.as_array_mut()) else {
continue;
};
for block in blocks.iter_mut() {
let Some(bm) = block.as_object_mut() else {
continue;
};
if bm.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
continue;
}
if bm.get("is_error").and_then(|e| e.as_bool()) == Some(true) {
continue; // images forbidden in error tool_results
}
// Don't clobber a tool_result that already carries an image block
// (e.g. a screenshot the tool returned) — tool_result_text only reads
// the text sub-blocks, so imaging here would silently drop that image.
if tool_result_has_image(bm.get("content")) {
continue;
}
let text = tool_result_text(bm.get("content"));
if text.len() < opts.min_live_block_chars {
continue;
}
// Cap each result at the per-result limit AND whatever total budget
// is left, so the aggregate can't exceed max_total_images.
let per_result = opts.max_images_per_tool_result.min(*budget);
if per_result == 0 {
break;
}
let (rendered, omitted) = truncate_for_budget(&text, per_result, opts);
let cc = bm.get("cache_control").cloned();
if let Some(r) = render_live_block(&rendered, opts, cc, true, *budget) {
*budget -= r.img_count;
if omitted > 0 {
info.truncated_tool_results += 1;
info.omitted_chars += omitted;
}
info.tool_result_imgs += r.img_count;
info.image_count += r.img_count;
info.image_bytes += r.bytes;
info.image_pixels += r.pixels;
info.dropped_chars += r.dropped;
info.compressed_chars += text.len();
bm.remove("cache_control"); // relocated onto the last image
bm.insert("content".into(), Value::Array(r.blocks));
}
}
}
}
+10 -475
View File
@@ -17,16 +17,16 @@ pub struct CommandDetection {
pub category: String,
}
struct Detector {
r#type: &'static str,
category: &'static str,
command_patterns: Vec<Regex>,
content_patterns: Vec<Regex>,
pub(super) struct Detector {
pub(super) r#type: &'static str,
pub(super) category: &'static str,
pub(super) command_patterns: Vec<Regex>,
pub(super) content_patterns: Vec<Regex>,
}
/// Compile with explicit flags: `i` = case-insensitive, `m` = multiline.
/// Fixed, in-tree patterns — a bad transcription panics (caught by `table_builds`).
fn rx(pattern: &str, flags: &str) -> Regex {
pub(super) fn rx(pattern: &str, flags: &str) -> Regex {
RegexBuilder::new(pattern)
.case_insensitive(flags.contains('i'))
.multi_line(flags.contains('m'))
@@ -96,486 +96,21 @@ fn command_prefix_re() -> &'static Regex {
RE.get_or_init(|| Regex::new(&format!(r"^(?:{})\b", COMMAND_PREFIXES.join("|"))).unwrap())
}
mod table;
fn detectors() -> &'static Vec<Detector> {
static D: OnceLock<Vec<Detector>> = OnceLock::new();
D.get_or_init(build_detectors)
D.get_or_init(table::build_detectors)
}
/// (type, category, command patterns (all "i"), (content pattern, flags) pairs).
type DetectorRow = (
pub(super) type DetectorRow = (
&'static str,
&'static str,
&'static [&'static str],
&'static [(&'static str, &'static str)],
);
fn build_detectors() -> Vec<Detector> {
let table: &[DetectorRow] = &[
(
"git-status",
"git",
&[r"^git\s+status\b"],
&[
(r"^On branch ", "m"),
(r"^Changes (?:not staged|to be committed)", "m"),
(r"^Untracked files:", "m"),
],
),
(
"git-branch",
"git",
&[r"^git\s+branch\b", r"^git\s+checkout\b", r"^git\s+switch\b"],
&[
(r"^\*\s+\S+", "m"),
(r"Switched to (?:a new )?branch", "i"),
(r#"Already on ['"][^'"]+['"]"#, "i"),
],
),
(
"git-diff",
"git",
&[r"^git\s+diff\b", r"^git\s+show\b"],
&[
(r"^diff --git ", "m"),
(r"^@@\s+-\d+,\d+\s+\+\d+,\d+\s+@@", "m"),
],
),
(
"git-log",
"git",
&[r"^git\s+log\b"],
&[(r"^commit [0-9a-f]{7,40}", "m"), (r"^Author: ", "m")],
),
(
"make",
"build",
&[r"^make\b"],
&[
(r"^make\[\d+\]: (?:Entering|Leaving) directory", "m"),
(r"make: \*\*\* ", ""),
],
),
(
"gradle",
"build",
&[r"^(?:gradle|gradlew|\./gradlew)\b"],
&[
(r"^> Task :", "m"),
(r"^BUILD (?:SUCCESSFUL|FAILED)\b", "m"),
],
),
(
"dotnet",
"build",
&[
r"^dotnet\s+(?:build|test|run|restore|publish|pack|msbuild)\b",
r"^dotnet\b",
],
&[
(r"^Build (?:succeeded|FAILED)\b", "m"),
(r"\b(?:error|warning) CS\d+\b", "m"),
],
),
(
"terraform-plan",
"infra",
&[r"^terraform\s+plan\b"],
&[
(r"Terraform will perform the following actions:", ""),
(r"Plan: \d+ to add", "i"),
],
),
(
"tofu-plan",
"infra",
&[r"^(?:tofu|opentofu)\s+plan\b"],
&[
(r"OpenTofu will perform the following actions:", ""),
(r"Plan: \d+ to add", "i"),
],
),
(
"systemctl-status",
"infra",
&[r"^systemctl\s+status\b"],
&[
(r"^\s*Loaded:\s+", "m"),
(r"^\s*Active:\s+", "m"),
(r"^●\s+\S+\.service", "m"),
],
),
(
"test-vitest",
"test",
&[r"^vitest\b", r"^npm\s+(?:run\s+)?test:vitest\b"],
&[
(r"\bvitest\b", "i"),
(r"^ ✓ ", "m"),
(r"^ ", "m"),
(r"Test Files\s+\d+\s+(?:passed|failed)", "i"),
],
),
(
"test-jest",
"test",
&[r"^jest\b", r"^npm\s+(?:run\s+)?test\b"],
&[
(r"Test Suites:\s+\d+", "i"),
(r"Tests:\s+\d+", "i"),
(r"^PASS\s+", "m"),
(r"^FAIL\s+", "m"),
],
),
(
"test-pytest",
"test",
&[r"^pytest\b", r"^python\s+-m\s+pytest\b"],
&[
(r"=+\s+(?:\d+\s+)?(?:passed|failed|errors?)", "i"),
(r"^E\s+", "m"),
(r"^FAILED ", "m"),
],
),
(
"test-cargo",
"test",
&[r"^cargo\s+test\b", r"^cargo\s+nextest\b"],
&[
(r"^running \d+ tests?", "m"),
(r"^test\s+[\w:.-]+\s+\.\.\.\s+(?:ok|FAILED|ignored)", "m"),
(r"test result:\s+(?:ok|FAILED)", "i"),
],
),
(
"test-go",
"test",
&[r"^go\s+test\b"],
&[
(r"^(?:ok|FAIL)\s+[\w./-]+\s+[\d.]+s", "m"),
(r"^--- FAIL: ", "m"),
(r"^panic: ", "m"),
],
),
(
"build-typescript",
"build",
&[r"^tsc\b", r"^npm\s+run\s+typecheck\b"],
&[(r"TS\d{4}:", ""), (r"error TS\d{4}", "i")],
),
(
"build-eslint",
"build",
&[r"^eslint\b", r"^npm\s+run\s+lint\b"],
&[
(r"\s+\d+:\d+\s+(?:error|warning)\s+", ""),
(r"✖\s+\d+\s+problems?", ""),
],
),
(
"build-webpack",
"build",
&[
r"^webpack\b",
r"^npx\s+webpack\b",
r"^npm\s+run\s+build:webpack\b",
],
&[
(r"webpack\s+\d", "i"),
(r"compiled (?:successfully|with \d+ errors?)", "i"),
(r"asset .+\.js", "i"),
],
),
(
"build-vite",
"build",
&[
r"^vite\s+build\b",
r"^npm\s+run\s+build\b",
r"^pnpm\s+build\b",
],
&[
(r"vite v[\d.]+", "i"),
(r"✓ built in", "i"),
(r"transforming \(\d+\)", "i"),
],
),
(
"biome",
"build",
&[r"^biome\b", r"^npx\s+biome\b"],
&[
(r"lint/[A-Za-z0-9/.-]+", ""),
(r"Checked \d+ files? in", "i"),
],
),
(
"prettier",
"build",
&[r"^prettier\b", r"^npx\s+prettier\b"],
&[
(r"^Checking formatting\.\.\.", "m"),
(r"Code style issues found", "i"),
],
),
(
"turbo",
"build",
&[r"^turbo\b", r"^npx\s+turbo\b"],
&[
(r"^• Packages in scope:", "m"),
(r"^Tasks:\s+\d+\s+successful", "m"),
],
),
(
"nx",
"build",
&[r"^nx\b", r"^npx\s+nx\b"],
&[(r"^NX\s+", "m"), (r"^> nx run ", "m")],
),
(
"playwright",
"test",
&[r"^playwright\s+test\b", r"^npx\s+playwright\s+test\b"],
&[
(r"Running \d+ tests? using \d+ workers?", "i"),
(r"^\s+\d+ failed", "m"),
],
),
(
"npm-install",
"package",
&[r"^(?:npm|pnpm|yarn)\s+(?:install|add|update)\b"],
&[
(r"added \d+ packages", "i"),
(r"packages are looking for funding", "i"),
(r"audited \d+ packages", "i"),
],
),
(
"npm-audit",
"package",
&[r"^(?:npm|pnpm|yarn)\s+audit\b"],
&[
(r"found \d+ vulnerabilities", "i"),
(r"\b(?:low|moderate|high|critical)\b", "i"),
],
),
(
"ruff",
"build",
&[r"^ruff\b", r"^uv\s+run\s+ruff\b"],
&[
(r"^[\w./-]+\.py:\d+:\d+:\s+[A-Z]\d+", "m"),
(r"Found \d+ errors?\.", "i"),
],
),
(
"mypy",
"build",
&[r"^mypy\b", r"^python\s+-m\s+mypy\b"],
&[
(r"^[\w./-]+\.py:\d+:\s+error:", "m"),
(r"Found \d+ errors? in \d+ files?", "i"),
],
),
(
"pip",
"package",
&[
r"^pip\s+(?:install|download|uninstall)\b",
r"^python\s+-m\s+pip\b",
],
&[(r"^Collecting ", "m"), (r"^Successfully installed ", "m")],
),
(
"uv-sync",
"package",
&[r"^uv\s+sync\b", r"^uv\s+pip\s+install\b"],
&[
(r"^Resolved \d+ packages?", "m"),
(r"^Installed \d+ packages?", "m"),
],
),
(
"poetry-install",
"package",
&[r"^poetry\s+install\b"],
&[
(r"^Installing dependencies from lock file", "m"),
(r"^Package operations:", "m"),
],
),
(
"golangci-lint",
"build",
&[r"^golangci-lint\b"],
&[(r"^[\w./-]+\.go:\d+:\d+:", "m"), (r"^\d+ issues?:", "m")],
),
(
"bundle-install",
"package",
&[r"^bundle\s+install\b"],
&[
(r"^Fetching gem metadata from ", "m"),
(r"^Bundle complete!", "m"),
],
),
(
"rubocop",
"build",
&[r"^rubocop\b", r"^bundle\s+exec\s+rubocop\b"],
&[
(r"^Inspecting \d+ files", "m"),
(r"^[\w./-]+\.rb:\d+:\d+:\s+[A-Z]:", "m"),
],
),
(
"docker-ps",
"docker",
&[r"^docker\s+ps\b"],
&[(r"^CONTAINER ID\s+IMAGE\s+COMMAND", "m")],
),
(
"docker-logs",
"docker",
&[r"^docker\s+logs\b", r"^docker\s+compose\s+logs\b"],
&[
(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", "m"),
(r"\b(?:ERROR|WARN|INFO)\b", ""),
(r"^Attaching to ", "m"),
],
),
(
"aws",
"cloud",
&[r"^aws\b"],
&[
(r"An error occurred \([A-Za-z0-9]+\) when calling", ""),
(r"^(?:upload|download): ", "m"),
],
),
(
"gcloud",
"cloud",
&[r"^gcloud\b"],
&[(r"^ERROR: \(gcloud\.", "m"), (r"^Updated property \[", "m")],
),
(
"ssh",
"cloud",
&[r"^ssh\b"],
&[
(r"Permission denied \(", ""),
(r"Host key verification failed", ""),
(r"Connection timed out", ""),
],
),
(
"rsync",
"cloud",
&[r"^rsync\b"],
&[
(r"^sending incremental file list", "m"),
(r"^rsync error:", "m"),
],
),
(
"curl",
"cloud",
&[r"^curl\b"],
&[(r"curl: \(\d+\)", ""), (r"^HTTP/\d(?:\.\d)? \d{3}", "m")],
),
(
"wget",
"cloud",
&[r"^wget\b"],
&[(r"^--\d{4}-\d{2}-\d{2}", "m"), (r"^ERROR \d{3}:", "m")],
),
(
"json-output",
"generic",
&[r"^jq\b", r"^cat\s+.*\.json\b"],
&[(r"^\s*[\[{][\s\S]*[\]}]\s*$", "")],
),
(
"shell-ls",
"shell",
&[r"^ls(?:\s+-[A-Za-z]+)?\b"],
&[
(r"^total \d+", "m"),
(r"^\S+\s+\S+\s+\d+\s+\w+\s+\d{1,2}\s+", "m"),
],
),
(
"shell-find",
"shell",
&[r"^find\b"],
&[(r"^(?:\.{1,2}|/|[\w.-]+/).+", "m")],
),
(
"shell-grep",
"shell",
&[r"^(?:grep|rg|ag)\b"],
&[
(
r"^[\w./-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|md|json|ya?ml|txt):\d*:",
"m",
),
(r"^[\w./-]+/[\w./-]+:\d*:", "m"),
],
),
(
"shell-ps",
"shell",
&[r"^ps\b"],
&[(r"^(?:USER\s+PID|\s*PID\s+)", "m")],
),
(
"shell-df",
"shell",
&[r"^df\b"],
&[(r"^Filesystem\s+.*Use%", "m")],
),
(
"shell-du",
"shell",
&[r"^du\b"],
&[(r"^\d+(?:\.\d+)?[KMGTP]?\s+\S+", "m")],
),
(
"error-stacktrace",
"generic",
&[],
&[
(r"Traceback \(most recent call last\):", ""),
(r"^\s+at\s+\S+\s+\(.+:\d+:\d+\)", "m"),
(r"^panic: ", "m"),
(r"^thread '[^']+' panicked at", "m"),
],
),
(
"generic-error",
"generic",
&[],
&[
(r"Error:", ""),
(r"Exception:", ""),
(r"Traceback \(most recent call last\):", ""),
],
),
];
table
.iter()
.map(|(t, cat, cmds, contents)| Detector {
r#type: t,
category: cat,
command_patterns: cmds.iter().map(|p| rx(p, "i")).collect(),
content_patterns: contents.iter().map(|(p, f)| rx(p, f)).collect(),
})
.collect()
}
/// Quote-aware composite-command splitter — port of `lastCommandSegment`.
/// Returns the last top-level `&&`/`||`/`;`-separated segment (trimmed).
pub fn last_command_segment(command: &str) -> String {
+468
View File
@@ -0,0 +1,468 @@
use super::{rx, Detector, DetectorRow};
pub(super) fn build_detectors() -> Vec<Detector> {
let table: &[DetectorRow] = &[
(
"git-status",
"git",
&[r"^git\s+status\b"],
&[
(r"^On branch ", "m"),
(r"^Changes (?:not staged|to be committed)", "m"),
(r"^Untracked files:", "m"),
],
),
(
"git-branch",
"git",
&[r"^git\s+branch\b", r"^git\s+checkout\b", r"^git\s+switch\b"],
&[
(r"^\*\s+\S+", "m"),
(r"Switched to (?:a new )?branch", "i"),
(r#"Already on ['"][^'"]+['"]"#, "i"),
],
),
(
"git-diff",
"git",
&[r"^git\s+diff\b", r"^git\s+show\b"],
&[
(r"^diff --git ", "m"),
(r"^@@\s+-\d+,\d+\s+\+\d+,\d+\s+@@", "m"),
],
),
(
"git-log",
"git",
&[r"^git\s+log\b"],
&[(r"^commit [0-9a-f]{7,40}", "m"), (r"^Author: ", "m")],
),
(
"make",
"build",
&[r"^make\b"],
&[
(r"^make\[\d+\]: (?:Entering|Leaving) directory", "m"),
(r"make: \*\*\* ", ""),
],
),
(
"gradle",
"build",
&[r"^(?:gradle|gradlew|\./gradlew)\b"],
&[
(r"^> Task :", "m"),
(r"^BUILD (?:SUCCESSFUL|FAILED)\b", "m"),
],
),
(
"dotnet",
"build",
&[
r"^dotnet\s+(?:build|test|run|restore|publish|pack|msbuild)\b",
r"^dotnet\b",
],
&[
(r"^Build (?:succeeded|FAILED)\b", "m"),
(r"\b(?:error|warning) CS\d+\b", "m"),
],
),
(
"terraform-plan",
"infra",
&[r"^terraform\s+plan\b"],
&[
(r"Terraform will perform the following actions:", ""),
(r"Plan: \d+ to add", "i"),
],
),
(
"tofu-plan",
"infra",
&[r"^(?:tofu|opentofu)\s+plan\b"],
&[
(r"OpenTofu will perform the following actions:", ""),
(r"Plan: \d+ to add", "i"),
],
),
(
"systemctl-status",
"infra",
&[r"^systemctl\s+status\b"],
&[
(r"^\s*Loaded:\s+", "m"),
(r"^\s*Active:\s+", "m"),
(r"^●\s+\S+\.service", "m"),
],
),
(
"test-vitest",
"test",
&[r"^vitest\b", r"^npm\s+(?:run\s+)?test:vitest\b"],
&[
(r"\bvitest\b", "i"),
(r"^ ✓ ", "m"),
(r"^ ", "m"),
(r"Test Files\s+\d+\s+(?:passed|failed)", "i"),
],
),
(
"test-jest",
"test",
&[r"^jest\b", r"^npm\s+(?:run\s+)?test\b"],
&[
(r"Test Suites:\s+\d+", "i"),
(r"Tests:\s+\d+", "i"),
(r"^PASS\s+", "m"),
(r"^FAIL\s+", "m"),
],
),
(
"test-pytest",
"test",
&[r"^pytest\b", r"^python\s+-m\s+pytest\b"],
&[
(r"=+\s+(?:\d+\s+)?(?:passed|failed|errors?)", "i"),
(r"^E\s+", "m"),
(r"^FAILED ", "m"),
],
),
(
"test-cargo",
"test",
&[r"^cargo\s+test\b", r"^cargo\s+nextest\b"],
&[
(r"^running \d+ tests?", "m"),
(r"^test\s+[\w:.-]+\s+\.\.\.\s+(?:ok|FAILED|ignored)", "m"),
(r"test result:\s+(?:ok|FAILED)", "i"),
],
),
(
"test-go",
"test",
&[r"^go\s+test\b"],
&[
(r"^(?:ok|FAIL)\s+[\w./-]+\s+[\d.]+s", "m"),
(r"^--- FAIL: ", "m"),
(r"^panic: ", "m"),
],
),
(
"build-typescript",
"build",
&[r"^tsc\b", r"^npm\s+run\s+typecheck\b"],
&[(r"TS\d{4}:", ""), (r"error TS\d{4}", "i")],
),
(
"build-eslint",
"build",
&[r"^eslint\b", r"^npm\s+run\s+lint\b"],
&[
(r"\s+\d+:\d+\s+(?:error|warning)\s+", ""),
(r"✖\s+\d+\s+problems?", ""),
],
),
(
"build-webpack",
"build",
&[
r"^webpack\b",
r"^npx\s+webpack\b",
r"^npm\s+run\s+build:webpack\b",
],
&[
(r"webpack\s+\d", "i"),
(r"compiled (?:successfully|with \d+ errors?)", "i"),
(r"asset .+\.js", "i"),
],
),
(
"build-vite",
"build",
&[
r"^vite\s+build\b",
r"^npm\s+run\s+build\b",
r"^pnpm\s+build\b",
],
&[
(r"vite v[\d.]+", "i"),
(r"✓ built in", "i"),
(r"transforming \(\d+\)", "i"),
],
),
(
"biome",
"build",
&[r"^biome\b", r"^npx\s+biome\b"],
&[
(r"lint/[A-Za-z0-9/.-]+", ""),
(r"Checked \d+ files? in", "i"),
],
),
(
"prettier",
"build",
&[r"^prettier\b", r"^npx\s+prettier\b"],
&[
(r"^Checking formatting\.\.\.", "m"),
(r"Code style issues found", "i"),
],
),
(
"turbo",
"build",
&[r"^turbo\b", r"^npx\s+turbo\b"],
&[
(r"^• Packages in scope:", "m"),
(r"^Tasks:\s+\d+\s+successful", "m"),
],
),
(
"nx",
"build",
&[r"^nx\b", r"^npx\s+nx\b"],
&[(r"^NX\s+", "m"), (r"^> nx run ", "m")],
),
(
"playwright",
"test",
&[r"^playwright\s+test\b", r"^npx\s+playwright\s+test\b"],
&[
(r"Running \d+ tests? using \d+ workers?", "i"),
(r"^\s+\d+ failed", "m"),
],
),
(
"npm-install",
"package",
&[r"^(?:npm|pnpm|yarn)\s+(?:install|add|update)\b"],
&[
(r"added \d+ packages", "i"),
(r"packages are looking for funding", "i"),
(r"audited \d+ packages", "i"),
],
),
(
"npm-audit",
"package",
&[r"^(?:npm|pnpm|yarn)\s+audit\b"],
&[
(r"found \d+ vulnerabilities", "i"),
(r"\b(?:low|moderate|high|critical)\b", "i"),
],
),
(
"ruff",
"build",
&[r"^ruff\b", r"^uv\s+run\s+ruff\b"],
&[
(r"^[\w./-]+\.py:\d+:\d+:\s+[A-Z]\d+", "m"),
(r"Found \d+ errors?\.", "i"),
],
),
(
"mypy",
"build",
&[r"^mypy\b", r"^python\s+-m\s+mypy\b"],
&[
(r"^[\w./-]+\.py:\d+:\s+error:", "m"),
(r"Found \d+ errors? in \d+ files?", "i"),
],
),
(
"pip",
"package",
&[
r"^pip\s+(?:install|download|uninstall)\b",
r"^python\s+-m\s+pip\b",
],
&[(r"^Collecting ", "m"), (r"^Successfully installed ", "m")],
),
(
"uv-sync",
"package",
&[r"^uv\s+sync\b", r"^uv\s+pip\s+install\b"],
&[
(r"^Resolved \d+ packages?", "m"),
(r"^Installed \d+ packages?", "m"),
],
),
(
"poetry-install",
"package",
&[r"^poetry\s+install\b"],
&[
(r"^Installing dependencies from lock file", "m"),
(r"^Package operations:", "m"),
],
),
(
"golangci-lint",
"build",
&[r"^golangci-lint\b"],
&[(r"^[\w./-]+\.go:\d+:\d+:", "m"), (r"^\d+ issues?:", "m")],
),
(
"bundle-install",
"package",
&[r"^bundle\s+install\b"],
&[
(r"^Fetching gem metadata from ", "m"),
(r"^Bundle complete!", "m"),
],
),
(
"rubocop",
"build",
&[r"^rubocop\b", r"^bundle\s+exec\s+rubocop\b"],
&[
(r"^Inspecting \d+ files", "m"),
(r"^[\w./-]+\.rb:\d+:\d+:\s+[A-Z]:", "m"),
],
),
(
"docker-ps",
"docker",
&[r"^docker\s+ps\b"],
&[(r"^CONTAINER ID\s+IMAGE\s+COMMAND", "m")],
),
(
"docker-logs",
"docker",
&[r"^docker\s+logs\b", r"^docker\s+compose\s+logs\b"],
&[
(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", "m"),
(r"\b(?:ERROR|WARN|INFO)\b", ""),
(r"^Attaching to ", "m"),
],
),
(
"aws",
"cloud",
&[r"^aws\b"],
&[
(r"An error occurred \([A-Za-z0-9]+\) when calling", ""),
(r"^(?:upload|download): ", "m"),
],
),
(
"gcloud",
"cloud",
&[r"^gcloud\b"],
&[(r"^ERROR: \(gcloud\.", "m"), (r"^Updated property \[", "m")],
),
(
"ssh",
"cloud",
&[r"^ssh\b"],
&[
(r"Permission denied \(", ""),
(r"Host key verification failed", ""),
(r"Connection timed out", ""),
],
),
(
"rsync",
"cloud",
&[r"^rsync\b"],
&[
(r"^sending incremental file list", "m"),
(r"^rsync error:", "m"),
],
),
(
"curl",
"cloud",
&[r"^curl\b"],
&[(r"curl: \(\d+\)", ""), (r"^HTTP/\d(?:\.\d)? \d{3}", "m")],
),
(
"wget",
"cloud",
&[r"^wget\b"],
&[(r"^--\d{4}-\d{2}-\d{2}", "m"), (r"^ERROR \d{3}:", "m")],
),
(
"json-output",
"generic",
&[r"^jq\b", r"^cat\s+.*\.json\b"],
&[(r"^\s*[\[{][\s\S]*[\]}]\s*$", "")],
),
(
"shell-ls",
"shell",
&[r"^ls(?:\s+-[A-Za-z]+)?\b"],
&[
(r"^total \d+", "m"),
(r"^\S+\s+\S+\s+\d+\s+\w+\s+\d{1,2}\s+", "m"),
],
),
(
"shell-find",
"shell",
&[r"^find\b"],
&[(r"^(?:\.{1,2}|/|[\w.-]+/).+", "m")],
),
(
"shell-grep",
"shell",
&[r"^(?:grep|rg|ag)\b"],
&[
(
r"^[\w./-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|md|json|ya?ml|txt):\d*:",
"m",
),
(r"^[\w./-]+/[\w./-]+:\d*:", "m"),
],
),
(
"shell-ps",
"shell",
&[r"^ps\b"],
&[(r"^(?:USER\s+PID|\s*PID\s+)", "m")],
),
(
"shell-df",
"shell",
&[r"^df\b"],
&[(r"^Filesystem\s+.*Use%", "m")],
),
(
"shell-du",
"shell",
&[r"^du\b"],
&[(r"^\d+(?:\.\d+)?[KMGTP]?\s+\S+", "m")],
),
(
"error-stacktrace",
"generic",
&[],
&[
(r"Traceback \(most recent call last\):", ""),
(r"^\s+at\s+\S+\s+\(.+:\d+:\d+\)", "m"),
(r"^panic: ", "m"),
(r"^thread '[^']+' panicked at", "m"),
],
),
(
"generic-error",
"generic",
&[],
&[
(r"Error:", ""),
(r"Exception:", ""),
(r"Traceback \(most recent call last\):", ""),
],
),
];
table
.iter()
.map(|(t, cat, cmds, contents)| Detector {
r#type: t,
category: cat,
command_patterns: cmds.iter().map(|p| rx(p, "i")).collect(),
content_patterns: contents.iter().map(|(p, f)| rx(p, f)).collect(),
})
.collect()
}
+5 -7
View File
@@ -257,13 +257,11 @@ impl RtkFilter {
.build()
.ok()?;
let unless = match r.unless {
Some(u) => {
RegexBuilder::new(&u)
.case_insensitive(true)
.multi_line(true)
.build()
.ok()
}
Some(u) => RegexBuilder::new(&u)
.case_insensitive(true)
.multi_line(true)
.build()
.ok(),
None => None,
};
Some(MatchOutputRule {
+8 -5
View File
@@ -31,11 +31,14 @@ fn build_lookup(messages: &[Value]) -> ToolLookup {
.unwrap_or_default()
.to_string();
let command = part.get("input").and_then(command_from_input);
lookup.insert(id.to_string(), ToolMeta {
name,
command,
arguments_str: None,
});
lookup.insert(
id.to_string(),
ToolMeta {
name,
command,
arguments_str: None,
},
);
}
}
lookup
+8 -5
View File
@@ -40,11 +40,14 @@ fn build_lookup(messages: &[Value]) -> ToolLookup {
// strings). For OpenAI the parse is deferred — build_lookup only
// stores the raw string.
let command = None;
lookup.insert(id.to_string(), ToolMeta {
name,
command,
arguments_str,
});
lookup.insert(
id.to_string(),
ToolMeta {
name,
command,
arguments_str,
},
);
}
}
lookup
@@ -1,682 +1,20 @@
// Anthropic Messages API <-> Gemini generateContent API message mapping.
//
// Pure translation functions, no IO. Converts Anthropic request types into
// Gemini request types and Gemini response types back into Anthropic responses.
// Pure translation functions, no IO. Split by direction:
// - `request` Anthropic -> Gemini request
// - `response` Gemini -> Anthropic response
// - `reverse` Gemini -> Anthropic request & Anthropic -> Gemini response
// - `helpers` shared utilities (role merging, tool-id mapping)
use std::collections::HashMap;
mod helpers;
mod request;
mod response;
mod reverse;
use crate::anthropic::messages as anthropic;
use crate::gemini::request as gemini;
use crate::gemini::response as gemini_resp;
use crate::mapping::tools_map::sanitize_schema_for_gemini;
use crate::util::ids::{generate_message_id, generate_tool_use_id};
// ---------------------------------------------------------------------------
// Request direction: Anthropic -> Gemini
// ---------------------------------------------------------------------------
/// Compute degradation warnings for a Gemini-bound request.
///
/// Call before translating to surface features that are silently dropped during
/// Anthropic → Gemini translation. Emit the result as an `x-anyllm-degradation`
/// response header so clients can detect lossy translations.
pub fn compute_gemini_request_warnings(
req: &anthropic::MessageCreateRequest,
) -> crate::mapping::warnings::TranslationWarnings {
use crate::mapping::warnings::TranslationWarnings;
let mut w = TranslationWarnings::default();
// Single pass: collect all per-block warning flags at once.
let mut has_thinking = false;
let mut has_document = false;
let mut has_url_image = false;
for msg in &req.messages {
if let anthropic::Content::Blocks(blocks) = &msg.content {
for b in blocks {
match b {
// Thinking/RedactedThinking: no Gemini Content equivalent.
anthropic::ContentBlock::Thinking { .. }
| anthropic::ContentBlock::RedactedThinking { .. } => has_thinking = true,
// Document blocks have no Gemini equivalent.
anthropic::ContentBlock::Document { .. } => has_document = true,
// URL-type images: Gemini only accepts inline base64 data.
anthropic::ContentBlock::Image { source } if source.source_type != "base64" => {
has_url_image = true
}
_ => {}
}
}
}
}
if has_thinking {
w.add("thinking_blocks");
}
if has_document {
w.add("document_blocks");
}
if has_url_image {
w.add("url_images");
}
// cache_control on system blocks is dropped; Gemini has no prompt-caching API.
if let Some(anthropic::System::Blocks(blocks)) = &req.system {
if blocks.iter().any(|b| b.cache_control.is_some()) {
w.add("cache_control");
}
}
w
}
/// Convert an Anthropic `MessageCreateRequest` into a Gemini `GenerateContentRequest`.
///
/// Maps `thinking_config` to `generationConfig.thinkingConfig` for Gemini 2.5
/// thinking models. Drops unsupported features (thinking content blocks in prior
/// messages, document blocks, cache_control) and merges consecutive same-role
/// messages to satisfy Gemini's strict alternation requirement.
pub fn anthropic_to_gemini_request(
req: &anthropic::MessageCreateRequest,
) -> gemini::GenerateContentRequest {
let tool_id_map = build_tool_id_map(&req.messages);
// System instruction
let system_instruction = req.system.as_ref().map(|sys| {
let text = match sys {
anthropic::System::Text(s) => s.clone(),
anthropic::System::Blocks(blocks) => blocks
.iter()
.map(|b| b.text.as_str())
.collect::<Vec<_>>()
.join("\n"),
};
gemini::Content {
role: None,
parts: vec![gemini::Part::text(text)],
}
});
// Convert messages
let mut contents: Vec<gemini::Content> = Vec::new();
for msg in &req.messages {
let role = match msg.role {
anthropic::Role::User => "user",
anthropic::Role::Assistant => "model",
};
let parts = content_blocks_to_parts(&msg.content, &tool_id_map);
if !parts.is_empty() {
contents.push(gemini::Content {
role: Some(role.to_string()),
parts,
});
}
}
contents = merge_consecutive_roles(contents);
// Tools
let tools = req.tools.as_ref().map(|tools| {
vec![gemini::Tool {
function_declarations: tools
.iter()
.map(|t| gemini::FunctionDeclaration {
name: t.name.clone(),
description: t.description.clone(),
parameters: Some(sanitize_schema_for_gemini(t.input_schema.clone())),
})
.collect(),
}]
});
// Tool config
let tool_config = req.tool_choice.as_ref().map(|tc| {
if matches!(
tc,
anthropic::ToolChoice::Auto {
disable_parallel_tool_use: Some(true)
} | anthropic::ToolChoice::Any {
disable_parallel_tool_use: Some(true)
}
) {
tracing::warn!(
"disable_parallel_tool_use=true is not supported by Gemini; \
parallel tool calls may still occur"
);
}
let (mode, allowed) = match tc {
anthropic::ToolChoice::Auto { .. } => ("AUTO", None),
anthropic::ToolChoice::Any { .. } => ("ANY", None),
anthropic::ToolChoice::None => ("NONE", None),
// Gemini ANY + allowedFunctionNames restricts to a specific tool.
anthropic::ToolChoice::Tool { name, .. } => ("ANY", Some(vec![name.clone()])),
};
gemini::ToolConfig {
function_calling_config: gemini::FunctionCallingConfig {
mode: mode.to_string(),
allowed_function_names: allowed,
},
}
});
// Generation config
let generation_config = {
let thinking_config =
if let Some(anthropic::ThinkingConfig::Enabled { budget_tokens }) = &req.thinking {
Some(gemini::ThinkingConfig {
thinking_budget: *budget_tokens,
include_thoughts: Some(true),
})
} else {
None
};
let gc = gemini::GenerationConfig {
max_output_tokens: Some(req.max_tokens),
temperature: req.temperature,
top_p: req.top_p,
top_k: req.top_k,
stop_sequences: req.stop_sequences.clone(),
thinking_config,
..Default::default()
};
Some(gc)
};
gemini::GenerateContentRequest {
contents,
system_instruction,
generation_config,
tools,
tool_config,
safety_settings: None,
}
}
/// Build a map from Anthropic tool_use IDs to tool names.
///
/// Scans all messages for `ToolUse` blocks so that `ToolResult` translation can
/// look up the function name Gemini expects.
pub fn build_tool_id_map(messages: &[anthropic::InputMessage]) -> HashMap<String, String> {
let mut map = HashMap::new();
for msg in messages {
let blocks = match &msg.content {
anthropic::Content::Text(_) => continue,
anthropic::Content::Blocks(b) => b,
};
for block in blocks {
if let anthropic::ContentBlock::ToolUse { id, name, .. } = block {
map.insert(id.clone(), name.clone());
}
}
}
map
}
/// Merge consecutive same-role `Content` entries by concatenating their parts.
///
/// Gemini requires strict user/model role alternation. When the Anthropic
/// conversation has two consecutive user (or model) turns, this merges them
/// into a single turn.
pub fn merge_consecutive_roles(contents: Vec<gemini::Content>) -> Vec<gemini::Content> {
let mut merged: Vec<gemini::Content> = Vec::with_capacity(contents.len());
for c in contents {
if let Some(last) = merged.last_mut() {
if last.role == c.role {
last.parts.extend(c.parts);
continue;
}
}
merged.push(c);
}
// Gemini requires the first content turn to have role "user". An Anthropic
// client may legally send an assistant-first conversation (for few-shot
// prompting). Prepend a dummy user turn so Gemini does not return a 400.
if merged.first().and_then(|c| c.role.as_deref()) == Some("model") {
merged.insert(
0,
gemini::Content {
role: Some("user".to_string()),
parts: vec![gemini::Part::text(String::new())],
},
);
}
merged
}
/// Convert Anthropic message content into a vec of Gemini Parts.
fn content_blocks_to_parts(
content: &anthropic::Content,
tool_id_map: &HashMap<String, String>,
) -> Vec<gemini::Part> {
match content {
anthropic::Content::Text(s) => vec![gemini::Part::text(s.clone())],
anthropic::Content::Blocks(blocks) => blocks
.iter()
.filter_map(|block| content_block_to_part(block, tool_id_map))
.collect(),
}
}
/// Convert a single Anthropic ContentBlock to a Gemini Part, or None if dropped.
fn content_block_to_part(
block: &anthropic::ContentBlock,
tool_id_map: &HashMap<String, String>,
) -> Option<gemini::Part> {
match block {
anthropic::ContentBlock::Text { text } => Some(gemini::Part::text(text.clone())),
anthropic::ContentBlock::Image { source } => {
// Gemini only supports inline base64 data, not URLs.
if source.source_type == "base64" {
let mime = source
.media_type
.clone()
.unwrap_or_else(|| "image/png".into());
let data = source.data.clone().unwrap_or_default();
Some(gemini::Part::inline_data(mime, data))
} else {
// URL-type images cannot be sent as inline_data; drop.
None
}
}
anthropic::ContentBlock::ToolUse { name, input, .. } => {
// Strip the Anthropic tool_use id; Gemini uses name-based correlation.
Some(gemini::Part::function_call(name.clone(), input.clone()))
}
anthropic::ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => {
let Some(name) = tool_id_map.get(tool_use_id).cloned() else {
// Gemini requires the function name to match a declared FunctionDeclaration.
// Emitting an unknown name causes a 400; drop the result instead.
tracing::warn!(
tool_use_id,
"dropping ToolResult: tool_use_id not found in tool_id_map"
);
return None;
};
let response_value = tool_result_to_json(content, *is_error);
Some(gemini::Part::function_response(name, response_value))
}
// Thinking, RedactedThinking, Document: not supported by Gemini, drop.
anthropic::ContentBlock::Thinking { .. }
| anthropic::ContentBlock::RedactedThinking { .. }
| anthropic::ContentBlock::Document { .. } => None,
_ => None,
}
}
/// Convert Anthropic ToolResult content into a JSON value for Gemini FunctionResponse.
fn tool_result_to_json(
content: &Option<anthropic::ToolResultContent>,
is_error: Option<bool>,
) -> serde_json::Value {
let text = match content {
Some(anthropic::ToolResultContent::Text(s)) => s.clone(),
Some(anthropic::ToolResultContent::Blocks(blocks)) => {
// Concatenate text blocks; other block types (e.g., images) cannot be
// represented in Gemini FunctionResponse and are replaced with a placeholder.
blocks
.iter()
.map(|b| match b {
anthropic::ContentBlock::Text { text } => text.clone(),
_ => {
tracing::warn!(
"tool_result contains non-text block; \
replacing with \"[non-text]\" placeholder for Gemini"
);
"[non-text]".into()
}
})
.collect::<Vec<_>>()
.join("\n")
}
None => String::new(),
};
if is_error == Some(true) {
serde_json::json!({ "error": text })
} else {
serde_json::json!({ "result": text })
}
}
// ---------------------------------------------------------------------------
// Response direction: Gemini -> Anthropic
// ---------------------------------------------------------------------------
/// Convert a Gemini `GenerateContentResponse` into an Anthropic `MessageResponse`.
///
/// Uses only the first candidate. Synthesizes Anthropic-format tool IDs for any
/// function calls.
pub fn gemini_to_anthropic_response(
resp: &gemini_resp::GenerateContentResponse,
model: &str,
) -> anthropic::MessageResponse {
let candidate = resp.candidates.first();
let content = candidate
.map(|c| {
c.content
.parts
.iter()
.filter_map(gemini_part_to_content_block)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let has_function_call = content
.iter()
.any(|b| matches!(b, anthropic::ContentBlock::ToolUse { .. }));
let stop_reason = candidate
.and_then(|c| c.finish_reason.as_ref())
.map(|fr| match fr {
gemini_resp::FinishReason::STOP if has_function_call => anthropic::StopReason::ToolUse,
gemini_resp::FinishReason::STOP => anthropic::StopReason::EndTurn,
gemini_resp::FinishReason::MAX_TOKENS => anthropic::StopReason::MaxTokens,
// SAFETY, RECITATION, LANGUAGE, OTHER, Unknown all map to EndTurn.
_ => anthropic::StopReason::EndTurn,
})
// No finish_reason at all (e.g. empty candidates) -> EndTurn.
.or(if candidate.is_some() {
Some(anthropic::StopReason::EndTurn)
} else {
None
});
let usage = resp
.usage_metadata
.as_ref()
.map(|u| anthropic::Usage {
input_tokens: u.prompt_token_count,
output_tokens: u.candidates_token_count,
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
..Default::default()
})
.unwrap_or_default();
anthropic::MessageResponse {
id: generate_message_id(),
response_type: "message".into(),
role: anthropic::Role::Assistant,
content,
model: model.to_string(),
stop_reason,
stop_sequence: None,
usage,
created: None,
}
}
/// Convert a single Gemini Part to an Anthropic ContentBlock, or None if not mappable.
fn gemini_part_to_content_block(part: &gemini::Part) -> Option<anthropic::ContentBlock> {
// Thought parts from thinking models map to Anthropic thinking blocks.
if part.thought == Some(true) {
return part
.text
.as_ref()
.map(|text| anthropic::ContentBlock::Thinking {
thinking: text.clone(),
signature: None,
});
}
if let Some(text) = &part.text {
return Some(anthropic::ContentBlock::Text { text: text.clone() });
}
if let Some(fc) = &part.function_call {
return Some(anthropic::ContentBlock::ToolUse {
id: generate_tool_use_id(),
name: fc.name.clone(),
input: fc.args.clone(),
});
}
// inline_data, file_data, function_response: not expected in model output,
// or have no Anthropic equivalent. Drop.
None
}
// ---------------------------------------------------------------------------
// Input direction: Gemini CLI -> Anthropic (for accepting Gemini-format input)
// ---------------------------------------------------------------------------
/// Convert a Gemini CLI `GenerateContentRequest` into an Anthropic `MessageCreateRequest`.
///
/// `model` is the model name extracted from the URL path (e.g. `gemini-2.5-pro` from
/// `POST /v1beta/models/gemini-2.5-pro:generateContent`). All generation config fields
/// map directly; unsupported Gemini features (safety settings, response_schema) are dropped.
pub fn gemini_to_anthropic_request(
req: &gemini::GenerateContentRequest,
model: &str,
) -> anthropic::MessageCreateRequest {
// Build name->id map so that function_response parts reference the same
// synthetic tool_use_id as the corresponding function_call part.
let mut name_to_id: HashMap<String, String> = HashMap::new();
for content in &req.contents {
for part in &content.parts {
if let Some(ref fc) = part.function_call {
name_to_id
.entry(fc.name.clone())
.or_insert_with(generate_tool_use_id);
}
}
}
// System instruction -> system
let system = req.system_instruction.as_ref().map(|si| {
let text = si
.parts
.iter()
.filter_map(|p| p.text.as_deref())
.collect::<Vec<_>>()
.join("\n");
anthropic::System::Text(text)
});
// Contents -> messages
let messages: Vec<anthropic::InputMessage> = req
.contents
.iter()
.filter_map(|c| gemini_content_to_input_message(c, &name_to_id))
.collect();
// Generation config
let gc = req.generation_config.as_ref();
let max_tokens = gc.and_then(|g| g.max_output_tokens).unwrap_or(8192);
let temperature = gc.and_then(|g| g.temperature);
let top_p = gc.and_then(|g| g.top_p);
let top_k = gc.and_then(|g| g.top_k);
let stop_sequences = gc
.and_then(|g| g.stop_sequences.clone())
.filter(|v| !v.is_empty());
// Tools
let tools = req.tools.as_ref().map(|ts| {
ts.iter()
.flat_map(|t| t.function_declarations.iter())
.map(|fd| anthropic::Tool {
name: fd.name.clone(),
description: fd.description.clone(),
input_schema: fd
.parameters
.clone()
.unwrap_or_else(|| serde_json::json!({"type": "object"})),
})
.collect::<Vec<_>>()
});
// Tool choice
let tool_choice = req.tool_config.as_ref().map(|tc| {
match tc.function_calling_config.mode.as_str() {
"NONE" => anthropic::ToolChoice::None,
"ANY" => match tc.function_calling_config.allowed_function_names.as_deref() {
Some([name]) => anthropic::ToolChoice::Tool { name: name.clone() },
_ => anthropic::ToolChoice::Any {
disable_parallel_tool_use: None,
},
},
// AUTO or anything else
_ => anthropic::ToolChoice::Auto {
disable_parallel_tool_use: None,
},
}
});
anthropic::MessageCreateRequest {
model: model.to_string(),
max_tokens,
messages,
system,
temperature,
top_p,
top_k,
stop_sequences,
tools,
tool_choice,
metadata: None,
thinking: None,
stream: None,
extra: Default::default(),
}
}
/// Convert a single Gemini `Content` turn into an Anthropic `InputMessage`.
/// Returns `None` if the turn produces no content blocks (e.g. all parts were dropped).
fn gemini_content_to_input_message(
content: &gemini::Content,
name_to_id: &HashMap<String, String>,
) -> Option<anthropic::InputMessage> {
let role = match content.role.as_deref() {
Some("model") => anthropic::Role::Assistant,
// "user", None, or anything unrecognised -> user.
_ => anthropic::Role::User,
};
let blocks: Vec<anthropic::ContentBlock> = content
.parts
.iter()
.filter_map(|p| gemini_input_part_to_block(p, name_to_id))
.collect();
if blocks.is_empty() {
return None;
}
Some(anthropic::InputMessage {
role,
content: anthropic::Content::Blocks(blocks),
})
}
/// Convert a single Gemini `Part` from a user/model message into an Anthropic `ContentBlock`.
fn gemini_input_part_to_block(
part: &gemini::Part,
name_to_id: &HashMap<String, String>,
) -> Option<anthropic::ContentBlock> {
if let Some(ref text) = part.text {
return Some(anthropic::ContentBlock::Text { text: text.clone() });
}
if let Some(ref fc) = part.function_call {
let id = name_to_id
.get(&fc.name)
.cloned()
.unwrap_or_else(generate_tool_use_id);
return Some(anthropic::ContentBlock::ToolUse {
id,
name: fc.name.clone(),
input: fc.args.clone(),
});
}
if let Some(ref fr) = part.function_response {
let tool_use_id = name_to_id
.get(&fr.name)
.cloned()
.unwrap_or_else(generate_tool_use_id);
return Some(anthropic::ContentBlock::ToolResult {
tool_use_id,
content: Some(anthropic::ToolResultContent::Text(
serde_json::to_string(&fr.response).unwrap_or_default(),
)),
is_error: None,
});
}
if let Some(ref data) = part.inline_data {
if data.mime_type.starts_with("image/") {
return Some(anthropic::ContentBlock::Image {
source: anthropic::ImageSource {
source_type: "base64".to_string(),
media_type: Some(data.mime_type.clone()),
data: Some(data.data.clone()),
url: None,
},
});
}
// Non-image inline data (audio, video, etc.) has no Anthropic equivalent — drop.
tracing::warn!(
mime_type = %data.mime_type,
"dropping inline_data part with non-image mime type (no Anthropic equivalent)"
);
}
None
}
/// Convert an Anthropic `MessageResponse` into a Gemini `GenerateContentResponse`.
///
/// Used when the proxy accepts Gemini CLI input and must return Gemini-format output.
/// This is the inverse of `gemini_to_anthropic_response`.
pub fn anthropic_to_gemini_response(
resp: &anthropic::MessageResponse,
) -> gemini_resp::GenerateContentResponse {
let parts: Vec<gemini::Part> = resp
.content
.iter()
.filter_map(|block| match block {
anthropic::ContentBlock::Text { text } => Some(gemini::Part::text(text.clone())),
anthropic::ContentBlock::ToolUse { name, input, .. } => {
Some(gemini::Part::function_call(name.clone(), input.clone()))
}
anthropic::ContentBlock::Thinking { thinking, .. } => Some(gemini::Part {
thought: Some(true),
text: Some(thinking.clone()),
..Default::default()
}),
// ToolResult, Image, Document, RedactedThinking: not expected in model output.
_ => None,
})
.collect();
let finish_reason = resp.stop_reason.as_ref().map(|sr| match sr {
anthropic::StopReason::EndTurn | anthropic::StopReason::ToolUse => {
gemini_resp::FinishReason::STOP
}
anthropic::StopReason::MaxTokens => gemini_resp::FinishReason::MAX_TOKENS,
anthropic::StopReason::StopSequence => gemini_resp::FinishReason::STOP,
_ => gemini_resp::FinishReason::STOP,
});
let candidate = gemini_resp::Candidate {
content: gemini::Content {
role: Some("model".to_string()),
parts,
},
finish_reason,
safety_ratings: None,
};
gemini_resp::GenerateContentResponse {
candidates: vec![candidate],
usage_metadata: Some(gemini_resp::UsageMetadata {
prompt_token_count: resp.usage.input_tokens,
candidates_token_count: resp.usage.output_tokens,
total_token_count: resp.usage.input_tokens + resp.usage.output_tokens,
cached_content_token_count: 0,
}),
model_version: Some(resp.model.clone()),
}
}
pub use helpers::{build_tool_id_map, merge_consecutive_roles};
pub use request::{anthropic_to_gemini_request, compute_gemini_request_warnings};
pub use response::gemini_to_anthropic_response;
pub use reverse::{anthropic_to_gemini_response, gemini_to_anthropic_request};
#[cfg(test)]
mod tests;
@@ -0,0 +1,60 @@
// Common translation utilities shared across the Gemini message-map directions:
// tool-id lookup and Gemini role-alternation merging.
use std::collections::HashMap;
use crate::anthropic::messages as anthropic;
use crate::gemini::request as gemini;
/// Build a map from Anthropic tool_use IDs to tool names.
///
/// Scans all messages for `ToolUse` blocks so that `ToolResult` translation can
/// look up the function name Gemini expects.
pub fn build_tool_id_map(messages: &[anthropic::InputMessage]) -> HashMap<String, String> {
let mut map = HashMap::new();
for msg in messages {
let blocks = match &msg.content {
anthropic::Content::Text(_) => continue,
anthropic::Content::Blocks(b) => b,
};
for block in blocks {
if let anthropic::ContentBlock::ToolUse { id, name, .. } = block {
map.insert(id.clone(), name.clone());
}
}
}
map
}
/// Merge consecutive same-role `Content` entries by concatenating their parts.
///
/// Gemini requires strict user/model role alternation. When the Anthropic
/// conversation has two consecutive user (or model) turns, this merges them
/// into a single turn.
pub fn merge_consecutive_roles(contents: Vec<gemini::Content>) -> Vec<gemini::Content> {
let mut merged: Vec<gemini::Content> = Vec::with_capacity(contents.len());
for c in contents {
if let Some(last) = merged.last_mut() {
if last.role == c.role {
last.parts.extend(c.parts);
continue;
}
}
merged.push(c);
}
// Gemini requires the first content turn to have role "user". An Anthropic
// client may legally send an assistant-first conversation (for few-shot
// prompting). Prepend a dummy user turn so Gemini does not return a 400.
if merged.first().and_then(|c| c.role.as_deref()) == Some("model") {
merged.insert(
0,
gemini::Content {
role: Some("user".to_string()),
parts: vec![gemini::Part::text(String::new())],
},
);
}
merged
}
@@ -0,0 +1,286 @@
// Request direction: Anthropic -> Gemini.
use std::collections::HashMap;
use super::helpers::{build_tool_id_map, merge_consecutive_roles};
use crate::anthropic::messages as anthropic;
use crate::gemini::request as gemini;
use crate::mapping::tools_map::sanitize_schema_for_gemini;
/// Compute degradation warnings for a Gemini-bound request.
///
/// Call before translating to surface features that are silently dropped during
/// Anthropic → Gemini translation. Emit the result as an `x-anyllm-degradation`
/// response header so clients can detect lossy translations.
pub fn compute_gemini_request_warnings(
req: &anthropic::MessageCreateRequest,
) -> crate::mapping::warnings::TranslationWarnings {
use crate::mapping::warnings::TranslationWarnings;
let mut w = TranslationWarnings::default();
// Single pass: collect all per-block warning flags at once.
let mut has_thinking = false;
let mut has_document = false;
let mut has_url_image = false;
for msg in &req.messages {
if let anthropic::Content::Blocks(blocks) = &msg.content {
for b in blocks {
match b {
// Thinking/RedactedThinking: no Gemini Content equivalent.
anthropic::ContentBlock::Thinking { .. }
| anthropic::ContentBlock::RedactedThinking { .. } => has_thinking = true,
// Document blocks have no Gemini equivalent.
anthropic::ContentBlock::Document { .. } => has_document = true,
// URL-type images: Gemini only accepts inline base64 data.
anthropic::ContentBlock::Image { source } if source.source_type != "base64" => {
has_url_image = true
}
_ => {}
}
}
}
}
if has_thinking {
w.add("thinking_blocks");
}
if has_document {
w.add("document_blocks");
}
if has_url_image {
w.add("url_images");
}
// cache_control on system blocks is dropped; Gemini has no prompt-caching API.
if let Some(anthropic::System::Blocks(blocks)) = &req.system {
if blocks.iter().any(|b| b.cache_control.is_some()) {
w.add("cache_control");
}
}
w
}
/// Convert an Anthropic `MessageCreateRequest` into a Gemini `GenerateContentRequest`.
///
/// Maps `thinking_config` to `generationConfig.thinkingConfig` for Gemini 2.5
/// thinking models. Drops unsupported features (thinking content blocks in prior
/// messages, document blocks, cache_control) and merges consecutive same-role
/// messages to satisfy Gemini's strict alternation requirement.
pub fn anthropic_to_gemini_request(
req: &anthropic::MessageCreateRequest,
) -> gemini::GenerateContentRequest {
let tool_id_map = build_tool_id_map(&req.messages);
// System instruction
let system_instruction = req.system.as_ref().map(|sys| {
let text = match sys {
anthropic::System::Text(s) => s.clone(),
anthropic::System::Blocks(blocks) => blocks
.iter()
.map(|b| b.text.as_str())
.collect::<Vec<_>>()
.join("\n"),
};
gemini::Content {
role: None,
parts: vec![gemini::Part::text(text)],
}
});
// Convert messages
let mut contents: Vec<gemini::Content> = Vec::new();
for msg in &req.messages {
let role = match msg.role {
anthropic::Role::User => "user",
anthropic::Role::Assistant => "model",
};
let parts = content_blocks_to_parts(&msg.content, &tool_id_map);
if !parts.is_empty() {
contents.push(gemini::Content {
role: Some(role.to_string()),
parts,
});
}
}
contents = merge_consecutive_roles(contents);
// Tools
let tools = req.tools.as_ref().map(|tools| {
vec![gemini::Tool {
function_declarations: tools
.iter()
.map(|t| gemini::FunctionDeclaration {
name: t.name.clone(),
description: t.description.clone(),
parameters: Some(sanitize_schema_for_gemini(t.input_schema.clone())),
})
.collect(),
}]
});
// Tool config
let tool_config = req.tool_choice.as_ref().map(|tc| {
if matches!(
tc,
anthropic::ToolChoice::Auto {
disable_parallel_tool_use: Some(true)
} | anthropic::ToolChoice::Any {
disable_parallel_tool_use: Some(true)
}
) {
tracing::warn!(
"disable_parallel_tool_use=true is not supported by Gemini; \
parallel tool calls may still occur"
);
}
let (mode, allowed) = match tc {
anthropic::ToolChoice::Auto { .. } => ("AUTO", None),
anthropic::ToolChoice::Any { .. } => ("ANY", None),
anthropic::ToolChoice::None => ("NONE", None),
// Gemini ANY + allowedFunctionNames restricts to a specific tool.
anthropic::ToolChoice::Tool { name, .. } => ("ANY", Some(vec![name.clone()])),
};
gemini::ToolConfig {
function_calling_config: gemini::FunctionCallingConfig {
mode: mode.to_string(),
allowed_function_names: allowed,
},
}
});
// Generation config
let generation_config = {
let thinking_config =
if let Some(anthropic::ThinkingConfig::Enabled { budget_tokens }) = &req.thinking {
Some(gemini::ThinkingConfig {
thinking_budget: *budget_tokens,
include_thoughts: Some(true),
})
} else {
None
};
let gc = gemini::GenerationConfig {
max_output_tokens: Some(req.max_tokens),
temperature: req.temperature,
top_p: req.top_p,
top_k: req.top_k,
stop_sequences: req.stop_sequences.clone(),
thinking_config,
..Default::default()
};
Some(gc)
};
gemini::GenerateContentRequest {
contents,
system_instruction,
generation_config,
tools,
tool_config,
safety_settings: None,
}
}
/// Convert Anthropic message content into a vec of Gemini Parts.
fn content_blocks_to_parts(
content: &anthropic::Content,
tool_id_map: &HashMap<String, String>,
) -> Vec<gemini::Part> {
match content {
anthropic::Content::Text(s) => vec![gemini::Part::text(s.clone())],
anthropic::Content::Blocks(blocks) => blocks
.iter()
.filter_map(|block| content_block_to_part(block, tool_id_map))
.collect(),
}
}
/// Convert a single Anthropic ContentBlock to a Gemini Part, or None if dropped.
fn content_block_to_part(
block: &anthropic::ContentBlock,
tool_id_map: &HashMap<String, String>,
) -> Option<gemini::Part> {
match block {
anthropic::ContentBlock::Text { text } => Some(gemini::Part::text(text.clone())),
anthropic::ContentBlock::Image { source } => {
// Gemini only supports inline base64 data, not URLs.
if source.source_type == "base64" {
let mime = source
.media_type
.clone()
.unwrap_or_else(|| "image/png".into());
let data = source.data.clone().unwrap_or_default();
Some(gemini::Part::inline_data(mime, data))
} else {
// URL-type images cannot be sent as inline_data; drop.
None
}
}
anthropic::ContentBlock::ToolUse { name, input, .. } => {
// Strip the Anthropic tool_use id; Gemini uses name-based correlation.
Some(gemini::Part::function_call(name.clone(), input.clone()))
}
anthropic::ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => {
let Some(name) = tool_id_map.get(tool_use_id).cloned() else {
// Gemini requires the function name to match a declared FunctionDeclaration.
// Emitting an unknown name causes a 400; drop the result instead.
tracing::warn!(
tool_use_id,
"dropping ToolResult: tool_use_id not found in tool_id_map"
);
return None;
};
let response_value = tool_result_to_json(content, *is_error);
Some(gemini::Part::function_response(name, response_value))
}
// Thinking, RedactedThinking, Document: not supported by Gemini, drop.
anthropic::ContentBlock::Thinking { .. }
| anthropic::ContentBlock::RedactedThinking { .. }
| anthropic::ContentBlock::Document { .. } => None,
_ => None,
}
}
/// Convert Anthropic ToolResult content into a JSON value for Gemini FunctionResponse.
fn tool_result_to_json(
content: &Option<anthropic::ToolResultContent>,
is_error: Option<bool>,
) -> serde_json::Value {
let text = match content {
Some(anthropic::ToolResultContent::Text(s)) => s.clone(),
Some(anthropic::ToolResultContent::Blocks(blocks)) => {
// Concatenate text blocks; other block types (e.g., images) cannot be
// represented in Gemini FunctionResponse and are replaced with a placeholder.
blocks
.iter()
.map(|b| match b {
anthropic::ContentBlock::Text { text } => text.clone(),
_ => {
tracing::warn!(
"tool_result contains non-text block; \
replacing with \"[non-text]\" placeholder for Gemini"
);
"[non-text]".into()
}
})
.collect::<Vec<_>>()
.join("\n")
}
None => String::new(),
};
if is_error == Some(true) {
serde_json::json!({ "error": text })
} else {
serde_json::json!({ "result": text })
}
}
@@ -0,0 +1,98 @@
// Response direction: Gemini -> Anthropic.
use crate::anthropic::messages as anthropic;
use crate::gemini::request as gemini;
use crate::gemini::response as gemini_resp;
use crate::util::ids::{generate_message_id, generate_tool_use_id};
/// Convert a Gemini `GenerateContentResponse` into an Anthropic `MessageResponse`.
///
/// Uses only the first candidate. Synthesizes Anthropic-format tool IDs for any
/// function calls.
pub fn gemini_to_anthropic_response(
resp: &gemini_resp::GenerateContentResponse,
model: &str,
) -> anthropic::MessageResponse {
let candidate = resp.candidates.first();
let content = candidate
.map(|c| {
c.content
.parts
.iter()
.filter_map(gemini_part_to_content_block)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let has_function_call = content
.iter()
.any(|b| matches!(b, anthropic::ContentBlock::ToolUse { .. }));
let stop_reason = candidate
.and_then(|c| c.finish_reason.as_ref())
.map(|fr| match fr {
gemini_resp::FinishReason::STOP if has_function_call => anthropic::StopReason::ToolUse,
gemini_resp::FinishReason::STOP => anthropic::StopReason::EndTurn,
gemini_resp::FinishReason::MAX_TOKENS => anthropic::StopReason::MaxTokens,
// SAFETY, RECITATION, LANGUAGE, OTHER, Unknown all map to EndTurn.
_ => anthropic::StopReason::EndTurn,
})
// No finish_reason at all (e.g. empty candidates) -> EndTurn.
.or(if candidate.is_some() {
Some(anthropic::StopReason::EndTurn)
} else {
None
});
let usage = resp
.usage_metadata
.as_ref()
.map(|u| anthropic::Usage {
input_tokens: u.prompt_token_count,
output_tokens: u.candidates_token_count,
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
..Default::default()
})
.unwrap_or_default();
anthropic::MessageResponse {
id: generate_message_id(),
response_type: "message".into(),
role: anthropic::Role::Assistant,
content,
model: model.to_string(),
stop_reason,
stop_sequence: None,
usage,
created: None,
}
}
/// Convert a single Gemini Part to an Anthropic ContentBlock, or None if not mappable.
fn gemini_part_to_content_block(part: &gemini::Part) -> Option<anthropic::ContentBlock> {
// Thought parts from thinking models map to Anthropic thinking blocks.
if part.thought == Some(true) {
return part
.text
.as_ref()
.map(|text| anthropic::ContentBlock::Thinking {
thinking: text.clone(),
signature: None,
});
}
if let Some(text) = &part.text {
return Some(anthropic::ContentBlock::Text { text: text.clone() });
}
if let Some(fc) = &part.function_call {
return Some(anthropic::ContentBlock::ToolUse {
id: generate_tool_use_id(),
name: fc.name.clone(),
input: fc.args.clone(),
});
}
// inline_data, file_data, function_response: not expected in model output,
// or have no Anthropic equivalent. Drop.
None
}

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