use anyhow::{Context, Result};
use serde_json::{Value, json};
use crate::transport::Endpoint;
use llmtrim_core::compress_with_config;
use llmtrim_core::config::DenseConfig;
use llmtrim_core::ir::ProviderKind;
use llmtrim_core::provider::{self, Provider};
pub struct RecallCase {
pub name: String,
pub request: String,
pub provider: ProviderKind,
pub must_keep: Vec<String>,
}
pub fn recall(must_keep: &[String], haystack: &str) -> f64 {
if must_keep.is_empty() {
return 1.0;
}
let hit = must_keep
.iter()
.filter(|p| haystack.contains(p.as_str()))
.count();
hit as f64 / must_keep.len() as f64
}
#[derive(Debug, Clone)]
pub struct RecallResult {
pub name: String,
pub recall: f64,
pub tokens_before: usize,
pub tokens_after: usize,
}
impl RecallResult {
pub fn savings_pct(&self) -> f64 {
if self.tokens_before == 0 {
0.0
} else {
(self.tokens_before as f64 - self.tokens_after as f64) / self.tokens_before as f64
* 100.0
}
}
}
pub fn run_recall(cases: &[RecallCase], config: &DenseConfig) -> Result<Vec<RecallResult>> {
cases
.iter()
.map(|c| {
let r = compress_with_config(&c.request, Some(c.provider), config)?;
Ok(RecallResult {
name: c.name.clone(),
recall: recall(&c.must_keep, &r.request_json),
tokens_before: r.input_tokens_before.0,
tokens_after: r.input_tokens_after.0,
})
})
.collect()
}
pub fn mean_recall(results: &[RecallResult]) -> f64 {
if results.is_empty() {
return 1.0;
}
results.iter().map(|r| r.recall).sum::<f64>() / results.len() as f64
}
pub fn load_corpus(jsonl: &str, provider: ProviderKind) -> Result<Vec<RecallCase>> {
let mut cases = Vec::new();
for (i, line) in jsonl.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let v: Value = serde_json::from_str(line)
.with_context(|| format!("corpus line {} is not valid JSON", i + 1))?;
let context = field(&v, &["context", "input", "passage", "document"]).unwrap_or_default();
let question = field(&v, &["question", "query", "prompt"]).unwrap_or_default();
let mut messages = vec![json!({"role": "user", "content": context})];
if !question.is_empty() {
messages.push(json!({"role": "user", "content": question}));
}
cases.push(RecallCase {
name: format!("case-{}", i + 1),
request: json!({"model": "gpt-4o", "messages": messages}).to_string(),
provider,
must_keep: answers(&v),
});
}
Ok(cases)
}
fn field(v: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|k| v.get(*k).and_then(Value::as_str))
.map(str::to_string)
}
fn answers(v: &Value) -> Vec<String> {
if let Some(arr) = v.get("answers").and_then(Value::as_array) {
return arr
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
}
field(v, &["answer", "expected"]).into_iter().collect()
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Usage {
pub prompt_tokens: Option<u64>,
pub completion_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
}
pub fn parse_usage(response: &Value) -> Usage {
let u = response.get("usage");
let num = |k: &str| u.and_then(|u| u.get(k)).and_then(Value::as_u64);
let cached = u
.and_then(|u| u.pointer("/prompt_tokens_details/cached_tokens"))
.and_then(Value::as_u64)
.or_else(|| num("cache_read_input_tokens"))
.or_else(|| num("cached_tokens"));
Usage {
prompt_tokens: num("prompt_tokens").or_else(|| num("input_tokens")),
completion_tokens: num("completion_tokens").or_else(|| num("output_tokens")),
cached_tokens: cached,
}
}
pub trait Model {
fn answer(&self, request_json: &str) -> Result<String>;
fn answer_with_usage(&self, request_json: &str) -> Result<(String, Usage)> {
Ok((self.answer(request_json)?, Usage::default()))
}
}
pub struct TaskCase {
pub name: String,
pub request: String,
pub provider: ProviderKind,
pub expected: String,
}
pub fn run_task_success(
cases: &[TaskCase],
config: &DenseConfig,
model: &dyn Model,
) -> Result<f64> {
if cases.is_empty() {
return Ok(1.0);
}
let mut ok = 0usize;
for case in cases {
let r = compress_with_config(&case.request, Some(case.provider), config)?;
let answer = model.answer(&r.request_json)?;
if answer.contains(case.expected.as_str()) {
ok += 1;
}
}
Ok(ok as f64 / cases.len() as f64)
}
pub struct HttpModel {
endpoint: Endpoint,
provider: Box<dyn Provider>,
}
impl HttpModel {
pub fn from_env(provider: ProviderKind) -> Result<Self> {
Ok(Self {
endpoint: Endpoint::from_env(provider)?,
provider: provider::for_kind(provider),
})
}
pub fn new(provider: ProviderKind, base_url: String, api_key: String) -> Self {
Self {
endpoint: Endpoint {
provider,
base_url,
api_key,
},
provider: provider::for_kind(provider),
}
}
}
impl Model for HttpModel {
fn answer(&self, request_json: &str) -> Result<String> {
let proxy_url = crate::transport::upstream_proxy_url(None)?;
let response = self.endpoint.send(request_json, proxy_url.as_deref())?;
let value: serde_json::Value =
serde_json::from_str(&response).context("response is not valid JSON")?;
self.provider
.answer_text(&value)
.context("no answer text found in response")
}
}
#[cfg(feature = "live")]
pub struct OpenRouterModel {
client: async_openai::Client<async_openai::config::OpenAIConfig>,
runtime: tokio::runtime::Runtime,
provider: Box<dyn Provider>,
}
#[cfg(feature = "live")]
impl OpenRouterModel {
pub fn new(api_key: String, provider: ProviderKind) -> Result<Self> {
let config = async_openai::config::OpenAIConfig::new()
.with_api_base("https://openrouter.ai/api/v1")
.with_api_key(api_key);
let http_client = reqwest::Client::builder()
.no_proxy()
.build()
.context("failed to build the OpenRouter HTTP client")?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("failed to start Tokio runtime for the OpenRouter client")?;
Ok(Self {
client: async_openai::Client::with_config(config).with_http_client(http_client),
runtime,
provider: provider::for_kind(provider),
})
}
}
#[cfg(feature = "live")]
impl OpenRouterModel {
pub fn send_raw(&self, request_json: &str) -> Result<Value> {
self.send(request_json)
}
fn send(&self, request_json: &str) -> Result<Value> {
let body: Value =
serde_json::from_str(request_json).context("request is not valid JSON")?;
let mut last = anyhow::anyhow!("no attempt made");
const ATTEMPTS: u32 = 8;
for attempt in 0..ATTEMPTS {
std::thread::sleep(std::time::Duration::from_millis(350));
match self
.runtime
.block_on(self.client.chat().create_byot::<Value, Value>(body.clone()))
{
Ok(v) => return Ok(v),
Err(e) => {
let msg = e.to_string();
let transient = ["429", "rate", "temporarily", "502", "503", "overloaded"]
.iter()
.any(|p| msg.contains(p));
last = anyhow::anyhow!("OpenRouter request failed: {msg}");
if !transient || attempt == ATTEMPTS - 1 {
break;
}
let backoff = (800u64 << attempt).min(20_000);
std::thread::sleep(std::time::Duration::from_millis(backoff));
}
}
}
Err(last)
}
}
#[cfg(feature = "live")]
fn answer_text_or_reasoning(
provider: &dyn provider::Provider,
response: &serde_json::Value,
) -> Option<String> {
if let Some(t) = provider.answer_text(response)
&& !t.trim().is_empty()
{
return Some(t);
}
[
"/choices/0/message/reasoning",
"/choices/0/message/reasoning_content",
]
.iter()
.find_map(|p| response.pointer(p).and_then(serde_json::Value::as_str))
.filter(|t| !t.trim().is_empty())
.map(str::to_string)
}
#[cfg(feature = "live")]
impl Model for OpenRouterModel {
fn answer(&self, request_json: &str) -> Result<String> {
let response = self.send(request_json)?;
answer_text_or_reasoning(self.provider.as_ref(), &response)
.context("no answer text found in response")
}
fn answer_with_usage(&self, request_json: &str) -> Result<(String, Usage)> {
let response = self.send(request_json)?;
let text = answer_text_or_reasoning(self.provider.as_ref(), &response)
.context("no answer text found in response")?;
Ok((text, parse_usage(&response)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_corpus_parses_common_schemas() {
let jsonl = "{\"context\":\"The vault code is 7741.\",\"question\":\"code?\",\"answers\":[\"7741\"]}\n\
{\"input\":\"Paris is the capital.\",\"query\":\"capital?\",\"answer\":\"Paris\"}";
let cases = load_corpus(jsonl, ProviderKind::OpenAi).unwrap();
assert_eq!(cases.len(), 2);
assert_eq!(cases[0].must_keep, vec!["7741"]);
assert_eq!(cases[1].must_keep, vec!["Paris"]);
let v: Value = serde_json::from_str(&cases[0].request).unwrap();
assert_eq!(
v["messages"].as_array().unwrap().len(),
2,
"context + question"
);
}
#[test]
fn recall_counts_surviving_phrases() {
let keep = vec!["alpha".to_string(), "beta".to_string()];
assert_eq!(recall(&keep, "x alpha y"), 0.5);
assert_eq!(recall(&keep, "alpha beta"), 1.0);
assert_eq!(recall(&[], "anything"), 1.0);
}
struct EchoModel;
impl Model for EchoModel {
fn answer(&self, request_json: &str) -> Result<String> {
Ok(request_json.to_string())
}
}
#[test]
fn task_success_harness_runs_offline() {
let cases = vec![TaskCase {
name: "secret".to_string(),
request: r#"{"model":"gpt-4o","messages":[{"role":"user","content":"the secret code is ALPHA7"}]}"#.to_string(),
provider: ProviderKind::OpenAi,
expected: "ALPHA7".to_string(),
}];
let rate = run_task_success(&cases, &DenseConfig::lossless(), &EchoModel).unwrap();
assert_eq!(
rate, 1.0,
"echoed compressed request still contains the answer"
);
}
#[test]
fn provider_answer_text_extraction() {
use llmtrim_core::provider::for_kind;
use serde_json::json;
let oa = for_kind(ProviderKind::OpenAi);
assert_eq!(
oa.answer_text(&json!({"choices":[{"message":{"content":"hi"}}]})),
Some("hi".to_string())
);
assert_eq!(oa.answer_text(&json!({"nope": 1})), None);
let an = for_kind(ProviderKind::Anthropic);
assert_eq!(
an.answer_text(
&json!({"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]})
),
Some("ab".to_string())
);
}
}