use serde_json::{Map, Value};
use crate::types::AgentKind;
const MAX_DEPTH: u8 = 6;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Channel {
CliStream,
CliStderr,
}
pub(crate) fn provider_attributable(
output: &str,
agent: AgentKind,
channel: Channel,
) -> Attributable {
let mut kept = Attributable::default();
for raw in output.lines() {
let cleaned = crate::watcher::strip_terminal_escapes(raw);
let line = cleaned.as_ref().trim();
if line.is_empty() {
continue;
}
if channel == Channel::CliStderr {
push_line(&mut kept.cli_diagnostic, line);
continue;
}
match serde_json::from_str::<Value>(line)
.ok()
.and_then(|value| value.as_object().cloned())
{
Some(object) => keep_envelope_strings(&object, agent, &mut kept),
None => push_line(&mut kept.unsplit, line),
}
}
kept
}
#[derive(Debug, Default)]
pub(crate) struct Attributable {
pub(crate) cli_diagnostic: String,
pub(crate) unsplit: String,
}
impl Attributable {
pub(crate) fn all(&self) -> String {
format!("{}{}", self.cli_diagnostic, self.unsplit)
}
pub(crate) fn quota_text(&self, agent: AgentKind) -> String {
if agent == AgentKind::Claude { self.cli_diagnostic.clone() } else { self.all() }
}
}
pub(crate) fn mark_stream_refusal(agent: AgentKind, custom_name: Option<&str>, raw: &str) {
let Some(message) = crate::rate_limit::refusal_on_channel(raw, agent, Channel::CliStream)
else {
return;
};
crate::rate_limit::mark_rate_limited_for_message(&agent, custom_name, &message);
}
fn keep_envelope_strings(object: &Map<String, Value>, agent: AgentKind, kept: &mut Attributable) {
if agent == AgentKind::Claude && object.get("type").and_then(Value::as_str) == Some("rate_limit_event") {
if let Some(limit) = crate::rate_limit::claude_rejected_limit(object) {
push_line(&mut kept.cli_diagnostic, &limit);
}
return;
}
if is_error_envelope(object) {
if agent == AgentKind::Claude
&& let Some(limit) = crate::rate_limit::claude_rejected_limit(object) {
push_line(&mut kept.cli_diagnostic, &limit);
}
collect_strings(
&Value::Object(object.clone()),
MAX_DEPTH,
&mut kept.cli_diagnostic,
);
} else if terminal_text_is_a_refusal_channel(agent) && is_terminal_result(object) {
if let Some(text) = object.get("result").and_then(Value::as_str) {
push_line(&mut kept.unsplit, text);
}
}
}
fn is_error_envelope(object: &Map<String, Value>) -> bool {
if describes_a_tool(object) {
return false;
}
names_an_error_type(object)
|| object.contains_key("error")
|| object.contains_key("errorCode")
|| object.get("is_error").and_then(Value::as_bool) == Some(true)
}
fn names_an_error_type(object: &Map<String, Value>) -> bool {
object
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind == "error" || kind.ends_with(".error") || kind.ends_with("_error"))
}
fn describes_a_tool(object: &Map<String, Value>) -> bool {
object
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind.contains("tool"))
|| object.contains_key("tool_call")
|| object.contains_key("tool_use_id")
|| object.contains_key("toolCallId")
}
fn is_terminal_result(object: &Map<String, Value>) -> bool {
matches!(
object.get("type").and_then(Value::as_str),
Some("result" | "turn_complete")
)
}
fn terminal_text_is_a_refusal_channel(agent: AgentKind) -> bool {
matches!(agent, AgentKind::Qwen)
}
fn collect_strings(value: &Value, depth: u8, kept: &mut String) {
if depth == 0 {
return;
}
match value {
Value::String(text) => push_line(kept, text),
Value::Array(items) => {
for item in items {
collect_strings(item, depth - 1, kept);
}
}
Value::Object(fields) => {
for field in fields.values() {
collect_strings(field, depth - 1, kept);
}
}
Value::Number(n) if matches!(n.as_i64(), Some(402 | 429)) => {
push_line(kept, &n.to_string());
}
_ => {}
}
}
fn push_line(kept: &mut String, text: &str) {
let text = text.trim();
if text.is_empty() {
return;
}
kept.push_str(text);
kept.push('\n');
}
#[cfg(test)]
pub(crate) fn test_qwen_result_envelope(result: &str) -> String {
let mut envelope: Value = serde_json::from_str(
include_str!("../tests/fixtures/qwen-0.22.3-real-envelopes.jsonl")
.lines()
.nth(1)
.expect("qwen result fixture"),
)
.expect("valid qwen result fixture");
envelope["result"] = Value::String(result.to_owned());
envelope.to_string()
}
#[cfg(test)]
#[path = "quota_channel_tests.rs"]
mod tests;