refactor: DRY cleanup — extract shared helpers, remove duplication across tools, LSP, overlays, and runtime
Eliminate ~500 lines of duplicate code across 31 files by extracting shared functions, helpers, and consolidating repeated patterns. Highlights: - Toast helpers (toast_info/success/warning/error) on AppStateRest - push_event() helper for turn-event queue (19 callers consolidated) - log_write_edit_tool() shared fn (turn.rs + engine.rs ~50 lines saved) - resolve_api_key() shared fn (spawn.rs + provider.rs) - LSP call_positional() helper on LspClient - lsp_cursor_params() shared schema for 4 tool files -overlay_block() helper for consistent overlay title/border styling - cycle_selected_index(), path_not_found/a_directory() helpers - mark_dirty(), save_settings() on AppStateRest - Remove redundant Err(e) => Err(e) arms in LSP tools - Consolidate generate_workspace_tree (turn.rs → workspace.rs) - Simplify background-review wrapper args in auto/mod.rs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b02754acd2
commit
9a6ab62562
@@ -25,6 +25,7 @@
|
||||
//! caller-level fallback handles that case.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::app::runtime::stream::turn::StreamedTurn;
|
||||
@@ -43,11 +44,15 @@ const REQUEST_TIMEOUT: Duration = Duration::from_mins(1);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return a pseudo-random jitter offset in the range [0, range_ns).
|
||||
///
|
||||
/// Uses the full epoch nanoseconds (wrapped to u64) instead of the
|
||||
/// sub-second component so the jitter range scales with `range_ns`
|
||||
/// rather than being capped at ~1 s.
|
||||
fn jitter_ns(range_ns: u64) -> u64 {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos() as u64;
|
||||
.as_nanos() as u64;
|
||||
nanos % range_ns
|
||||
}
|
||||
|
||||
@@ -56,10 +61,11 @@ fn jitter_ns(range_ns: u64) -> u64 {
|
||||
/// `attempt` is 1-based (first retry → attempt=1).
|
||||
fn backoff_duration(attempt: u32) -> Duration {
|
||||
let base_secs = (2u64).pow(attempt).min(30);
|
||||
let quarter = (base_secs * 250_000_000).max(100_000_000); // ±25%, min 100ms
|
||||
let offset = jitter_ns(quarter);
|
||||
// ±25% jitter: sometimes slightly less, sometimes slightly more
|
||||
let ns = base_secs * 1_000_000_000 + offset - quarter / 2;
|
||||
let half_range = (base_secs * 250_000_000).max(100_000_000); // 25% of base, min 100ms
|
||||
let offset = jitter_ns(half_range * 2); // [0, 50% of base)
|
||||
// ±25% jitter: subtract half_range so the result varies
|
||||
// between base-25% and base+25%.
|
||||
let ns = base_secs * 1_000_000_000 + offset - half_range;
|
||||
Duration::from_nanos(ns)
|
||||
}
|
||||
|
||||
@@ -91,9 +97,9 @@ fn backoff_for_error(attempt: u32, err_str: &str) -> Duration {
|
||||
if is_rate_limit(err_str) {
|
||||
// Rate limits need more time to drain — start at 5s instead of 2s.
|
||||
let base_secs = (5u64 * (2u64).pow(attempt.saturating_sub(1))).min(60);
|
||||
let quarter = (base_secs * 250_000_000).max(100_000_000);
|
||||
let offset = jitter_ns(quarter);
|
||||
let ns = base_secs * 1_000_000_000 + offset - quarter / 2;
|
||||
let half_range = (base_secs * 250_000_000).max(100_000_000);
|
||||
let offset = jitter_ns(half_range * 2);
|
||||
let ns = base_secs * 1_000_000_000 + offset - half_range;
|
||||
Duration::from_nanos(ns)
|
||||
} else {
|
||||
backoff_duration(attempt)
|
||||
@@ -188,12 +194,15 @@ impl LlmClient {
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
abort_flag: Option<&AtomicBool>,
|
||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let req = ChatRequest {
|
||||
model: self.model.clone(),
|
||||
messages: messages.to_vec(),
|
||||
max_tokens: Some(4096),
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(max_tokens.unwrap_or(4096)),
|
||||
temperature: Some(temperature.unwrap_or(0.7)),
|
||||
tools,
|
||||
stream: Some(false),
|
||||
stop: None,
|
||||
@@ -209,6 +218,12 @@ impl LlmClient {
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
// Check abort before each retry so user cancellation is
|
||||
// responsive even during a long non-streaming backoff chain.
|
||||
if abort_flag.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
anyhow::bail!("aborted");
|
||||
}
|
||||
|
||||
let mut http_req = self
|
||||
.client
|
||||
.post(&url)
|
||||
@@ -298,7 +313,11 @@ impl LlmClient {
|
||||
temperature: Option<f32>,
|
||||
max_tokens: Option<u32>,
|
||||
mut on_event: impl FnMut(&StreamEvent) -> bool,
|
||||
abort_flag: Option<&AtomicBool>,
|
||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
// Clone tools for the non-streaming fallback path — the original
|
||||
// is moved into the ChatRequest below and cannot be used again.
|
||||
let tools_for_fallback = tools.clone();
|
||||
let req = ChatRequest {
|
||||
model: self.model.clone(),
|
||||
messages: messages.to_vec(),
|
||||
@@ -374,19 +393,23 @@ impl LlmClient {
|
||||
// This preserves the conversation state because the messages
|
||||
// passed in are the same — we don't need the partial SSE output.
|
||||
if meaningful_content {
|
||||
// Check abort before entering the blocking non-streaming
|
||||
// call — otherwise the fallback ignores user cancellation.
|
||||
if abort_flag.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
tracing::warn!(
|
||||
"streaming failed after meaningful content — falling back to non-streaming retry",
|
||||
"streaming failed after meaningful content — falling back to non-streaming call",
|
||||
);
|
||||
// Use the same messages; pass None for tools (streaming already
|
||||
// included them) and let the non-streaming path handle retries.
|
||||
// The on_event callback is irrelevant for non-streaming, but we
|
||||
// signal a special synthetic Done event so callers aren't left
|
||||
// hanging waiting for stream completion.
|
||||
// Rebuild ChatRequest without streaming options.
|
||||
return self.chat_with_tools_non_streaming_retry(
|
||||
// Use the same messages and tools so the fallback produces
|
||||
// a response compatible with what the streaming request
|
||||
// would have returned (including tool definitions).
|
||||
return self.chat_with_tools_non_streaming(
|
||||
messages,
|
||||
max_tokens.unwrap_or(4096),
|
||||
temperature.unwrap_or(0.7),
|
||||
tools_for_fallback,
|
||||
max_tokens,
|
||||
temperature,
|
||||
abort_flag,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,93 +418,6 @@ impl LlmClient {
|
||||
))
|
||||
}
|
||||
|
||||
/// Non-streaming fallback used by the streaming method after a partial
|
||||
/// stream failure. Same retry policy as `chat_with_tools_non_streaming`.
|
||||
fn chat_with_tools_non_streaming_retry(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
max_tokens: u32,
|
||||
temperature: f32,
|
||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let req = ChatRequest {
|
||||
model: self.model.clone(),
|
||||
messages: messages.to_vec(),
|
||||
max_tokens: Some(max_tokens),
|
||||
temperature: Some(temperature),
|
||||
tools: None,
|
||||
stream: Some(false),
|
||||
stop: None,
|
||||
stream_options: None,
|
||||
tool_choice: None,
|
||||
top_p: None,
|
||||
};
|
||||
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let max_retries = 10;
|
||||
let mut attempt = 0u32;
|
||||
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
let mut http_req = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json");
|
||||
|
||||
if !self.api_key.is_empty() {
|
||||
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
}
|
||||
|
||||
let result = (|| -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let resp = http_req.json(&req).send().map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
anyhow::anyhow!("API request timed out after {REQUEST_TIMEOUT:?}. Check your network or try again.")
|
||||
} else if e.is_connect() {
|
||||
anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url)
|
||||
} else {
|
||||
anyhow::anyhow!("API request failed: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
||||
}
|
||||
|
||||
let data: crate::dto::provider::response::ChatResponse = resp.json()?;
|
||||
let usage = data
|
||||
.usage
|
||||
.map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens)));
|
||||
let message = data
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|c| c.message)
|
||||
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
|
||||
Ok((message, usage))
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok((msg, usage)) => return Ok((msg, usage)),
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if attempt >= max_retries || is_auth_error(&err_str) {
|
||||
return Err(e);
|
||||
}
|
||||
let delay = backoff_for_error(attempt, &err_str);
|
||||
tracing::warn!(
|
||||
"Warning [non-streaming fallback]: {}. Retrying {}/{}, sleeping {delay:?}...",
|
||||
e,
|
||||
attempt,
|
||||
max_retries,
|
||||
);
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform one streaming chat completion request, parsing SSE events until completion.
|
||||
///
|
||||
/// Flow: POST → read body in chunks → advance past valid UTF-8 boundary →
|
||||
@@ -590,3 +526,34 @@ impl LlmClient {
|
||||
Ok((turn.build_assistant_message(), usage))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the API key for the currently configured provider, falling back
|
||||
/// through settings → env var → provider default.
|
||||
///
|
||||
/// Used by both the main agent turn loop (`spawn.rs`) and subagent provider
|
||||
/// resolution (`subagent/provider.rs`) to share the identical fallback chain.
|
||||
///
|
||||
/// Flow: try `settings.api_keys[provider]` → try `api_key_env` env var →
|
||||
/// try `default_api_key` from config → return empty string if all paths
|
||||
/// exhausted (callers must check and reject the empty case).
|
||||
pub fn resolve_api_key(
|
||||
settings: &zesdex_cms::domain::settings::Settings,
|
||||
app_config: &zesdex_cms::domain::app_config::AppConfig,
|
||||
) -> String {
|
||||
let mut api_key = settings
|
||||
.api_keys
|
||||
.get(&settings.provider)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if api_key.is_empty() {
|
||||
if let Some(provider_cfg) = app_config.providers.get(&settings.provider) {
|
||||
api_key = provider_cfg
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
.or_else(|| provider_cfg.default_api_key.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
api_key
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user