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:
parent
078dcf22d0
commit
1f7b585d41
3 changed files with 37 additions and 769 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue