Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "animus-provider-oai-agent"
version = "0.1.5"
version = "0.1.6"
edition = "2021"
license = "Elastic-2.0"
description = "Full agentic OpenAI-compatible provider plugin for Animus — tool calling, MCP, multi-turn agent loop, file ops, bash, search"
Expand Down
2 changes: 1 addition & 1 deletion plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema = "animus.plugin.v1"
name = "animus-provider-oai-agent"
version = "0.1.5"
version = "0.1.6"
plugin_kind = "provider"
description = "Full agentic OpenAI-compatible provider plugin for Animus — tool calling, MCP, multi-turn agent loop"

Expand Down
12 changes: 11 additions & 1 deletion src/bin/animus-oai-runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,19 @@ async fn main() -> Result<()> {
None => None,
};

let client = api::client::ApiClient::new(
// Resolve provider-side caching from env (the runtime contract has no
// `cache` object plumbed yet — ao-cli will wire that seam later; the
// env fallbacks make it controllable today).
let cache_config = config::CacheConfig::resolve(None);
let response_cache_ttl = cache_config
.response_cache
.then_some(cache_config.response_cache_ttl_secs);

let client = api::client::ApiClient::with_response_cache(
resolved_config.api_base,
resolved_config.api_key,
idle_timeout,
response_cache_ttl,
);

let native_tools = if read_only {
Expand Down Expand Up @@ -206,6 +215,7 @@ async fn main() -> Result<()> {
cancel_token,
context_limit,
max_tokens,
cache_config.prompt_breakpoints,
)
.await;

Expand Down
115 changes: 110 additions & 5 deletions src/runner_core/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,25 @@ pub struct ApiClient {
http: reqwest::Client,
api_base: String,
api_key: String,
/// When set, opt into OpenRouter's response cache via request headers.
/// `Some(ttl_secs)` sends `X-OpenRouter-Cache: true` plus
/// `X-OpenRouter-Cache-TTL: <ttl>`; `None` sends neither.
response_cache_ttl_secs: Option<u32>,
}

impl ApiClient {
pub fn new(api_base: String, api_key: String, timeout_secs: u64) -> Self {
Self::with_response_cache(api_base, api_key, timeout_secs, None)
}

/// Construct a client, optionally enabling OpenRouter's response cache with
/// the given TTL (seconds). `None` leaves response caching off.
pub fn with_response_cache(
api_base: String,
api_key: String,
timeout_secs: u64,
response_cache_ttl_secs: Option<u32>,
) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
Expand All @@ -168,9 +183,17 @@ impl ApiClient {
http,
api_base,
api_key,
response_cache_ttl_secs,
}
}

/// The configured chat-completions base URL (e.g. the OpenRouter or OpenAI
/// endpoint). Used to decide whether Anthropic/OpenRouter prompt-cache
/// breakpoints apply.
pub fn api_base(&self) -> &str {
&self.api_base
}

pub async fn stream_chat(
&self,
request: &ChatRequest,
Expand Down Expand Up @@ -269,15 +292,31 @@ impl ApiClient {
request: &ChatRequest,
on_text_chunk: &mut dyn FnMut(&str),
) -> Result<(ChatMessage, Option<UsageInfo>)> {
let resp = self
let mut builder = self
.http
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("User-Agent", "claude-code/2.1.80")
.json(request)
.send()
.await?;
.header("User-Agent", "claude-code/2.1.80");
// Opt into OpenRouter's response cache (byte-identical request reuse,
// billed as 0 tokens on a hit) only when explicitly enabled.
if let Some(ttl) = self.response_cache_ttl_secs {
builder = builder
.header("X-OpenRouter-Cache", "true")
.header("X-OpenRouter-Cache-TTL", ttl.to_string());
}
let resp = builder.json(request).send().await?;

// Surface OpenRouter's response-cache hit/miss at debug for observability.
if self.response_cache_ttl_secs.is_some() {
if let Some(cache_status) = resp
.headers()
.get("x-openrouter-cache-status")
.and_then(|v| v.to_str().ok())
{
eprintln!("[oai-runner] OpenRouter response cache: {}", cache_status);
}
}

let status = resp.status();
if !status.is_success() {
Expand Down Expand Up @@ -457,6 +496,72 @@ mod tests {
}
}

// ── response-cache headers (P2) ─────────────────────────────────

fn minimal_request() -> ChatRequest {
ChatRequest {
model: "anthropic/claude-sonnet-4".to_string(),
messages: vec![ChatMessage {
reasoning_content: None,
role: "user".to_string(),
content: Some("hi".to_string()),
tool_calls: None,
tool_call_id: None,
}],
stream: true,
tools: None,
max_tokens: Some(16),
response_format: None,
stream_options: None,
cache_message_breakpoints: Vec::new(),
}
}

/// A single SSE chunk + [DONE] so `do_stream` returns cleanly.
const SSE_BODY: &str =
"data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n";

#[tokio::test]
async fn response_cache_headers_sent_when_enabled() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/chat/completions")
.match_header("x-openrouter-cache", "true")
.match_header("x-openrouter-cache-ttl", "600")
.with_status(200)
.with_header("content-type", "text/event-stream")
.with_header("x-openrouter-cache-status", "MISS")
.with_body(SSE_BODY)
.create_async()
.await;

let client =
ApiClient::with_response_cache(server.url(), "sk-test".to_string(), 5, Some(600));
let res = client.stream_chat(&minimal_request(), &mut |_| {}).await;
assert!(res.is_ok(), "stream should succeed: {res:?}");
mock.assert_async().await; // header matchers verified
}

#[tokio::test]
async fn response_cache_headers_absent_when_disabled() {
let mut server = mockito::Server::new_async().await;
// Expect NO cache headers on the request.
let mock = server
.mock("POST", "/chat/completions")
.match_header("x-openrouter-cache", mockito::Matcher::Missing)
.match_header("x-openrouter-cache-ttl", mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "text/event-stream")
.with_body(SSE_BODY)
.create_async()
.await;

let client = ApiClient::new(server.url(), "sk-test".to_string(), 5);
let res = client.stream_chat(&minimal_request(), &mut |_| {}).await;
assert!(res.is_ok(), "stream should succeed: {res:?}");
mock.assert_async().await;
}

// ── circuit_state ──────────────────────────────────────────────

#[test]
Expand Down
Loading
Loading