Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Empty LLM responses from reasoning models.** Models like GLM can spend the entire `max_tokens` budget on chain-of-thought and return `content: ""` with `finish_reason: "length"`, which previously surfaced as a misleading `EOF while parsing` error. Cora now reads `finish_reason`/`reasoning_content`, automatically retries with a doubled budget (up to 32768), salvages JSON from reasoning text as a last resort, and reports an explicit "EMPTY response" error when nothing is recoverable (#536).

### Changed

- **Default `max_tokens` raised from 4096 to 8192** to give reasoning models headroom above their chain-of-thought (#536).

### Changed

- **Relicensed from MIT to Apache-2.0.** All 18 CodeCoraDev repositories now
Expand Down
6 changes: 3 additions & 3 deletions src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ impl Default for Config {
scan_system_prompt_override: None,
scan_system_prompt_file: None,
temperature: 0.0,
max_tokens: 4096,
max_tokens: 8192, // #536: reasoning models need headroom above chain-of-thought
max_tokens_param: "auto".to_string(),
timeout: 600,
cache_ttl: 1440, // 24h in minutes
Expand Down Expand Up @@ -1340,7 +1340,7 @@ scan:
#[test]
fn config_default_max_tokens() {
let cfg = Config::default();
assert_eq!(cfg.max_tokens, 4096);
assert_eq!(cfg.max_tokens, 8192);
}

#[test]
Expand Down Expand Up @@ -1402,7 +1402,7 @@ llm:
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.temperature, 0.7);
// Other LLM fields should remain at defaults
assert_eq!(cfg.max_tokens, 4096);
assert_eq!(cfg.max_tokens, 8192);
assert_eq!(cfg.timeout, 600);
assert_eq!(cfg.cache_ttl, 1440);
}
Expand Down
150 changes: 142 additions & 8 deletions src/engine/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,55 @@ static SHARED_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
})
});

/// Cap for the empty-content budget escalation (#536).
const MAX_TOKENS_CEILING: u32 = 32_768;

/// Next output budget when a response came back with empty content.
/// `finish_reason == "length"` means reasoning consumed the budget — double
/// it, capped at [`MAX_TOKENS_CEILING`]. Any other reason → give up (None).
fn next_budget_on_empty(finish_reason: Option<&str>, current: u32) -> Option<u32> {
if finish_reason != Some("length") {
return None;
}
let doubled = current.saturating_mul(2);
(doubled <= MAX_TOKENS_CEILING).then_some(doubled)
}

/// Flatten a `reasoning_content` value (string or content-parts array) to text.
fn reasoning_text(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Array(parts) => {
let joined: Vec<String> = parts
.iter()
.filter_map(|p| {
p.get("text")
.and_then(|t| t.as_str())
.map(std::string::ToString::to_string)
})
.collect();
(!joined.is_empty()).then(|| {
joined.join(
"
",
)
})
}
_ => None,
}
}

/// Last-resort raw response when `content` is empty: some models write the
/// final JSON inside their reasoning. Only accept when it plausibly contains
/// JSON — the parse layer still validates.
fn salvage_from_reasoning(reasoning: Option<&Value>) -> Option<String> {
let text = reasoning_text(reasoning?)?;
let trimmed = text.trim();
let plausible =
trimmed.starts_with('[') || trimmed.starts_with('{') || trimmed.contains("```json");
plausible.then(|| trimmed.to_string())
}

/// Return the shared `reqwest::Client` for LLM API requests.
pub fn shared_client() -> reqwest::Client {
SHARED_CLIENT.clone()
Expand Down Expand Up @@ -80,7 +129,20 @@ struct ChatResponse {

#[derive(Debug, Clone, Deserialize)]
struct ChatChoice {
message: ChatMessage,
message: ResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}

/// Response-side message: `content` may be ABSENT or null when a reasoning
/// model spends the entire output budget on chain-of-thought (#536), and some
/// providers expose the thinking under `reasoning_content` (string or parts).
#[derive(Debug, Clone, Deserialize)]
struct ResponseMessage {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<Value>,
}

/// Usage statistics from the LLM API response.
Expand Down Expand Up @@ -342,18 +404,53 @@ async fn chat_completion(
let parsed: ChatResponse =
serde_json::from_str(&body).map_err(|e| CoraError::LlmParse(format!("{e}: {body}")))?;

let content = parsed
.choices
.first()
.map(|c| c.message.content.clone())
.unwrap_or_default();

let usage = parsed.usage.as_ref().and_then(parse_usage_value);

debug!(tokens = ?usage, "LLM response received");
tracing::Span::current().record("tokens_used", usage.as_ref().map(|u| u.total_tokens));

Ok((content, usage))
let choice = parsed.choices.first();
let finish_reason = choice.and_then(|c| c.finish_reason.clone());
let reasoning = choice.and_then(|c| c.message.reasoning_content.clone());
let content = choice
.and_then(|c| c.message.content.clone())
.unwrap_or_default();

if !content.trim().is_empty() {
return Ok((content, usage));
}

// Empty content (#536): a reasoning model can spend the whole output
// budget on chain-of-thought. Recover instead of failing — first by
// raising the budget, then by salvaging JSON from the reasoning text.
if let Some(next) = next_budget_on_empty(finish_reason.as_deref(), config.max_tokens) {
tracing::warn!(
finish_reason = ?finish_reason,
from = config.max_tokens,
to = next,
"empty LLM content — retrying with raised max_tokens"
);
let mut raised = config.clone();
raised.max_tokens = next;
return Box::pin(chat_completion(
&raised,
system_prompt,
user_message,
spinner,
response_format,
))
.await;
}

if let Some(salvaged) = salvage_from_reasoning(reasoning.as_ref()) {
tracing::warn!("content empty — salvaged JSON from reasoning_content");
return Ok((salvaged, usage));
}

Err(CoraError::LlmParse(format!(
"provider returned an EMPTY response (finish_reason={finish_reason:?}) after raising max_tokens to {}. Raise `max_tokens` in config or disable reasoning on the model.",
config.max_tokens
)))
}

/// Create an animated spinner for LLM operations.
Expand Down Expand Up @@ -883,6 +980,12 @@ pub(crate) fn parse_review_response(
raw: &str,
usage: Option<&Usage>,
) -> std::result::Result<(Vec<ReviewIssue>, String, Option<TokenUsage>), CoraError> {
if raw.trim().is_empty() {
return Err(CoraError::LlmParse(
"provider returned an EMPTY response (no message content). Common cause: reasoning consumed the output budget — raise `max_tokens` in config."
.to_string(),
));
}
let (json_str, summary) = extract_json_and_summary(raw);

// Strip markdown code fences if present
Expand Down Expand Up @@ -1327,6 +1430,37 @@ fn strip_code_fences(s: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn budget_doubles_only_on_length() {
assert_eq!(next_budget_on_empty(Some("length"), 4096), Some(8192));
assert_eq!(next_budget_on_empty(Some("length"), 32768), None);
assert_eq!(next_budget_on_empty(Some("stop"), 4096), None);
assert_eq!(next_budget_on_empty(None, 4096), None);
}

#[test]
fn salvage_accepts_only_jsonish_reasoning() {
let arr = Value::String("[{\"file\":\"a.rs\"}]".to_string());
assert!(salvage_from_reasoning(Some(&arr)).is_some());

let fenced = Value::String("thinking... ```json\n[]\n```".to_string());
assert!(salvage_from_reasoning(Some(&fenced)).is_some());

let parts = Value::Array(vec![serde_json::json!({"text": "{\"x\":1}"})]);
assert!(salvage_from_reasoning(Some(&parts)).is_some());

let prose = Value::String("the diff looks fine overall".to_string());
assert!(salvage_from_reasoning(Some(&prose)).is_none());
assert!(salvage_from_reasoning(None).is_none());
}

#[test]
fn empty_raw_is_explicit_not_eof() {
let err = parse_review_response("", None).unwrap_err();
assert!(err.to_string().contains("EMPTY"), "got: {err}");
}

use crate::engine::types::Severity;

const SINGLE_ISSUE_JSON: &str = r#"[{"file":"src/main.rs","line":42,"severity":"critical","issue_type":"security","title":"SQL Injection","body":"User input is concatenated directly into SQL query.","suggested_fix":"Use parameterized queries."}]"#;
Expand Down
Loading