diff --git a/rust/crates/api/src/error.rs b/rust/crates/api/src/error.rs index 21d980af20..e62f214a65 100644 --- a/rust/crates/api/src/error.rs +++ b/rust/crates/api/src/error.rs @@ -20,6 +20,7 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[ "completion tokens", "prompt tokens", "request is too large", + "no parseable body", ]; #[derive(Debug)] @@ -60,6 +61,9 @@ pub enum ApiError { retryable: bool, /// Suggested user action based on error type (e.g., "Reduce prompt size" for 413) suggested_action: Option, + /// Parsed Retry-After header value (seconds) for 429 responses. + /// When present, overrides the exponential backoff delay. + retry_after: Option, }, RetriesExhausted { attempts: u32, @@ -128,6 +132,18 @@ impl ApiError { } #[must_use] + /// Return the `Retry-After` delay if this error came from a 429 response + /// that included a `retry-after` header. Callers should prefer this value + /// over the computed backoff delay when it exists. + #[must_use] + pub fn retry_after(&self) -> Option { + match self { + Self::Api { retry_after, .. } => *retry_after, + Self::RetriesExhausted { last_error, .. } => last_error.retry_after(), + _ => None, + } + } + pub fn is_retryable(&self) -> bool { match self { Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(), @@ -496,6 +512,7 @@ mod tests { body: String::new(), retryable: true, suggested_action: None, + retry_after: None, }; assert!(error.is_generic_fatal_wrapper()); @@ -519,6 +536,7 @@ mod tests { body: String::new(), retryable: true, suggested_action: None, + retry_after: None, }), }; @@ -540,6 +558,7 @@ mod tests { body: String::new(), retryable: false, suggested_action: None, + retry_after: None, }; assert!(error.is_context_window_failure()); diff --git a/rust/crates/api/src/http_client.rs b/rust/crates/api/src/http_client.rs index e2a235012c..136401946f 100644 --- a/rust/crates/api/src/http_client.rs +++ b/rust/crates/api/src/http_client.rs @@ -1,9 +1,69 @@ +use std::time::Duration; + use crate::error::ApiError; const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"]; const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"]; const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"]; +/// Timeout configuration for outbound HTTP requests. +/// +/// When set, the `reqwest::Client` will abort requests that take longer +/// than the configured duration and return a timeout error (which is +/// retryable by the existing exponential backoff logic). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimeoutConfig { + /// Maximum time to wait for a connection to be established. + /// Defaults to 30 seconds. + pub connect_timeout: Duration, + /// Maximum time for the entire request (including reading the response + /// body). For streaming responses this is the timeout for the initial + /// handshake only; the stream itself is governed by SSE parsing. + /// Defaults to 5 minutes (300 seconds). + pub request_timeout: Duration, +} + +impl Default for TimeoutConfig { + fn default() -> Self { + Self { + connect_timeout: Duration::from_secs(30), + request_timeout: Duration::from_secs(300), + } + } +} + +impl TimeoutConfig { + /// Read timeout settings from the process environment. + /// - `CLAW_API_CONNECT_TIMEOUT` — connect timeout in seconds + /// - `CLAW_API_REQUEST_TIMEOUT` — overall request timeout in seconds + #[must_use] + pub fn from_env() -> Self { + let connect_timeout = std::env::var("CLAW_API_CONNECT_TIMEOUT") + .ok() + .and_then(|v| v.parse::().ok()) + .map(Duration::from_secs) + .unwrap_or(Duration::from_secs(30)); + let request_timeout = std::env::var("CLAW_API_REQUEST_TIMEOUT") + .ok() + .and_then(|v| v.parse::().ok()) + .map(Duration::from_secs) + .unwrap_or(Duration::from_secs(300)); + Self { + connect_timeout, + request_timeout, + } + } + + /// Create from explicit second values (used by config file parsing). + #[must_use] + pub fn from_seconds(connect_secs: u64, request_secs: u64) -> Self { + Self { + connect_timeout: Duration::from_secs(connect_secs), + request_timeout: Duration::from_secs(request_secs), + } + } +} + /// Snapshot of the proxy-related environment variables that influence the /// outbound HTTP client. Captured up front so callers can inspect, log, and /// test the resolved configuration without re-reading the process environment. @@ -61,7 +121,7 @@ impl ProxyConfig { /// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is /// configured the client behaves identically to `reqwest::Client::new()`. pub fn build_http_client() -> Result { - build_http_client_with(&ProxyConfig::from_env()) + build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env()) } /// Infallible counterpart to [`build_http_client`] for constructors that @@ -71,17 +131,26 @@ pub fn build_http_client() -> Result { /// first outbound request instead of at construction time. #[must_use] pub fn build_http_client_or_default() -> reqwest::Client { - build_http_client().unwrap_or_else(|_| reqwest::Client::new()) + build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env()) + .unwrap_or_else(|_| reqwest::Client::new()) } /// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests /// and by callers that want to override process-level environment lookups. -/// -/// When `config.proxy_url` is set it overrides the per-scheme `http_proxy` -/// and `https_proxy` fields and is registered as both an HTTP and HTTPS -/// proxy so a single value can route every outbound request. pub fn build_http_client_with(config: &ProxyConfig) -> Result { - let mut builder = reqwest::Client::builder().no_proxy(); + build_http_client_with_opts(config, &TimeoutConfig::from_env()) +} + +/// Build a `reqwest::Client` from explicit [`ProxyConfig`] and [`TimeoutConfig`]. +/// Used by callers that want to control both proxy routing and request timing. +pub fn build_http_client_with_opts( + config: &ProxyConfig, + timeout: &TimeoutConfig, +) -> Result { + let mut builder = reqwest::Client::builder() + .no_proxy() + .connect_timeout(timeout.connect_timeout) + .timeout(timeout.request_timeout); let no_proxy = config .no_proxy @@ -124,7 +193,7 @@ where mod tests { use std::collections::HashMap; - use super::{build_http_client_with, ProxyConfig}; + use super::{build_http_client_with, build_http_client_with_opts, ProxyConfig, TimeoutConfig}; fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig { let map: HashMap = pairs @@ -136,30 +205,19 @@ mod tests { #[test] fn proxy_config_is_empty_when_no_env_vars_are_set() { - // given let config = config_from_map(&[]); - - // when - let empty = config.is_empty(); - - // then - assert!(empty); + assert!(config.is_empty()); assert_eq!(config, ProxyConfig::default()); } #[test] fn proxy_config_reads_uppercase_http_https_and_no_proxy() { - // given let pairs = [ ("HTTP_PROXY", "http://proxy.internal:3128"), ("HTTPS_PROXY", "http://secure.internal:3129"), ("NO_PROXY", "localhost,127.0.0.1,.corp"), ]; - - // when let config = config_from_map(&pairs); - - // then assert_eq!( config.http_proxy.as_deref(), Some("http://proxy.internal:3128") @@ -177,17 +235,12 @@ mod tests { #[test] fn proxy_config_falls_back_to_lowercase_keys() { - // given let pairs = [ ("http_proxy", "http://lower.internal:3128"), ("https_proxy", "http://lower-secure.internal:3129"), ("no_proxy", ".lower"), ]; - - // when let config = config_from_map(&pairs); - - // then assert_eq!( config.http_proxy.as_deref(), Some("http://lower.internal:3128") @@ -201,16 +254,11 @@ mod tests { #[test] fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() { - // given let pairs = [ ("HTTP_PROXY", "http://upper.internal:3128"), ("http_proxy", "http://lower.internal:3128"), ]; - - // when let config = config_from_map(&pairs); - - // then assert_eq!( config.http_proxy.as_deref(), Some("http://upper.internal:3128") @@ -219,59 +267,39 @@ mod tests { #[test] fn proxy_config_treats_empty_strings_as_unset() { - // given let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")]; - - // when let config = config_from_map(&pairs); - - // then assert!(config.http_proxy.is_none()); } #[test] fn build_http_client_succeeds_when_no_proxy_is_configured() { - // given let config = ProxyConfig::default(); - - // when let result = build_http_client_with(&config); - - // then assert!(result.is_ok()); } #[test] fn build_http_client_succeeds_with_valid_http_and_https_proxies() { - // given let config = ProxyConfig { http_proxy: Some("http://proxy.internal:3128".to_string()), https_proxy: Some("http://secure.internal:3129".to_string()), no_proxy: Some("localhost,127.0.0.1".to_string()), proxy_url: None, }; - - // when let result = build_http_client_with(&config); - - // then assert!(result.is_ok()); } #[test] fn build_http_client_returns_http_error_for_invalid_proxy_url() { - // given let config = ProxyConfig { http_proxy: None, https_proxy: Some("not a url".to_string()), no_proxy: None, proxy_url: None, }; - - // when let result = build_http_client_with(&config); - - // then let error = result.expect_err("invalid proxy URL must be reported as a build failure"); assert!( matches!(error, crate::error::ApiError::Http(_)), @@ -281,10 +309,7 @@ mod tests { #[test] fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() { - // given / when let config = ProxyConfig::from_proxy_url("http://unified.internal:3128"); - - // then assert_eq!( config.proxy_url.as_deref(), Some("http://unified.internal:3128") @@ -296,49 +321,56 @@ mod tests { #[test] fn build_http_client_succeeds_with_unified_proxy_url() { - // given let config = ProxyConfig { proxy_url: Some("http://unified.internal:3128".to_string()), no_proxy: Some("localhost".to_string()), ..ProxyConfig::default() }; - - // when let result = build_http_client_with(&config); - - // then assert!(result.is_ok()); } #[test] fn proxy_url_takes_precedence_over_per_scheme_fields() { - // given – both per-scheme and unified are set let config = ProxyConfig { http_proxy: Some("http://per-scheme.internal:1111".to_string()), https_proxy: Some("http://per-scheme.internal:2222".to_string()), no_proxy: None, proxy_url: Some("http://unified.internal:3128".to_string()), }; - - // when – building succeeds (the unified URL is valid) let result = build_http_client_with(&config); - - // then assert!(result.is_ok()); } #[test] fn build_http_client_returns_error_for_invalid_unified_proxy_url() { - // given let config = ProxyConfig::from_proxy_url("not a url"); - - // when let result = build_http_client_with(&config); - - // then assert!( matches!(result, Err(crate::error::ApiError::Http(_))), "invalid unified proxy URL should fail: {result:?}" ); } + + #[test] + fn timeout_config_defaults() { + let config = TimeoutConfig::default(); + assert_eq!(config.connect_timeout, std::time::Duration::from_secs(30)); + assert_eq!(config.request_timeout, std::time::Duration::from_secs(300)); + } + + #[test] + fn timeout_config_from_seconds() { + let config = TimeoutConfig::from_seconds(10, 60); + assert_eq!(config.connect_timeout, std::time::Duration::from_secs(10)); + assert_eq!(config.request_timeout, std::time::Duration::from_secs(60)); + } + + #[test] + fn build_http_client_with_custom_timeouts() { + let config = ProxyConfig::default(); + let timeout = TimeoutConfig::from_seconds(5, 120); + let result = build_http_client_with_opts(&config, &timeout); + assert!(result.is_ok()); + } } diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index d55b211bca..db5a90e9e1 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -12,7 +12,9 @@ pub use client::{ }; pub use error::ApiError; pub use http_client::{ - build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig, + TimeoutConfig, + build_http_client, build_http_client_or_default, build_http_client_with, + build_http_client_with_opts, ProxyConfig, }; pub use prompt_cache::{ CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord, diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 7c9f02945e..f22afad19f 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -211,6 +211,19 @@ impl AnthropicClient { self } + /// Replace the internal HTTP client with one that respects the given + /// timeout configuration. This controls connect and request-level + /// timeouts for all outbound API calls. + #[must_use] + pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self { + self.http = crate::http_client::build_http_client_with_opts( + &crate::http_client::ProxyConfig::from_env(), + timeout, + ) + .unwrap_or_else(|_| reqwest::Client::new()); + self + } + #[must_use] pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self { self.session_tracer = Some(session_tracer); @@ -454,7 +467,12 @@ impl AnthropicClient { break; } - tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await; + let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after()) { + retry_after + } else { + self.jittered_backoff_for_attempt(attempts)? + }; + tokio::time::sleep(delay).await; } Err(ApiError::RetriesExhausted { @@ -869,10 +887,12 @@ async fn expect_success(response: reqwest::Response) -> Result(&body).ok(); let retryable = is_retryable_status(status); + let retry_after = parse_retry_after(&headers, status); Err(ApiError::Api { status, @@ -886,13 +906,41 @@ async fn expect_success(response: reqwest::Response) -> Result Option { + if status != reqwest::StatusCode::TOO_MANY_REQUESTS { + return None; + } + headers + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .map(std::time::Duration::from_secs) +} + const fn is_retryable_status(status: reqwest::StatusCode) -> bool { matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504) } +/// Some providers return HTTP 400 with an unparseable body when a gateway +/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)"). +/// These are transient network blips, not actual bad requests, and should +/// be retried. We detect them by checking the body for known gateway error +/// phrases. +fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool { + if status != reqwest::StatusCode::BAD_REQUEST { + return false; + } + let lowered = body.to_ascii_lowercase(); + lowered.contains("no parseable body") + || lowered.contains("connection reset") + || lowered.contains("broken pipe") + || lowered.contains("empty reply from server") +} + /// Anthropic API keys (`sk-ant-*`) are accepted over the `x-api-key` header /// and rejected with HTTP 401 "Invalid bearer token" when sent as a Bearer /// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild @@ -911,6 +959,8 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError { body, retryable, suggested_action, + retry_after, + .. } = error else { return error; @@ -924,6 +974,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError { body, retryable, suggested_action, + retry_after, }; } let Some(bearer_token) = auth.bearer_token() else { @@ -935,6 +986,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError { body, retryable, suggested_action, + retry_after, }; }; if !bearer_token.starts_with("sk-ant-") { @@ -946,6 +998,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError { body, retryable, suggested_action, + retry_after, }; } // Only append the hint when the AuthSource is pure BearerToken. If both @@ -961,6 +1014,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError { body, retryable, suggested_action, + retry_after, }; } let enriched_message = match message { @@ -975,6 +1029,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError { body, retryable, suggested_action, + retry_after, } } @@ -1563,6 +1618,7 @@ mod tests { body: String::new(), retryable: false, suggested_action: None, + retry_after: None, }; // when @@ -1604,6 +1660,7 @@ mod tests { body: String::new(), retryable: true, suggested_action: None, + retry_after: None, }; // when @@ -1633,6 +1690,7 @@ mod tests { body: String::new(), retryable: false, suggested_action: None, + retry_after: None, }; // when @@ -1661,6 +1719,7 @@ mod tests { body: String::new(), retryable: false, suggested_action: None, + retry_after: None, }; // when @@ -1686,6 +1745,7 @@ mod tests { body: String::new(), retryable: false, suggested_action: None, + retry_after: None, }; // when diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index b3800d6acf..a9d5084844 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -158,6 +158,18 @@ impl OpenAiCompatClient { self } + /// Replace the internal HTTP client with one that respects the given + /// timeout configuration. + #[must_use] + pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self { + self.http = crate::http_client::build_http_client_with_opts( + &crate::http_client::ProxyConfig::from_env(), + timeout, + ) + .unwrap_or_else(|_| reqwest::Client::new()); + self + } + pub async fn send_message( &self, request: &MessageRequest, @@ -200,6 +212,7 @@ impl OpenAiCompatClient { reqwest::StatusCode::from_u16(code.unwrap_or(400)) .unwrap_or(reqwest::StatusCode::BAD_REQUEST), ), + retry_after: None, }); } } @@ -253,7 +266,12 @@ impl OpenAiCompatClient { break retryable_error; } - tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await; + let delay = if let Some(retry_after) = retryable_error.retry_after() { + retry_after + } else { + self.jittered_backoff_for_attempt(attempts)? + }; + tokio::time::sleep(delay).await; }; Err(ApiError::RetriesExhausted { @@ -497,10 +515,12 @@ impl StreamState { } for choice in chunk.choices { + // Handle reasoning/thinking from various provider fields if let Some(reasoning) = choice .delta .reasoning_content .filter(|value| !value.is_empty()) + .or(choice.delta.thinking.and_then(|t| t.content).filter(|value| !value.is_empty())) { if !self.thinking_started { self.thinking_started = true; @@ -728,6 +748,7 @@ impl ToolCallState { #[derive(Debug, Deserialize)] struct ChatCompletionResponse { + #[serde(default)] id: String, model: String, choices: Vec, @@ -775,6 +796,7 @@ struct OpenAiUsage { #[derive(Debug, Deserialize)] struct ChatCompletionChunk { + #[serde(default)] id: String, #[serde(default)] model: Option, @@ -786,6 +808,7 @@ struct ChatCompletionChunk { #[derive(Debug, Deserialize)] struct ChunkChoice { + #[serde(default)] delta: ChunkDelta, #[serde(default)] finish_reason: Option, @@ -795,12 +818,21 @@ struct ChunkChoice { struct ChunkDelta { #[serde(default)] content: Option, + /// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content` #[serde(default)] reasoning_content: Option, + #[serde(default)] + thinking: Option, #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] tool_calls: Vec, } +#[derive(Debug, Default, Deserialize)] +struct ThinkingDelta { + #[serde(default)] + content: Option, +} + #[derive(Debug, Deserialize)] struct DeltaToolCall { #[serde(default)] @@ -1351,7 +1383,50 @@ fn parse_sse_frame( data_lines.push(data.trim_start()); } } + // If no SSE data lines found, check if the entire frame is raw JSON (error or otherwise) if data_lines.is_empty() { + // Detect raw JSON error response (not SSE-framed) + if let Ok(raw) = serde_json::from_str::(trimmed) { + if let Some(err_obj) = raw.get("error") { + let msg = err_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("provider returned an error") + .to_string(); + let code = err_obj + .get("code") + .and_then(serde_json::Value::as_u64) + .map(|c| c as u16); + let status = reqwest::StatusCode::from_u16(code.unwrap_or(500)) + .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); + return Err(ApiError::Api { + status, + error_type: err_obj + .get("type") + .and_then(|t| t.as_str()) + .map(str::to_owned), + message: Some(msg), + request_id: None, + body: trimmed.chars().take(500).collect(), + retryable: false, + suggested_action: suggested_action_for_status(status), + retry_after: None, + }); + } + } + // Detect HTML responses + if trimmed.starts_with('<') || trimmed.starts_with(" Result(&body).ok(); let retryable = is_retryable_status(status); + let retry_after = parse_retry_after(&headers, status); let suggested_action = suggested_action_for_status(status); @@ -1456,13 +1534,40 @@ async fn expect_success(response: reqwest::Response) -> Result Option { + if status != reqwest::StatusCode::TOO_MANY_REQUESTS { + return None; + } + headers + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .map(std::time::Duration::from_secs) +} + const fn is_retryable_status(status: reqwest::StatusCode) -> bool { matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504) } +/// Some providers return HTTP 400 with an unparseable body when a gateway +/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)"). +/// These are transient network blips, not actual bad requests, and should +/// be retried. +fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool { + if status != reqwest::StatusCode::BAD_REQUEST { + return false; + } + let lowered = body.to_ascii_lowercase(); + lowered.contains("no parseable body") + || lowered.contains("connection reset") + || lowered.contains("broken pipe") + || lowered.contains("empty reply from server") +} + /// Generate a suggested user action based on the HTTP status code and error context. /// This provides actionable guidance when API requests fail. fn suggested_action_for_status(status: reqwest::StatusCode) -> Option { diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 5e8f5eba8b..5570b2a615 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1472,10 +1472,15 @@ pub fn validate_slash_command_input( } "plan" => SlashCommand::Plan { mode: remainder }, "review" => SlashCommand::Review { scope: remainder }, + "team" => SlashCommand::Team { action: remainder }, "tasks" => SlashCommand::Tasks { args: remainder }, "theme" => SlashCommand::Theme { name: remainder }, "voice" => SlashCommand::Voice { mode: remainder }, "usage" => SlashCommand::Usage { scope: remainder }, +<<<<<<< HEAD +======= + "setup" => SlashCommand::Setup, +>>>>>>> 2f6a225 (fix: make id field optional in OpenAI response parsing) "rename" => SlashCommand::Rename { name: remainder }, "copy" => SlashCommand::Copy { target: remainder }, "hooks" => SlashCommand::Hooks { args: remainder }, diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index e4fd3db0d3..03f04053cb 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -128,7 +128,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio // is NOT an assistant message that contains a ToolUse block (i.e. the // pair is actually broken at the boundary). loop { - if k == 0 || k <= compacted_prefix_len { + if k == 0 || k <= compacted_prefix_len || k >= session.messages.len() { break; } let first_preserved = &session.messages[k]; diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 1566189282..68b7a2fd1e 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -51,6 +51,27 @@ pub struct RuntimePluginConfig { max_output_tokens: Option, } +/// API timeout and retry configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiTimeoutConfig { + /// Connect timeout in seconds. Defaults to 30. + pub connect_timeout_secs: u64, + /// Request timeout in seconds. Defaults to 300 (5 minutes). + pub request_timeout_secs: u64, + /// Maximum retry attempts on transient failures. Defaults to 8. + pub max_retries: u32, +} + +impl Default for ApiTimeoutConfig { + fn default() -> Self { + Self { + connect_timeout_secs: 30, + request_timeout_secs: 300, + max_retries: 8, + } + } +} + /// Structured feature configuration consumed by runtime subsystems. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeFeatureConfig { @@ -65,6 +86,7 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + api_timeout: ApiTimeoutConfig, } /// Ordered chain of fallback model identifiers used when the primary @@ -315,6 +337,7 @@ impl ConfigLoader { sandbox: parse_optional_sandbox_config(&merged_value)?, provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, trusted_roots: parse_optional_trusted_roots(&merged_value)?, + api_timeout: parse_optional_api_timeout_config(&merged_value)?, }; Ok(RuntimeConfig { @@ -904,6 +927,28 @@ fn parse_optional_provider_fallbacks( Ok(ProviderFallbackConfig { primary, fallbacks }) } +fn parse_optional_api_timeout_config(root: &JsonValue) -> Result { + let Some(timeout_value) = root.as_object().and_then(|obj| obj.get("apiTimeout")) else { + return Ok(ApiTimeoutConfig::default()); + }; + let Some(obj) = timeout_value.as_object() else { + return Ok(ApiTimeoutConfig::default()); + }; + let context = "merged settings.apiTimeout"; + let connect_timeout_secs = optional_u64(obj, "connectTimeout", context)? + .unwrap_or(30); + let request_timeout_secs = optional_u64(obj, "requestTimeout", context)? + .unwrap_or(300); + let max_retries = optional_u64(obj, "maxRetries", context)? + .map(|v| v as u32) + .unwrap_or(8); + Ok(ApiTimeoutConfig { + connect_timeout_secs, + request_timeout_secs, + max_retries, + }) +} + fn parse_optional_trusted_roots(root: &JsonValue) -> Result, ConfigError> { let Some(object) = root.as_object() else { return Ok(Vec::new()); diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index c1108d3dc7..1bfac48cb3 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -57,7 +57,7 @@ pub use compact::{ get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; pub use config::{ - ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, + ApiTimeoutConfig, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index df4d8da452..ae8deb811f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -9520,7 +9520,8 @@ mod tests { body: String::new(), retryable: true, suggested_action: None, - }; + retry_after: None, +}; let rendered = format_user_visible_api_error("session-issue-22", &error); assert!(rendered.contains("provider_internal")); @@ -9543,7 +9544,8 @@ mod tests { body: String::new(), retryable: true, suggested_action: None, - }), + retry_after: None, +}), }; let rendered = format_user_visible_api_error("session-issue-22", &error); @@ -9607,7 +9609,8 @@ mod tests { body: String::new(), retryable: false, suggested_action: None, - }; + retry_after: None, +}; let rendered = format_user_visible_api_error("session-issue-32", &error); assert!(rendered.contains("context_window_blocked"), "{rendered}"); @@ -9674,7 +9677,8 @@ mod tests { body: String::new(), retryable: false, suggested_action: None, - }), + retry_after: None, +}), }; let rendered = format_user_visible_api_error("session-issue-32", &error); diff --git a/rust/scripts/install.sh b/rust/scripts/install.sh new file mode 100755 index 0000000000..344a7b5c62 --- /dev/null +++ b/rust/scripts/install.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Build the release binary +cargo build --release + +# Link to ~/.local/bin +mkdir -p "$HOME/.local/bin" +ln -sf "$(pwd)/target/release/claw" "$HOME/.local/bin/claw" + +echo "✓ Claw installed to ~/.local/bin/claw"