remove Anthropic backend, add request logging on timeout

Delete anthropic.rs (713 lines) — we only use OpenAI-compatible
endpoints (vLLM, OpenRouter). Simplify ApiClient to store base_url
directly instead of Backend enum.

SseReader now stores the serialized request payload and saves it
to ~/.consciousness/logs/failed-request-{ts}.json on stream timeout,
so failed requests can be replayed with curl for debugging.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-02 14:13:23 -04:00
parent 078dcf22d0
commit 1f7b585d41
3 changed files with 37 additions and 769 deletions

View file

@ -1,17 +1,11 @@
// api/ — LLM API client with pluggable backends
// api/ — LLM API client (OpenAI-compatible)
//
// Supports two wire formats:
// - OpenAI-compatible (OpenRouter, vLLM, llama.cpp, Qwen)
// - Anthropic Messages API (direct API access, prompt caching)
//
// The backend is auto-detected from the API base URL. Both backends
// return the same internal types (Message, Usage) so the rest of
// the codebase doesn't need to know which is in use.
// Works with any provider that implements the OpenAI chat completions
// API: OpenRouter, vLLM, llama.cpp, Fireworks, Together, etc.
//
// Diagnostics: anomalies always logged to debug panel.
// Set POC_DEBUG=1 for verbose per-turn logging.
mod anthropic;
mod openai;
use anyhow::Result;
@ -54,18 +48,11 @@ pub enum StreamEvent {
Error(String),
}
enum Backend {
OpenAi {
base_url: String,
},
Anthropic,
}
pub struct ApiClient {
client: Client,
api_key: String,
pub model: String,
backend: Backend,
base_url: String,
}
impl ApiClient {
@ -76,18 +63,11 @@ impl ApiClient {
.build()
.expect("failed to build HTTP client");
let base = base_url.trim_end_matches('/').to_string();
let backend = if base.contains("anthropic.com") {
Backend::Anthropic
} else {
Backend::OpenAi { base_url: base }
};
Self {
client,
api_key: api_key.to_string(),
model: model.to_string(),
backend,
base_url: base_url.trim_end_matches('/').to_string(),
}
}
@ -113,30 +93,14 @@ impl ApiClient {
let tools = tools.map(|t| t.to_vec());
let ui_tx = ui_tx.clone();
let reasoning_effort = reasoning_effort.to_string();
let backend = match &self.backend {
Backend::OpenAi { base_url } => Backend::OpenAi { base_url: base_url.clone() },
Backend::Anthropic => Backend::Anthropic,
};
let base_url = self.base_url.clone();
tokio::spawn(async move {
let result = match &backend {
Backend::OpenAi { base_url } => {
openai::stream_events(
&client, base_url, &api_key, &model,
&messages, tools.as_deref(), &tx, &ui_tx,
&reasoning_effort, temperature, priority,
).await
}
Backend::Anthropic => {
// Anthropic backend still uses the old path for now —
// wrap it by calling the old stream() and synthesizing events.
anthropic::stream_events(
&client, &api_key, &model,
&messages, tools.as_deref(), &tx, &ui_tx,
&reasoning_effort,
).await
}
};
let result = openai::stream_events(
&client, &base_url, &api_key, &model,
&messages, tools.as_deref(), &tx, &ui_tx,
&reasoning_effort, temperature, priority,
).await;
if let Err(e) = result {
let _ = tx.send(StreamEvent::Error(e.to_string()));
}
@ -211,15 +175,10 @@ impl ApiClient {
/// Return a label for the active backend, used in startup info.
pub fn backend_label(&self) -> &str {
match &self.backend {
Backend::OpenAi { base_url } => {
if base_url.contains("openrouter") {
"openrouter"
} else {
"openai-compat"
}
}
Backend::Anthropic => "anthropic",
if self.base_url.contains("openrouter") {
"openrouter"
} else {
"openai-compat"
}
}
}
@ -332,6 +291,8 @@ pub(crate) struct SseReader {
debug: bool,
ui_tx: UiSender,
done: bool,
/// Serialized request payload — saved to disk on timeout for replay debugging.
request_json: Option<String>,
}
impl SseReader {
@ -346,9 +307,15 @@ impl SseReader {
debug: std::env::var("POC_DEBUG").is_ok(),
ui_tx: ui_tx.clone(),
done: false,
request_json: None,
}
}
/// Attach the serialized request payload for error diagnostics.
pub fn set_request(&mut self, request: &impl serde::Serialize) {
self.request_json = serde_json::to_string_pretty(request).ok();
}
/// Read the next SSE event from the response stream.
/// Returns Ok(Some(value)) for each parsed data line,
/// Ok(None) when the stream ends or [DONE] is received.
@ -415,6 +382,19 @@ impl SseReader {
self.chunks_received,
self.stream_start.elapsed().as_secs_f64()
)));
// Save the request for replay debugging
if let Some(ref json) = self.request_json {
let log_dir = dirs::home_dir()
.unwrap_or_default()
.join(".consciousness/logs");
let ts = chrono::Local::now().format("%Y%m%dT%H%M%S");
let path = log_dir.join(format!("failed-request-{}.json", ts));
if std::fs::write(&path, json).is_ok() {
let _ = self.ui_tx.send(UiMessage::Debug(format!(
"saved failed request to {}", path.display()
)));
}
}
anyhow::bail!(
"stream timeout: no data for {}s ({} chunks received)",
self.chunk_timeout.as_secs(),