use serde_json::Value;
use crate::types::{CompletionInfo, TaskStatus};
pub(crate) fn result_envelope_failed(v: &Value) -> bool {
if v.get("type").and_then(|t| t.as_str()) != Some("result") {
return false;
}
if v.get("is_error").and_then(|b| b.as_bool()) == Some(true) {
return true;
}
matches!(
v.get("subtype").and_then(|s| s.as_str()),
Some(sub) if sub != "success"
)
}
pub(crate) fn jsonl_has_error_type(output: &str) -> bool {
for line in output.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
continue;
};
if v.get("type").and_then(|t| t.as_str()) == Some("error") {
return true;
}
}
false
}
pub(crate) fn status_from_result_jsonl(output: &str) -> CompletionInfo {
let mut failed = jsonl_has_error_type(output);
if !failed {
for line in output.lines() {
let Ok(v) = serde_json::from_str::<Value>(line.trim()) else {
continue;
};
if result_envelope_failed(&v) {
failed = true;
break;
}
}
}
CompletionInfo {
tokens: None,
status: if failed {
TaskStatus::Failed
} else {
TaskStatus::Done
},
model: None,
cost_usd: None,
exit_code: None,
}
}
pub(crate) fn status_from_error_type_jsonl(output: &str) -> CompletionInfo {
CompletionInfo {
tokens: None,
status: if jsonl_has_error_type(output) {
TaskStatus::Failed
} else {
TaskStatus::Done
},
model: None,
cost_usd: None,
exit_code: None,
}
}
pub(crate) fn merge_parsed_completion(info: &mut CompletionInfo, parsed: CompletionInfo) {
if parsed.status == TaskStatus::Failed {
info.status = TaskStatus::Failed;
}
if info.tokens.is_none() {
info.tokens = parsed.tokens;
}
if info.model.is_none() {
info.model = parsed.model;
}
if info.cost_usd.is_none() {
info.cost_usd = parsed.cost_usd;
}
}
pub(crate) fn record_quota_exhaustion(
output: &str,
agent: crate::types::AgentKind,
custom_name: Option<&str>,
model: Option<&str>,
) -> QuotaOutcome {
let tail = crate::quota_channel::provider_attributable(
quota_scan_tail(output),
agent,
crate::quota_channel::Channel::CliStream,
)
.all();
let tail = tail.as_str();
if !agent_prose_quota_match(tail, agent) {
return QuotaOutcome::None;
}
let detail = quota_line(tail, agent).unwrap_or_else(|| tail.chars().take(200).collect());
match crate::agent::model_group::model_group(agent, model)
.or_else(|| crate::agent::model_group::group_from_refusal(agent, &detail))
{
Some(group) => {
crate::rate_limit::mark_group_rate_limited(&agent, custom_name, group, &detail)
}
None => crate::rate_limit::mark_rate_limited(&agent, custom_name, &detail),
}
if output_has_substantive_deliverable(output) {
QuotaOutcome::RecordedDelivered
} else {
QuotaOutcome::RecordedFailed
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum QuotaOutcome {
None,
RecordedDelivered,
RecordedFailed,
}
impl QuotaOutcome {
pub(crate) fn recorded(self) -> bool {
!matches!(self, Self::None)
}
pub(crate) fn should_fail(self) -> bool {
matches!(self, Self::RecordedFailed)
}
}
fn agent_prose_quota_match(output: &str, agent: crate::types::AgentKind) -> bool {
output.lines().any(|line| prose_line_is_quota_refusal(line, agent))
}
fn prose_line_is_quota_refusal(line: &str, agent: crate::types::AgentKind) -> bool {
crate::rate_limit_signatures::match_quota_signature_for_agent(line, agent).is_some()
}
fn output_has_substantive_deliverable(output: &str) -> bool {
crate::delivery_guard::looks_like_delivered_report(output)
}
pub(crate) fn quota_line(output: &str, agent: crate::types::AgentKind) -> Option<String> {
let line = output
.lines()
.find(|line| prose_line_is_quota_refusal(line, agent))?;
let lower = line.to_lowercase();
let anchor = quota_signature_anchor(&lower, agent)
.or_else(|| lower.find("quota"))
.or_else(|| lower.find("usage limit"))
.unwrap_or(0);
let refusal = enclosing_plain_run(line, anchor);
Some(refusal.chars().take(240).collect::<String>().trim().to_string())
}
fn enclosing_plain_run(line: &str, anchor: usize) -> &str {
const DELIMITERS: [char; 6] = ['"', '\\', '{', '}', '[', ']'];
let anchor = anchor.min(line.len());
let start = line[..anchor]
.rfind(DELIMITERS)
.map(|idx| idx + line[idx..].chars().next().map_or(1, char::len_utf8))
.unwrap_or(0);
let end = line[anchor..]
.find(DELIMITERS)
.map(|idx| anchor + idx)
.unwrap_or(line.len());
line[start..end].trim_matches(|c: char| c.is_whitespace() || c == ':' || c == ',')
}
fn quota_signature_anchor(lower: &str, agent: crate::types::AgentKind) -> Option<usize> {
crate::rate_limit_signatures::QUOTA_SIGNATURES
.iter()
.find(|signature| signature.agent == agent && lower.contains(signature.needle))
.and_then(|signature| lower.find(signature.needle))
}
fn quota_scan_tail(output: &str) -> &str {
const TAIL_BYTES: usize = 65_536;
if output.len() <= TAIL_BYTES {
return output;
}
let mut start = output.len() - TAIL_BYTES;
while start < output.len() && !output.is_char_boundary(start) {
start += 1;
}
if start > 0 && output.as_bytes()[start - 1] != b'\n' {
let mut floor = start.saturating_sub(TAIL_BYTES);
while floor < start && !output.is_char_boundary(floor) {
floor += 1;
}
if let Some(pos) = output[floor..start].rfind('\n') {
start = floor + pos + 1;
}
}
&output[start..]
}
#[cfg(test)]
#[path = "stream_completion_tests.rs"]
mod tests;