Skip to content

Commit 7149bbc

Browse files
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.
1 parent aefa5b0 commit 7149bbc

4 files changed

Lines changed: 84 additions & 1 deletion

File tree

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,10 +505,12 @@ impl StreamState {
505505
}
506506

507507
for choice in chunk.choices {
508+
// Handle reasoning/thinking from various provider fields
508509
if let Some(reasoning) = choice
509510
.delta
510511
.reasoning_content
511512
.filter(|value| !value.is_empty())
513+
.or(choice.delta.thinking.and_then(|t| t.content).filter(|value| !value.is_empty()))
512514
{
513515
if !self.thinking_started {
514516
self.thinking_started = true;
@@ -736,6 +738,7 @@ impl ToolCallState {
736738

737739
#[derive(Debug, Deserialize)]
738740
struct ChatCompletionResponse {
741+
#[serde(default)]
739742
id: String,
740743
model: String,
741744
choices: Vec<ChatChoice>,
@@ -806,6 +809,7 @@ impl OpenAiUsage {
806809

807810
#[derive(Debug, Deserialize)]
808811
struct ChatCompletionChunk {
812+
#[serde(default)]
809813
id: String,
810814
#[serde(default)]
811815
model: Option<String>,
@@ -817,6 +821,7 @@ struct ChatCompletionChunk {
817821

818822
#[derive(Debug, Deserialize)]
819823
struct ChunkChoice {
824+
#[serde(default)]
820825
delta: ChunkDelta,
821826
#[serde(default)]
822827
finish_reason: Option<String>,
@@ -826,12 +831,21 @@ struct ChunkChoice {
826831
struct ChunkDelta {
827832
#[serde(default)]
828833
content: Option<String>,
834+
/// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content`
829835
#[serde(default)]
830836
reasoning_content: Option<String>,
837+
#[serde(default)]
838+
thinking: Option<ThinkingDelta>,
831839
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
832840
tool_calls: Vec<DeltaToolCall>,
833841
}
834842

843+
#[derive(Debug, Default, Deserialize)]
844+
struct ThinkingDelta {
845+
#[serde(default)]
846+
content: Option<String>,
847+
}
848+
835849
#[derive(Debug, Deserialize)]
836850
struct DeltaToolCall {
837851
#[serde(default)]
@@ -1455,7 +1469,50 @@ fn parse_sse_frame(
14551469
data_lines.push(data.trim_start());
14561470
}
14571471
}
1472+
// If no SSE data lines found, check if the entire frame is raw JSON (error or otherwise)
14581473
if data_lines.is_empty() {
1474+
// Detect raw JSON error response (not SSE-framed)
1475+
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(trimmed) {
1476+
if let Some(err_obj) = raw.get("error") {
1477+
let msg = err_obj
1478+
.get("message")
1479+
.and_then(|m| m.as_str())
1480+
.unwrap_or("provider returned an error")
1481+
.to_string();
1482+
let code = err_obj
1483+
.get("code")
1484+
.and_then(serde_json::Value::as_u64)
1485+
.map(|c| c as u16);
1486+
let status = reqwest::StatusCode::from_u16(code.unwrap_or(500))
1487+
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
1488+
return Err(ApiError::Api {
1489+
status,
1490+
error_type: err_obj
1491+
.get("type")
1492+
.and_then(|t| t.as_str())
1493+
.map(str::to_owned),
1494+
message: Some(msg),
1495+
request_id: None,
1496+
body: trimmed.chars().take(500).collect(),
1497+
retryable: false,
1498+
suggested_action: suggested_action_for_status(status),
1499+
retry_after: None,
1500+
});
1501+
}
1502+
}
1503+
// Detect HTML responses
1504+
if trimmed.starts_with('<') || trimmed.starts_with("<!") {
1505+
return Err(ApiError::Api {
1506+
status: reqwest::StatusCode::BAD_REQUEST,
1507+
error_type: Some("invalid_response".to_string()),
1508+
message: Some("provider returned HTML instead of JSON (check endpoint URL)".to_string()),
1509+
request_id: None,
1510+
body: trimmed.chars().take(200).collect(),
1511+
retryable: false,
1512+
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
1513+
retry_after: None,
1514+
});
1515+
}
14591516
return Ok(None);
14601517
}
14611518
let payload = data_lines.join("\n");
@@ -1492,6 +1549,20 @@ fn parse_sse_frame(
14921549
});
14931550
}
14941551
}
1552+
// Detect HTML or other non-JSON responses early for better error messages
1553+
let trimmed_payload = payload.trim();
1554+
if trimmed_payload.starts_with('<') || trimmed_payload.starts_with("<!") {
1555+
return Err(ApiError::Api {
1556+
status: reqwest::StatusCode::BAD_REQUEST,
1557+
error_type: Some("invalid_response".to_string()),
1558+
message: Some("provider returned HTML instead of JSON (check endpoint URL)".to_string()),
1559+
request_id: None,
1560+
body: payload.chars().take(200).collect(),
1561+
retryable: false,
1562+
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
1563+
retry_after: None,
1564+
});
1565+
}
14951566
serde_json::from_str::<ChatCompletionChunk>(&payload)
14961567
.map(Some)
14971568
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))

rust/crates/commands/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,6 +1472,7 @@ pub fn validate_slash_command_input(
14721472
}
14731473
"plan" => SlashCommand::Plan { mode: remainder },
14741474
"review" => SlashCommand::Review { scope: remainder },
1475+
"team" => SlashCommand::Team { action: remainder },
14751476
"tasks" => SlashCommand::Tasks { args: remainder },
14761477
"theme" => SlashCommand::Theme { name: remainder },
14771478
"voice" => SlashCommand::Voice { mode: remainder },

rust/crates/runtime/src/compact.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
128128
// is NOT an assistant message that contains a ToolUse block (i.e. the
129129
// pair is actually broken at the boundary).
130130
loop {
131-
if k == 0 || k <= compacted_prefix_len {
131+
if k == 0 || k <= compacted_prefix_len || k >= session.messages.len() {
132132
break;
133133
}
134134
let first_preserved = &session.messages[k];

rust/scripts/install.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Build the release binary
5+
cargo build --release
6+
7+
# Link to ~/.local/bin
8+
mkdir -p "$HOME/.local/bin"
9+
ln -sf "$(pwd)/target/release/claw" "$HOME/.local/bin/claw"
10+
11+
echo "✓ Claw installed to ~/.local/bin/claw"

0 commit comments

Comments
 (0)