mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(llm): add Anthropic prompt caching and cache token tracking - Inject cache_control via additional_params for Claude models in rig_adapter - Add cache_read_input_tokens and cache_creation_input_tokens to CompletionResponse and ToolCompletionResponse - Extract cached_input_tokens from rig-core unified Usage - Add is_anthropic_model() detection helper with provider prefix support - Log prompt cache hits at debug level (consistent with response_cache) - Add 7 unit tests for cache injection and model detection - Update all mock providers and test fixtures with new fields * feat(cost): apply 90% cache discount to prompt-cached tokens in CostGuard - Add cache_read_input_tokens to TokenUsage so cache counts flow from CompletionResponse through the reasoning layer to the dispatcher - Update CostGuard::record_llm_call() to accept cache_read_input_tokens: cached tokens are billed at 10% of the normal input rate - Thread cache_read_input_tokens from dispatcher into CostGuard - Add test_cache_discount_reduces_cost verifying exact savings match 90% of input cost for fully-cached requests - Update all existing test callers with zero-cache parameter * refactor(cache): scope cache_control to Anthropic backend and validate model support - Replace model-name-based is_anthropic_model() with explicit enable_prompt_cache flag on RigAdapter, set only for the direct Anthropic backend via with_prompt_cache(true) - Add supports_prompt_cache() to validate model names per Anthropic docs: only Claude 3+ models support caching; claude-2 and claude-instant are excluded to prevent 400 errors - Warn when caching is enabled but model does not support it - Replace is_anthropic_model tests with flag-based and model validation tests * fix(cache): validate model at construction and propagate cache metrics through proxy - Move supports_prompt_cache() check into with_prompt_cache() so unsupported models are detected once at construction, not per request - Add cache_read_input_tokens and cache_creation_input_tokens to ProxyCompletionResponse and ProxyToolCompletionResponse with serde(default) for backward compatibility - Pass cache metrics through orchestrator proxy instead of zeroing - Use claude-opus-4-6 in cache discount test to match Anthropic semantics * feat(llm): add configurable cache retention with write surcharge - Add CacheRetention enum (none/short/long) to AnthropicDirectConfig - Parse ANTHROPIC_CACHE_RETENTION env var (default: short) - Inject TTL-aware cache_control (short=5m ephemeral, long=1h) - Extract cache_creation_input_tokens from raw Anthropic response - Add cache_write_multiplier() to LlmProvider trait (1.25x short, 2.0x long) - Pipe dynamic write multiplier through dispatcher to CostGuard - Add TokenUsage.cache_creation_input_tokens field - Add tests for Long TTL injection, 5m and 1h write surcharges - Document ANTHROPIC_CACHE_RETENTION in .env.example * docs: fix stale cache_retention field comment * fix: resolve CI failures after upstream merge - Add missing cost_per_token arg to cache test callsites - Apply cargo fmt to long lines in tests and tracing macros * fix: address Copilot review feedback - Use saturating_add for cache token sum to prevent u32 overflow - Tighten supports_prompt_cache to explicitly match claude-3+/claude-4+ and named families (claude-sonnet/claude-opus/claude-haiku) * fix: adapt prompt caching to registry architecture and add missing cache fields - Resolve merge conflicts: adapt CacheRetention and cache injection to the declarative provider registry (RegistryProviderConfig replaces AnthropicDirectConfig) - Parse ANTHROPIC_CACHE_RETENTION env var in create_anthropic_from_registry() - Use Anthropic automatic caching via top-level cache_control in additional_params (rig-core #[serde(flatten)] places it at request root) - Add cache_read/creation_input_tokens fields to all mock LlmProviders added on main after PR #291 branched (response_cache, dispatcher, provider_chaos, trace_llm) - Suppress clippy::too_many_arguments on record_llm_call and build_rig_request - Add regression tests for cache injection (short/long/none) and cache_write_multiplier values Co-Authored-By: Canvinus <[email protected]> * fix: delegate cache_write_multiplier through provider wrappers and make cache_read_discount configurable The 6 decorator providers (Retry, CircuitBreaker, Failover, SmartRouting, CachedProvider, RecordingLlm) did not delegate cache_write_multiplier() to their inner provider, causing it to always return 1.0 instead of the actual 1.25x/2.0x from RigAdapter. This fix adds delegation for both cache_write_multiplier() and the new cache_read_discount() method. Also makes the cache read discount per-provider instead of hardcoding Anthropic's 90% discount (÷10). OpenAI uses 50% (÷2), so the discount is now returned by each provider via the LlmProvider trait. Addresses review feedback on PR #660. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * test: add CacheRetention FromStr/Display unit tests Tests cover primary values, aliases (off/disabled/5m/ephemeral/1h), case-insensitivity, invalid input error, and Display round-trip. Addresses Copilot review feedback on PR #660. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Andrey <[email protected]> Co-authored-by: Andrey Gruzdev <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
650 lines
24 KiB
Rust
650 lines
24 KiB
Rust
//! TraceLlm -- a replay-based LLM provider for E2E testing.
|
|
//!
|
|
//! Replays canned responses from a JSON trace, advancing through steps
|
|
//! sequentially. Supports both text and tool-call responses with optional
|
|
//! request-hint validation.
|
|
|
|
use std::path::Path;
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
use async_trait::async_trait;
|
|
use rust_decimal::Decimal;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use ironclaw::error::LlmError;
|
|
use ironclaw::llm::{
|
|
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
|
|
ToolCompletionRequest, ToolCompletionResponse,
|
|
};
|
|
|
|
// Re-export shared types from recording module so existing test code can
|
|
// still import them from here.
|
|
// Re-export all shared types so downstream test files can import from here.
|
|
#[allow(unused_imports)]
|
|
pub use ironclaw::llm::recording::{
|
|
ExpectedToolResult, HttpExchange, HttpExchangeRequest, HttpExchangeResponse,
|
|
MemorySnapshotEntry, RequestHint, TraceResponse, TraceStep, TraceToolCall,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trace types (test-only wrappers around shared recording types)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A single turn in a trace: one user message and the LLM response steps that follow.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TraceTurn {
|
|
pub user_input: String,
|
|
pub steps: Vec<TraceStep>,
|
|
/// Declarative expectations for this turn (optional).
|
|
#[serde(default, skip_serializing_if = "TraceExpects::is_empty")]
|
|
pub expects: TraceExpects,
|
|
}
|
|
|
|
/// A complete LLM trace: a model name and an ordered list of turns.
|
|
///
|
|
/// Each turn pairs a user message with the LLM response steps that follow it.
|
|
/// For JSON backward compatibility, traces with a flat top-level `"steps"` array
|
|
/// (no `"turns"`) are deserialized into turns by splitting at `UserInput` boundaries.
|
|
///
|
|
/// Recorded traces (from `RecordingLlm`) may also include `memory_snapshot`,
|
|
/// `http_exchanges`, and `user_input` response steps.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct LlmTrace {
|
|
pub model_name: String,
|
|
pub turns: Vec<TraceTurn>,
|
|
/// Workspace memory documents captured before the recording session.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub memory_snapshot: Vec<MemorySnapshotEntry>,
|
|
/// HTTP exchanges recorded during the session, in order.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub http_exchanges: Vec<HttpExchange>,
|
|
/// Declarative expectations for the whole trace (optional).
|
|
#[serde(default, skip_serializing_if = "TraceExpects::is_empty")]
|
|
pub expects: TraceExpects,
|
|
/// Raw steps before turn conversion (populated only for recorded traces).
|
|
/// Used by `playable_steps()` for recorded-format inspection.
|
|
#[serde(skip)]
|
|
#[allow(dead_code)]
|
|
pub steps: Vec<TraceStep>,
|
|
}
|
|
|
|
/// Declarative expectations for a trace or turn.
|
|
///
|
|
/// All fields are optional and default to empty/None, so traces without
|
|
/// `expects` work unchanged (backward compatible).
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct TraceExpects {
|
|
/// Each string must appear in the response (case-insensitive).
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub response_contains: Vec<String>,
|
|
/// None of these may appear in the response (case-insensitive).
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub response_not_contains: Vec<String>,
|
|
/// Regex that must match the response.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub response_matches: Option<String>,
|
|
/// Each tool name must appear in started calls.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub tools_used: Vec<String>,
|
|
/// None of these tool names may appear.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub tools_not_used: Vec<String>,
|
|
/// If true, all tools must succeed.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub all_tools_succeeded: Option<bool>,
|
|
/// Upper bound on tool call count.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_tool_calls: Option<usize>,
|
|
/// Minimum response count.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub min_responses: Option<usize>,
|
|
/// Tool result preview must contain substring (tool_name -> substring).
|
|
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
|
|
pub tool_results_contain: std::collections::HashMap<String, String>,
|
|
/// Tools must have been called in this relative order.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub tools_order: Vec<String>,
|
|
}
|
|
|
|
impl TraceExpects {
|
|
/// Returns true if no expectations are set.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.response_contains.is_empty()
|
|
&& self.response_not_contains.is_empty()
|
|
&& self.response_matches.is_none()
|
|
&& self.tools_used.is_empty()
|
|
&& self.tools_not_used.is_empty()
|
|
&& self.all_tools_succeeded.is_none()
|
|
&& self.max_tool_calls.is_none()
|
|
&& self.min_responses.is_none()
|
|
&& self.tool_results_contain.is_empty()
|
|
&& self.tools_order.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Raw deserialization helper -- accepts either `turns` or flat `steps`.
|
|
#[derive(Deserialize)]
|
|
struct RawLlmTrace {
|
|
model_name: String,
|
|
#[serde(default)]
|
|
steps: Vec<TraceStep>,
|
|
#[serde(default)]
|
|
turns: Vec<TraceTurn>,
|
|
#[serde(default)]
|
|
memory_snapshot: Vec<MemorySnapshotEntry>,
|
|
#[serde(default)]
|
|
http_exchanges: Vec<HttpExchange>,
|
|
#[serde(default)]
|
|
expects: TraceExpects,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for LlmTrace {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let raw = RawLlmTrace::deserialize(deserializer)?;
|
|
// Keep the raw steps for `playable_steps()` inspection.
|
|
let raw_steps = raw.steps.clone();
|
|
let turns = if !raw.turns.is_empty() {
|
|
raw.turns
|
|
} else if !raw.steps.is_empty() {
|
|
// Split flat steps at UserInput boundaries into turns.
|
|
let mut turns = Vec::new();
|
|
let mut current_input = "(test input)".to_string();
|
|
let mut current_steps: Vec<TraceStep> = Vec::new();
|
|
|
|
for step in raw.steps {
|
|
if let TraceResponse::UserInput { ref content } = step.response {
|
|
// Flush accumulated steps as a turn (if any).
|
|
if !current_steps.is_empty() {
|
|
turns.push(TraceTurn {
|
|
user_input: current_input.clone(),
|
|
steps: std::mem::take(&mut current_steps),
|
|
expects: TraceExpects::default(),
|
|
});
|
|
}
|
|
current_input = content.clone();
|
|
} else {
|
|
current_steps.push(step);
|
|
}
|
|
}
|
|
|
|
// Flush remaining steps.
|
|
if !current_steps.is_empty() {
|
|
turns.push(TraceTurn {
|
|
user_input: current_input,
|
|
steps: current_steps,
|
|
expects: TraceExpects::default(),
|
|
});
|
|
}
|
|
|
|
turns
|
|
} else {
|
|
vec![]
|
|
};
|
|
Ok(LlmTrace {
|
|
model_name: raw.model_name,
|
|
turns,
|
|
memory_snapshot: raw.memory_snapshot,
|
|
http_exchanges: raw.http_exchanges,
|
|
expects: raw.expects,
|
|
steps: raw_steps,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
impl LlmTrace {
|
|
/// Create a trace from turns.
|
|
pub fn new(model_name: impl Into<String>, turns: Vec<TraceTurn>) -> Self {
|
|
Self {
|
|
model_name: model_name.into(),
|
|
turns,
|
|
memory_snapshot: Vec::new(),
|
|
http_exchanges: Vec::new(),
|
|
expects: TraceExpects::default(),
|
|
steps: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Convenience: create a single-turn trace (for simple tests).
|
|
pub fn single_turn(
|
|
model_name: impl Into<String>,
|
|
user_input: impl Into<String>,
|
|
steps: Vec<TraceStep>,
|
|
) -> Self {
|
|
Self {
|
|
model_name: model_name.into(),
|
|
turns: vec![TraceTurn {
|
|
user_input: user_input.into(),
|
|
steps,
|
|
expects: TraceExpects::default(),
|
|
}],
|
|
memory_snapshot: Vec::new(),
|
|
http_exchanges: Vec::new(),
|
|
expects: TraceExpects::default(),
|
|
steps: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Load a trace from a JSON file.
|
|
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let contents = std::fs::read_to_string(path)?;
|
|
let trace: Self = serde_json::from_str(&contents)?;
|
|
Ok(trace)
|
|
}
|
|
|
|
/// Replace all occurrences of `old` with `new` in tool call arguments,
|
|
/// text content, and user input throughout the trace.
|
|
///
|
|
/// Used to substitute hardcoded fixture paths (e.g. `/tmp/ironclaw_test`)
|
|
/// with dynamic `tempfile::tempdir()` paths so tests don't collide.
|
|
pub fn replace_paths(&mut self, old: &str, new: &str) {
|
|
for turn in &mut self.turns {
|
|
if turn.user_input.contains(old) {
|
|
turn.user_input = turn.user_input.replace(old, new);
|
|
}
|
|
for step in &mut turn.steps {
|
|
match &mut step.response {
|
|
TraceResponse::ToolCalls { tool_calls, .. } => {
|
|
for tc in tool_calls {
|
|
replace_in_json_value(&mut tc.arguments, old, new);
|
|
}
|
|
}
|
|
TraceResponse::Text { content, .. } => {
|
|
if content.contains(old) {
|
|
*content = content.replace(old, new);
|
|
}
|
|
}
|
|
TraceResponse::UserInput { content } => {
|
|
if content.contains(old) {
|
|
*content = content.replace(old, new);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Return only the playable steps from the raw steps (text + tool_calls),
|
|
/// skipping `user_input` markers. Only meaningful for recorded traces that
|
|
/// were deserialized from a flat `steps` array.
|
|
#[allow(dead_code)]
|
|
pub fn playable_steps(&self) -> Vec<&TraceStep> {
|
|
self.steps
|
|
.iter()
|
|
.filter(|s| !matches!(s.response, TraceResponse::UserInput { .. }))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// Recursively replace `old` with `new` in all string values within a JSON tree.
|
|
fn replace_in_json_value(value: &mut serde_json::Value, old: &str, new: &str) {
|
|
match value {
|
|
serde_json::Value::String(s) => {
|
|
if s.contains(old) {
|
|
*s = s.replace(old, new);
|
|
}
|
|
}
|
|
serde_json::Value::Object(map) => {
|
|
for v in map.values_mut() {
|
|
replace_in_json_value(v, old, new);
|
|
}
|
|
}
|
|
serde_json::Value::Array(arr) => {
|
|
for v in arr {
|
|
replace_in_json_value(v, old, new);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TraceLlm provider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// An `LlmProvider` that replays canned responses from a trace.
|
|
///
|
|
/// Steps from all turns are flattened into a single sequence at construction
|
|
/// time. The provider advances through them linearly regardless of turn
|
|
/// boundaries.
|
|
///
|
|
/// **Concurrency assumption:** Uses `AtomicUsize` for step indexing, so
|
|
/// concurrent calls to `complete`/`complete_with_tools` may consume steps
|
|
/// in non-deterministic order. Current tests are single-threaded per rig;
|
|
/// if parallel tool execution is ever enabled, steps may interleave.
|
|
pub struct TraceLlm {
|
|
model_name: String,
|
|
steps: Vec<TraceStep>,
|
|
index: AtomicUsize,
|
|
hint_mismatches: AtomicUsize,
|
|
captured_requests: Mutex<Vec<Vec<ChatMessage>>>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
impl TraceLlm {
|
|
/// Create from an in-memory trace.
|
|
pub fn from_trace(trace: LlmTrace) -> Self {
|
|
let steps: Vec<TraceStep> = trace.turns.into_iter().flat_map(|t| t.steps).collect();
|
|
Self {
|
|
model_name: trace.model_name,
|
|
steps,
|
|
index: AtomicUsize::new(0),
|
|
hint_mismatches: AtomicUsize::new(0),
|
|
captured_requests: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// Load from a JSON file and create the provider.
|
|
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let trace = LlmTrace::from_file(path)?;
|
|
Ok(Self::from_trace(trace))
|
|
}
|
|
|
|
/// Number of calls made so far.
|
|
pub fn calls(&self) -> usize {
|
|
self.index.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Number of request-hint mismatches observed (warnings only).
|
|
pub fn hint_mismatches(&self) -> usize {
|
|
self.hint_mismatches.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Clone of all captured request message lists.
|
|
pub fn captured_requests(&self) -> Vec<Vec<ChatMessage>> {
|
|
self.captured_requests.lock().unwrap().clone()
|
|
}
|
|
|
|
// -- internal helpers ---------------------------------------------------
|
|
|
|
/// Advance the step index and return the current step, or an error if exhausted.
|
|
///
|
|
/// Before returning, applies template substitution on tool_call arguments:
|
|
/// `{{call_id.json_path}}` is replaced with the value extracted from the
|
|
/// tool result message whose `tool_call_id` matches `call_id`. The
|
|
/// `json_path` is a dot-separated path into the JSON content of that tool
|
|
/// result (e.g., `{{call_cj_1.job_id}}` extracts `.job_id` from the result
|
|
/// of tool call `call_cj_1`).
|
|
fn next_step(&self, messages: &[ChatMessage]) -> Result<TraceStep, LlmError> {
|
|
// Capture the request messages.
|
|
self.captured_requests
|
|
.lock()
|
|
.unwrap()
|
|
.push(messages.to_vec());
|
|
|
|
let idx = self.index.fetch_add(1, Ordering::Relaxed);
|
|
let mut step = self
|
|
.steps
|
|
.get(idx)
|
|
.ok_or_else(|| LlmError::RequestFailed {
|
|
provider: self.model_name.clone(),
|
|
reason: format!(
|
|
"TraceLlm exhausted: called {} times but only {} steps",
|
|
idx + 1,
|
|
self.steps.len()
|
|
),
|
|
})?
|
|
.clone();
|
|
|
|
// Soft-validate request hints.
|
|
if let Some(ref hint) = step.request_hint {
|
|
self.validate_hint(hint, messages);
|
|
}
|
|
|
|
// Apply template substitution on tool_call arguments.
|
|
if let TraceResponse::ToolCalls {
|
|
ref mut tool_calls, ..
|
|
} = step.response
|
|
{
|
|
let vars = Self::extract_tool_result_vars(messages);
|
|
if !vars.is_empty() {
|
|
for tc in tool_calls.iter_mut() {
|
|
Self::substitute_templates(&mut tc.arguments, &vars);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(step)
|
|
}
|
|
|
|
fn validate_hint(&self, hint: &RequestHint, messages: &[ChatMessage]) {
|
|
if let Some(ref expected_substr) = hint.last_user_message_contains {
|
|
let last_user = messages.iter().rev().find(|m| matches!(m.role, Role::User));
|
|
let matched = last_user
|
|
.map(|m| m.content.contains(expected_substr.as_str()))
|
|
.unwrap_or(false);
|
|
if !matched {
|
|
self.hint_mismatches.fetch_add(1, Ordering::Relaxed);
|
|
eprintln!(
|
|
"[TraceLlm WARN] Request hint mismatch: expected last user message to contain {:?}, \
|
|
got {:?}",
|
|
expected_substr,
|
|
last_user.map(|m| &m.content),
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(min_count) = hint.min_message_count
|
|
&& messages.len() < min_count
|
|
{
|
|
self.hint_mismatches.fetch_add(1, Ordering::Relaxed);
|
|
eprintln!(
|
|
"[TraceLlm WARN] Request hint mismatch: expected >= {} messages, got {}",
|
|
min_count,
|
|
messages.len(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Build a map of `"call_id.json_path" -> resolved_value` from tool result
|
|
/// messages in the conversation. Each `Role::Tool` message with a
|
|
/// `tool_call_id` has its content parsed as JSON; all top-level
|
|
/// string/number/bool values are indexed so that `{{call_id.key}}` can be
|
|
/// resolved.
|
|
///
|
|
/// Tool results may be wrapped in `<tool_output>` XML tags by the safety
|
|
/// layer, so we strip those before parsing.
|
|
fn extract_tool_result_vars(
|
|
messages: &[ChatMessage],
|
|
) -> std::collections::HashMap<String, String> {
|
|
let mut vars = std::collections::HashMap::new();
|
|
for msg in messages {
|
|
if msg.role != Role::Tool {
|
|
continue;
|
|
}
|
|
let call_id = match &msg.tool_call_id {
|
|
Some(id) => id,
|
|
None => continue,
|
|
};
|
|
// Strip <tool_output ...>...</tool_output> wrapper if present.
|
|
let content = Self::unwrap_tool_output(&msg.content);
|
|
// Try parsing the content as JSON.
|
|
let json: serde_json::Value = match serde_json::from_str(&content) {
|
|
Ok(v) => v,
|
|
Err(_) => continue,
|
|
};
|
|
if let Some(obj) = json.as_object() {
|
|
for (key, val) in obj {
|
|
let str_val = match val {
|
|
serde_json::Value::String(s) => s.clone(),
|
|
serde_json::Value::Number(n) => n.to_string(),
|
|
serde_json::Value::Bool(b) => b.to_string(),
|
|
_ => continue,
|
|
};
|
|
vars.insert(format!("{call_id}.{key}"), str_val);
|
|
}
|
|
}
|
|
}
|
|
vars
|
|
}
|
|
|
|
/// Strip `<tool_output name="..." sanitized="...">...\n</tool_output>`
|
|
/// wrapper and unescape XML entities from safety-layer output.
|
|
fn unwrap_tool_output(content: &str) -> std::borrow::Cow<'_, str> {
|
|
let trimmed = content.trim();
|
|
if let Some(rest) = trimmed.strip_prefix("<tool_output")
|
|
&& let Some(tag_end) = rest.find('>')
|
|
{
|
|
let inner = &rest[tag_end + 1..];
|
|
if let Some(close) = inner.rfind("</tool_output>") {
|
|
let body = inner[..close].trim();
|
|
// Reverse XML escaping applied by safety layer.
|
|
if body.contains("&") || body.contains("<") || body.contains(">") {
|
|
return std::borrow::Cow::Owned(
|
|
body.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">"),
|
|
);
|
|
}
|
|
return std::borrow::Cow::Borrowed(body);
|
|
}
|
|
}
|
|
std::borrow::Cow::Borrowed(content)
|
|
}
|
|
|
|
/// Walk a JSON value and replace any string matching `{{call_id.path}}`
|
|
/// with the resolved value from the vars map. Operates in-place.
|
|
fn substitute_templates(
|
|
value: &mut serde_json::Value,
|
|
vars: &std::collections::HashMap<String, String>,
|
|
) {
|
|
match value {
|
|
serde_json::Value::String(s) => {
|
|
// Full-value replacement: if the entire string is `{{...}}`,
|
|
// replace the whole value (preserving type if possible).
|
|
if s.starts_with("{{") && s.ends_with("}}") && s.matches("{{").count() == 1 {
|
|
let key = s[2..s.len() - 2].trim();
|
|
if let Some(resolved) = vars.get(key) {
|
|
*s = resolved.clone();
|
|
return;
|
|
}
|
|
}
|
|
// Inline replacement: replace all `{{...}}` occurrences within the string.
|
|
let mut result = s.clone();
|
|
while let Some(start) = result.find("{{") {
|
|
if let Some(end) = result[start..].find("}}") {
|
|
let end = start + end + 2;
|
|
let key = result[start + 2..end - 2].trim();
|
|
if let Some(resolved) = vars.get(key) {
|
|
result = format!("{}{}{}", &result[..start], resolved, &result[end..]);
|
|
} else {
|
|
// Unresolved template — leave as-is and stop to avoid infinite loop.
|
|
break;
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
*s = result;
|
|
}
|
|
serde_json::Value::Object(map) => {
|
|
for val in map.values_mut() {
|
|
Self::substitute_templates(val, vars);
|
|
}
|
|
}
|
|
serde_json::Value::Array(arr) => {
|
|
for val in arr.iter_mut() {
|
|
Self::substitute_templates(val, vars);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl LlmProvider for TraceLlm {
|
|
fn model_name(&self) -> &str {
|
|
&self.model_name
|
|
}
|
|
|
|
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
|
(Decimal::ZERO, Decimal::ZERO)
|
|
}
|
|
|
|
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
|
let step = self.next_step(&request.messages)?;
|
|
match step.response {
|
|
TraceResponse::Text {
|
|
content,
|
|
input_tokens,
|
|
output_tokens,
|
|
} => Ok(CompletionResponse {
|
|
content,
|
|
input_tokens,
|
|
output_tokens,
|
|
finish_reason: FinishReason::Stop,
|
|
cache_read_input_tokens: 0,
|
|
cache_creation_input_tokens: 0,
|
|
}),
|
|
TraceResponse::ToolCalls { .. } => Err(LlmError::RequestFailed {
|
|
provider: self.model_name.clone(),
|
|
reason: "TraceLlm::complete() called but current step is a tool_calls response; \
|
|
use complete_with_tools() instead"
|
|
.to_string(),
|
|
}),
|
|
TraceResponse::UserInput { .. } => Err(LlmError::RequestFailed {
|
|
provider: self.model_name.clone(),
|
|
reason: "TraceLlm::complete() encountered a user_input step; \
|
|
these should have been filtered out during construction"
|
|
.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
async fn complete_with_tools(
|
|
&self,
|
|
request: ToolCompletionRequest,
|
|
) -> Result<ToolCompletionResponse, LlmError> {
|
|
let step = self.next_step(&request.messages)?;
|
|
match step.response {
|
|
TraceResponse::Text {
|
|
content,
|
|
input_tokens,
|
|
output_tokens,
|
|
} => Ok(ToolCompletionResponse {
|
|
content: Some(content),
|
|
tool_calls: Vec::new(),
|
|
input_tokens,
|
|
output_tokens,
|
|
finish_reason: FinishReason::Stop,
|
|
cache_read_input_tokens: 0,
|
|
cache_creation_input_tokens: 0,
|
|
}),
|
|
TraceResponse::ToolCalls {
|
|
tool_calls,
|
|
input_tokens,
|
|
output_tokens,
|
|
} => {
|
|
let calls: Vec<ToolCall> = tool_calls
|
|
.into_iter()
|
|
.map(|tc| ToolCall {
|
|
id: tc.id,
|
|
name: tc.name,
|
|
arguments: tc.arguments,
|
|
})
|
|
.collect();
|
|
Ok(ToolCompletionResponse {
|
|
content: None,
|
|
tool_calls: calls,
|
|
input_tokens,
|
|
output_tokens,
|
|
finish_reason: FinishReason::ToolUse,
|
|
cache_read_input_tokens: 0,
|
|
cache_creation_input_tokens: 0,
|
|
})
|
|
}
|
|
TraceResponse::UserInput { .. } => Err(LlmError::RequestFailed {
|
|
provider: self.model_name.clone(),
|
|
reason: "TraceLlm::complete_with_tools() encountered a user_input step; \
|
|
these should have been filtered out during construction"
|
|
.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
}
|