mirror of
https://github.com/instructkr/claude-code.git
synced 2026-05-26 15:36:46 +00:00
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:
@@ -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))
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
11
rust/scripts/install.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user