Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions rust/crates/api/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,10 +497,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;
Expand Down Expand Up @@ -728,6 +730,7 @@ impl ToolCallState {

#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
#[serde(default)]
id: String,
model: String,
choices: Vec<ChatChoice>,
Expand Down Expand Up @@ -775,6 +778,7 @@ struct OpenAiUsage {

#[derive(Debug, Deserialize)]
struct ChatCompletionChunk {
#[serde(default)]
id: String,
#[serde(default)]
model: Option<String>,
Expand All @@ -786,6 +790,7 @@ struct ChatCompletionChunk {

#[derive(Debug, Deserialize)]
struct ChunkChoice {
#[serde(default)]
delta: ChunkDelta,
#[serde(default)]
finish_reason: Option<String>,
Expand All @@ -795,12 +800,21 @@ struct ChunkChoice {
struct ChunkDelta {
#[serde(default)]
content: Option<String>,
/// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content`
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
}

#[derive(Debug, Default, Deserialize)]
struct ThinkingDelta {
#[serde(default)]
content: Option<String>,
}

#[derive(Debug, Deserialize)]
struct DeltaToolCall {
#[serde(default)]
Expand Down Expand Up @@ -1351,7 +1365,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::<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);
}
let payload = data_lines.join("\n");
Expand Down
5 changes: 5 additions & 0 deletions rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
15 changes: 10 additions & 5 deletions rust/crates/runtime/src/compact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,15 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
.first()
.and_then(extract_existing_compacted_summary);
let compacted_prefix_len = usize::from(existing_summary.is_some());
let raw_keep_from = session
.messages
.len()
.saturating_sub(config.preserve_recent_messages);
// When preserve_recent_messages is 0, the caller wants maximum compaction
// (no recent messages preserved). Without this guard, saturating_sub(0)
// returns messages.len(), which later indexes past the end of the array
// at session.messages[k] because keep_from == messages.len() is out of bounds.
let raw_keep_from = if config.preserve_recent_messages == 0 {
session.messages.len()
} else {
session.messages.len().saturating_sub(config.preserve_recent_messages)
};
// Ensure we do not split a tool-use / tool-result pair at the compaction
// boundary. If the first preserved message is a user message whose first
// block is a ToolResult, the assistant message with the matching ToolUse
Expand All @@ -128,7 +133,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];
Expand Down
1 change: 1 addition & 0 deletions rust/crates/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod bash_validation;
mod bootstrap;
pub mod branch_lock;
mod compact;
pub mod trident;
mod config;
pub mod config_validate;
mod conversation;
Expand Down
Loading