From ff416ff3e775a9d6b526fc8c538076ad8a81c45a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 9 Apr 2026 22:33:07 +0900 Subject: [PATCH] fix(api): surface provider error body before attempting completion parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a local/proxy OpenAI-compatible backend returns an error object: {"error":{"message":"...","type":"...","code":...}} claw was trying to deserialize it as a ChatCompletionResponse and failing with the cryptic 'failed to parse OpenAI response: missing field id', completely hiding the actual backend error message. Fix: before full deserialization, check if the parsed JSON has an 'error' key and promote it directly to ApiError::Api so the user sees the real error (e.g. 'The number of tokens to keep from the initial prompt is greater than the context length'). Source: devilayu in #claw-code 2026-04-09 — local LM Studio context limit error was invisible; user saw 'missing field id' instead. 159 CLI + 115 api tests pass. Fmt clean. --- .../crates/api/src/providers/openai_compat.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 82d5c3a..e6502ba 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -157,6 +157,35 @@ impl OpenAiCompatClient { let response = self.send_with_retry(&request).await?; let request_id = request_id_from_headers(response.headers()); let body = response.text().await.map_err(ApiError::from)?; + // Some backends return {"error":{"message":"...","type":"...","code":...}} + // instead of a valid completion object. Check for this before attempting + // full deserialization so the user sees the actual error, not a cryptic + // "missing field 'id'" parse failure. + if let Ok(raw) = serde_json::from_str::(&body) { + 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(|c| c.as_u64()) + .map(|c| c as u16); + return Err(ApiError::Api { + status: reqwest::StatusCode::from_u16(code.unwrap_or(400)) + .unwrap_or(reqwest::StatusCode::BAD_REQUEST), + error_type: err_obj + .get("type") + .and_then(|t| t.as_str()) + .map(str::to_owned), + message: Some(msg), + request_id, + body, + retryable: false, + }); + } + } let payload = serde_json::from_str::(&body).map_err(|error| { ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error) })?;