fix: streaming robustness — OpenAI parsing, error detection, reasoning content

Improves SSE parsing with raw JSON error detection, HTML response detection (for misconfigured endpoints), thinking/reasoning content from provider-specific delta fields, #[serde(default)] on streaming types for lenient deserialization, compact session boundary guard, and /team slash command. Adds install.sh convenience script.
This commit is contained in:
TheArchitectit
2026-05-24 21:22:47 -05:00
committed by GitHub
parent aefa5b0f19
commit 7149bbc3d9
4 changed files with 84 additions and 1 deletions

View File

@@ -505,10 +505,12 @@ impl StreamState {
} }
for choice in chunk.choices { for choice in chunk.choices {
// Handle reasoning/thinking from various provider fields
if let Some(reasoning) = choice if let Some(reasoning) = choice
.delta .delta
.reasoning_content .reasoning_content
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.or(choice.delta.thinking.and_then(|t| t.content).filter(|value| !value.is_empty()))
{ {
if !self.thinking_started { if !self.thinking_started {
self.thinking_started = true; self.thinking_started = true;
@@ -736,6 +738,7 @@ impl ToolCallState {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ChatCompletionResponse { struct ChatCompletionResponse {
#[serde(default)]
id: String, id: String,
model: String, model: String,
choices: Vec<ChatChoice>, choices: Vec<ChatChoice>,
@@ -806,6 +809,7 @@ impl OpenAiUsage {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ChatCompletionChunk { struct ChatCompletionChunk {
#[serde(default)]
id: String, id: String,
#[serde(default)] #[serde(default)]
model: Option<String>, model: Option<String>,
@@ -817,6 +821,7 @@ struct ChatCompletionChunk {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ChunkChoice { struct ChunkChoice {
#[serde(default)]
delta: ChunkDelta, delta: ChunkDelta,
#[serde(default)] #[serde(default)]
finish_reason: Option<String>, finish_reason: Option<String>,
@@ -826,12 +831,21 @@ struct ChunkChoice {
struct ChunkDelta { struct ChunkDelta {
#[serde(default)] #[serde(default)]
content: Option<String>, content: Option<String>,
/// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content`
#[serde(default)] #[serde(default)]
reasoning_content: Option<String>, reasoning_content: Option<String>,
#[serde(default)]
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>, tool_calls: Vec<DeltaToolCall>,
} }
#[derive(Debug, Default, Deserialize)]
struct ThinkingDelta {
#[serde(default)]
content: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct DeltaToolCall { struct DeltaToolCall {
#[serde(default)] #[serde(default)]
@@ -1455,7 +1469,50 @@ fn parse_sse_frame(
data_lines.push(data.trim_start()); 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() { if data_lines.is_empty() {
// Detect raw JSON error response (not SSE-framed)
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(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("<!") {
return Err(ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_response".to_string()),
message: Some("provider returned HTML instead of JSON (check endpoint URL)".to_string()),
request_id: None,
body: trimmed.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
return Ok(None); return Ok(None);
} }
let payload = data_lines.join("\n"); let payload = data_lines.join("\n");
@@ -1492,6 +1549,20 @@ fn parse_sse_frame(
}); });
} }
} }
// Detect HTML or other non-JSON responses early for better error messages
let trimmed_payload = payload.trim();
if trimmed_payload.starts_with('<') || trimmed_payload.starts_with("<!") {
return Err(ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_response".to_string()),
message: Some("provider returned HTML instead of JSON (check endpoint URL)".to_string()),
request_id: None,
body: payload.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
serde_json::from_str::<ChatCompletionChunk>(&payload) serde_json::from_str::<ChatCompletionChunk>(&payload)
.map(Some) .map(Some)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) .map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))

View File

@@ -1472,6 +1472,7 @@ pub fn validate_slash_command_input(
} }
"plan" => SlashCommand::Plan { mode: remainder }, "plan" => SlashCommand::Plan { mode: remainder },
"review" => SlashCommand::Review { scope: remainder }, "review" => SlashCommand::Review { scope: remainder },
"team" => SlashCommand::Team { action: remainder },
"tasks" => SlashCommand::Tasks { args: remainder }, "tasks" => SlashCommand::Tasks { args: remainder },
"theme" => SlashCommand::Theme { name: remainder }, "theme" => SlashCommand::Theme { name: remainder },
"voice" => SlashCommand::Voice { mode: remainder }, "voice" => SlashCommand::Voice { mode: remainder },

View File

@@ -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 // is NOT an assistant message that contains a ToolUse block (i.e. the
// pair is actually broken at the boundary). // pair is actually broken at the boundary).
loop { loop {
if k == 0 || k <= compacted_prefix_len { if k == 0 || k <= compacted_prefix_len || k >= session.messages.len() {
break; break;
} }
let first_preserved = &session.messages[k]; let first_preserved = &session.messages[k];

11
rust/scripts/install.sh Executable file
View File

@@ -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"