use super::error::{ProviderError, Result};
use super::r#trait::{Provider, ProviderStream};
use super::types::*;
use async_trait::async_trait;
use futures::stream::StreamExt;
use serde::Deserialize;
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::{LazyLock, RwLock};
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
#[cfg(not(test))]
fn learned_models_path() -> std::path::PathBuf {
crate::config::profile::base_opencrabs_dir().join("claude_cli_models.json")
}
#[cfg(not(test))]
fn load_learned_models() -> HashMap<String, String> {
std::fs::read_to_string(learned_models_path())
.ok()
.and_then(|s| serde_json::from_str::<HashMap<String, String>>(&s).ok())
.unwrap_or_default()
}
#[cfg(test)]
fn load_learned_models() -> HashMap<String, String> {
HashMap::new()
}
static LEARNED_MODELS: LazyLock<RwLock<HashMap<String, String>>> =
LazyLock::new(|| RwLock::new(load_learned_models()));
pub(crate) fn learned_alias(alias: &str) -> Option<String> {
LEARNED_MODELS.read().ok()?.get(alias).cloned()
}
pub(crate) fn record_alias(alias: &str, version: &str) {
if let Ok(read) = LEARNED_MODELS.read()
&& read.get(alias).map(String::as_str) == Some(version)
{
return;
}
let snapshot = {
let Ok(mut write) = LEARNED_MODELS.write() else {
return;
};
if write.get(alias).map(String::as_str) == Some(version) {
return;
}
write.insert(alias.to_string(), version.to_string());
write.clone()
};
tracing::info!(
"claude-cli: learned {alias} → {version} from the CLI; updating the alias map so footers and pricing track the current release"
);
#[cfg(not(test))]
{
let path = learned_models_path();
if let Ok(json) = serde_json::to_string_pretty(&snapshot) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, json);
}
}
let _ = snapshot;
}
#[cfg(test)]
pub(crate) fn clear_learned_models() {
if let Ok(mut map) = LEARNED_MODELS.write() {
map.clear();
}
}
pub(crate) const SUPPORTED_MODELS: &[&str] = &[
"fable",
"sonnet",
"opus",
"haiku",
"opus-4-8",
"opus-4-7",
"sonnet-4-6",
"opus-4-6",
"haiku-4-5",
];
pub(crate) const DEFAULT_MODEL: &str = "opus-4-8";
const HELP_PARSE_STOPWORDS: &[&str] = &["model", "alias", "name", "latest", "full", "session"];
fn looks_like_model_name(s: &str) -> bool {
s.len() >= 4
&& s.len() <= 40
&& s.starts_with(|c: char| c.is_ascii_lowercase())
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.')
&& !HELP_PARSE_STOPWORDS.contains(&s)
}
pub(crate) fn parse_models_from_help(help: &str) -> Vec<String> {
let mut block = String::new();
let mut in_block = false;
for line in help.lines() {
let trimmed = line.trim_start();
if in_block {
if trimmed.starts_with('-') || (!line.starts_with(' ') && !trimmed.is_empty()) {
break;
}
block.push(' ');
block.push_str(trimmed);
} else if trimmed.starts_with("--model") {
in_block = true;
block.push_str(trimmed);
}
}
if block.is_empty() {
return Vec::new();
}
let mut out: Vec<String> = Vec::new();
for seg in block.split('\'') {
let t = seg.trim();
if looks_like_model_name(t) && !out.iter().any(|m| m == t) {
out.push(t.to_string());
}
}
out
}
fn normalize_discovered(id: &str) -> String {
let s = id.strip_prefix("claude-").unwrap_or(id);
let s = s.split('[').next().unwrap_or(s);
s.trim().to_ascii_lowercase()
}
fn claude_code_state_models() -> Vec<String> {
let Some(path) = dirs::home_dir().map(|h| h.join(".claude.json")) else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
let mut push = |raw: &str| {
let m = normalize_discovered(raw);
if looks_like_model_name(&m) && !out.iter().any(|x| x == &m) {
out.push(m);
}
};
if let Some(arr) = json
.get("additionalModelOptionsCache")
.and_then(|v| v.as_array())
{
for entry in arr {
if let Some(v) = entry.get("value").and_then(|v| v.as_str()) {
push(v);
}
}
}
if let Some(slots) = json.get("clientDataCacheSlots").and_then(|v| v.as_object()) {
for slot in slots.values() {
if let Some(v) = slot.get("model").and_then(|v| v.as_str()) {
push(v);
}
}
}
out.sort();
out
}
fn split_family(id: &str) -> (&str, &str) {
match id.split_once('-') {
Some((family, rest)) => (family, rest),
None => (id, ""),
}
}
fn parse_version(rest: &str) -> (u32, u32) {
if rest.is_empty() {
return (0, 0);
}
match rest.split_once('-') {
Some((maj, min)) => (
maj.parse().unwrap_or(0),
min.split('-').next().unwrap_or("0").parse().unwrap_or(0),
),
None => (rest.parse().unwrap_or(0), 0),
}
}
fn model_sort_key(id: &str) -> (u8, u8, std::cmp::Reverse<(u32, u32)>) {
let (family, rest) = split_family(id);
let family_rank = match family {
"fable" => 0,
"opus" => 1,
"sonnet" => 2,
"haiku" => 3,
_ => 4,
};
let is_versioned = u8::from(!rest.is_empty());
(
is_versioned,
family_rank,
std::cmp::Reverse(parse_version(rest)),
)
}
static HELP_MODELS: LazyLock<RwLock<Option<Vec<String>>>> = LazyLock::new(|| RwLock::new(None));
fn help_models() -> Vec<String> {
if let Ok(cached) = HELP_MODELS.read()
&& let Some(ref v) = *cached
{
return v.clone();
}
let discovered = resolve_claude_path()
.ok()
.and_then(|path| std::process::Command::new(path).arg("--help").output().ok())
.map(|out| {
let text = String::from_utf8_lossy(&out.stdout);
parse_models_from_help(&text)
})
.unwrap_or_default();
if let Ok(mut w) = HELP_MODELS.write() {
*w = Some(discovered.clone());
}
discovered
}
pub(crate) fn available_models() -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |m: String| {
let m = normalize_discovered(&m);
if looks_like_model_name(&m) && !out.iter().any(|x| x == &m) {
out.push(m);
}
};
for m in help_models() {
push(m);
}
for m in claude_code_state_models() {
push(m);
}
for m in SUPPORTED_MODELS {
push((*m).to_string());
}
if let Ok(learned) = LEARNED_MODELS.read() {
let mut versions: Vec<String> = learned
.values()
.filter(|v| looks_like_model_name(v))
.cloned()
.collect();
versions.sort();
for v in versions {
push(v);
}
}
out.sort_by_key(|m| model_sort_key(m));
out
}
#[derive(Clone)]
pub struct ClaudeCliProvider {
claude_path: String,
default_model: String,
configured_context_window: Option<u32>,
}
impl ClaudeCliProvider {
pub fn new() -> Result<Self> {
let path = resolve_claude_path()?;
Ok(Self {
claude_path: path,
default_model: Self::default_for_alias("sonnet"),
configured_context_window: None,
})
}
pub fn with_context_window(mut self, context_window: u32) -> Self {
self.configured_context_window = Some(context_window);
self
}
pub fn with_default_model(mut self, model: String) -> Self {
self.default_model = Self::normalize_model(&model);
self
}
fn map_model(model: &str) -> &str {
if model.contains("fable") {
"fable"
} else if model.contains("opus") {
"opus"
} else if model.contains("haiku") {
"haiku"
} else {
"sonnet"
}
}
pub(crate) fn normalize_model(model: &str) -> String {
let stripped = model.strip_prefix("claude-").unwrap_or(model);
let without_date = strip_claude_date_suffix(stripped);
match without_date {
"opus" | "sonnet" | "haiku" => Self::default_for_alias(without_date),
other => other.to_string(),
}
}
pub(crate) fn default_for_alias(alias: &str) -> String {
if let Some(learned) = learned_alias(alias) {
return learned;
}
match Self::seed_for_alias(alias) {
Some(seed) => seed.to_string(),
None => alias.to_string(),
}
}
pub(crate) fn seed_for_alias(alias: &str) -> Option<&'static str> {
match alias {
"opus" => Some("opus-4-7"),
"sonnet" => Some("sonnet-4-6"),
"haiku" => Some("haiku-4-5"),
_ => None,
}
}
pub(crate) fn record_observed_model(cli_full_model: &str) {
let alias = Self::map_model(cli_full_model);
let version = Self::normalize_model(cli_full_model);
record_alias(alias, &version);
}
fn build_prompt(request: &LLMRequest) -> String {
let mut parts = Vec::new();
if let Some(system) = request.system_full()
&& !system.is_empty()
{
parts.push(system);
}
for msg in &request.messages {
let role = match msg.role {
Role::User => "Human",
Role::Assistant => "Assistant",
Role::System => "System",
};
let content: String = msg
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.clone()),
ContentBlock::ToolResult {
tool_use_id,
content,
..
} => Some(format!("[tool_result for {}]: {}", tool_use_id, content)),
ContentBlock::ToolUse { id, name, input } => {
Some(format!("[tool_use {} ({}): {}]", name, id, input))
}
ContentBlock::Thinking { thinking, .. } => {
if thinking.is_empty() {
None
} else {
Some(format!("<thinking>{}</thinking>", thinking))
}
}
ContentBlock::Image { source } => {
Some(match source {
ImageSource::Base64 { media_type, data } => {
let ext = match media_type.as_str() {
"image/png" => "png",
"image/jpeg" => "jpeg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "png",
};
let tmp = std::env::temp_dir().join(format!(
"opencrabs_cli_img_{}.{}",
uuid::Uuid::new_v4(),
ext
));
use base64::Engine;
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data)
&& std::fs::write(&tmp, &bytes).is_ok()
{
format!(
"[User attached an image at {}. Use the analyze_image tool to view it.]",
tmp.display()
)
} else {
"[User attached an image but it could not be decoded.]".to_string()
}
}
ImageSource::Url { url } => {
format!(
"[User attached an image: {}. Use the analyze_image tool to view it.]",
url
)
}
})
}
})
.collect::<Vec<_>>()
.join("\n");
if content.trim().is_empty() {
continue;
}
parts.push(format!("{}: {}", role, content));
}
parts.join("\n\n")
}
}
fn resolve_claude_path() -> Result<String> {
if let Ok(path) = std::env::var("CLAUDE_PATH") {
if std::path::Path::new(&path).exists() {
return Ok(path);
}
return Err(ProviderError::Internal(format!(
"CLAUDE_PATH set but not found: {}",
path
)));
}
for candidate in &["/opt/homebrew/bin/claude", "/usr/local/bin/claude"] {
if std::path::Path::new(candidate).exists() {
return Ok(candidate.to_string());
}
}
if let Some(path) = super::which_binary("claude") {
return Ok(path);
}
Err(ProviderError::Internal(
"claude CLI not found — install it or set CLAUDE_PATH".to_string(),
))
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum CliMessage {
System {},
Assistant {
message: CliAssistantMessage,
},
StreamEvent {
event: serde_json::Value,
},
User {},
RateLimitEvent {},
Result {
stop_reason: Option<String>,
usage: Option<CliUsage>,
#[serde(default)]
is_error: bool,
result: Option<String>,
},
}
#[derive(Debug, Deserialize)]
struct CliAssistantMessage {
pub id: Option<String>,
pub model: Option<String>,
pub usage: Option<CliUsage>,
#[serde(default)]
pub content: Vec<CliContentBlock>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum CliContentBlock {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
Thinking {
thinking: String,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize)]
struct CliUsage {
#[serde(default)]
pub input_tokens: u32,
#[serde(default)]
pub output_tokens: u32,
#[serde(default)]
pub cache_creation_input_tokens: u32,
#[serde(default)]
pub cache_read_input_tokens: u32,
}
impl CliUsage {
fn total_input(&self) -> u32 {
self.input_tokens
}
}
#[async_trait]
impl Provider for ClaudeCliProvider {
async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
let mut stream = self.stream(request).await?;
let mut id = String::new();
let mut model = String::new();
let mut content = Vec::new();
let mut stop_reason = None;
let mut usage = TokenUsage::default();
let mut text_buf = String::new();
while let Some(event) = stream.next().await {
match event? {
StreamEvent::MessageStart { message } => {
id = message.id;
model = message.model;
usage = message.usage;
}
StreamEvent::ContentBlockDelta {
delta: ContentDelta::TextDelta { text },
..
} => {
text_buf.push_str(&text);
}
StreamEvent::MessageDelta { delta: d, usage: u } => {
stop_reason = d.stop_reason;
usage.output_tokens = u.output_tokens;
if u.cache_creation_tokens > 0 {
usage.cache_creation_tokens = u.cache_creation_tokens;
}
if u.cache_read_tokens > 0 {
usage.cache_read_tokens = u.cache_read_tokens;
}
}
StreamEvent::MessageStop => break,
_ => {}
}
}
if !text_buf.is_empty() {
content.push(ContentBlock::Text { text: text_buf });
}
Ok(LLMResponse {
id,
model,
content,
stop_reason,
usage,
streaming_active_secs: None,
})
}
async fn stream(&self, request: LLMRequest) -> Result<ProviderStream> {
let prompt = Self::build_prompt(&request);
let original_model = Self::normalize_model(&request.model);
let model = Self::map_model(&request.model).to_string();
let cwd = request
.working_directory
.as_deref()
.map(std::path::PathBuf::from)
.filter(|p| p.is_dir())
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")));
tracing::info!(
"Spawning claude CLI: model={}, prompt_len={}, cwd={}",
model,
prompt.len(),
cwd.display()
);
let session_id_str = uuid::Uuid::new_v4().to_string();
let mut child = tokio::process::Command::new(&self.claude_path)
.env_remove("CLAUDECODE")
.env_remove("CLAUDE_CODE_ENTRYPOINT")
.arg("-p")
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--include-partial-messages")
.arg("--session-id")
.arg(&session_id_str)
.arg("--dangerously-skip-permissions")
.arg("--settings")
.arg(r#"{"attribution":{"commit":"","pr":""},"includeCoAuthoredBy":false}"#)
.arg("--model")
.arg(&model)
.current_dir(&cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| ProviderError::Internal(format!("failed to spawn claude CLI: {}", e)))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| ProviderError::Internal("failed to capture stdin".to_string()))?;
let prompt_bytes = prompt.into_bytes();
tokio::spawn(async move {
let _ = stdin.write_all(&prompt_bytes).await;
let _ = stdin.shutdown().await;
});
let stdout = child
.stdout
.take()
.ok_or_else(|| ProviderError::Internal("failed to capture stdout".to_string()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| ProviderError::Internal("failed to capture stderr".to_string()))?;
tokio::spawn(async move {
let reader = tokio::io::BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let line = line.trim().to_string();
if !line.is_empty() {
tracing::warn!("claude CLI stderr: {}", line);
}
}
});
let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(64);
tokio::spawn(async move {
let reader = tokio::io::BufReader::new(stdout);
let mut lines = reader.lines();
let mut started = false;
let mut streaming_via_events = false;
let mut completed_blocks: usize = 0;
let mut current_block_started = false;
let mut current_block_chars: usize = 0;
let mut input_tokens: u32 = 0;
let mut output_tokens: u32 = 0;
let mut cache_creation_tokens_last: u32 = 0;
let mut cache_read_tokens_last: u32 = 0;
let mut cache_creation_tokens_billing: u32 = 0;
let mut cache_read_tokens_billing: u32 = 0;
let mut result_received = false;
let mut line_count = 0u32;
let mut block_index_offset: usize = 0;
let mut max_block_index_this_round: usize = 0;
loop {
let line_result = tokio::select! {
biased;
_ = tx.closed() => {
tracing::info!("CLI stream cancelled — killing subprocess");
let _ = child.kill().await;
break;
}
result = lines.next_line() => result,
};
let line = match line_result {
Ok(Some(line)) => line,
Ok(None) => {
tracing::info!(
"CLI stdout EOF after {} lines (started={})",
line_count,
started
);
break;
}
Err(e) => {
tracing::error!("CLI stdout read error after {} lines: {}", line_count, e);
break;
}
};
line_count += 1;
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
tracing::debug!("CLI stdout raw: {}", &line[..line.floor_char_boundary(300)]);
let msg: CliMessage = match serde_json::from_str(&line) {
Ok(m) => m,
Err(e) => {
tracing::warn!(
"Skipping unparseable CLI line: {} — {}",
e,
&line[..line.floor_char_boundary(200)]
);
continue;
}
};
match msg {
CliMessage::System { .. } => {
tracing::debug!("CLI → system");
}
CliMessage::StreamEvent { event } => {
let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
streaming_via_events = true;
match event_type {
"message_start" => {
if !started {
started = true;
if let Some(msg) = event.get("message")
&& let Some(u) = msg.get("usage")
&& let Ok(cli_u) =
serde_json::from_value::<CliUsage>(u.clone())
{
input_tokens = cli_u.total_input();
}
if let Some(observed) = event
.get("message")
.and_then(|m| m.get("model"))
.and_then(|m| m.as_str())
{
Self::record_observed_model(observed);
}
match serde_json::from_value::<StreamEvent>(event) {
Ok(mut se) => {
if let StreamEvent::MessageStart { ref mut message } =
se
{
message.usage.input_tokens = input_tokens;
message.model =
Self::normalize_model(&message.model);
}
if tx.send(Ok(se)).await.is_err() {
break;
}
}
Err(e) => {
tracing::warn!("Failed to parse message_start: {}", e);
}
}
}
}
"message_delta" => {
let is_tool_round = event
.get("delta")
.and_then(|d| d.get("stop_reason"))
.and_then(|r| r.as_str())
== Some("tool_use");
if is_tool_round {
block_index_offset += max_block_index_this_round;
max_block_index_this_round = 0;
tracing::debug!(
"CLI tool round ended, block_index_offset={}",
block_index_offset
);
}
if let Some(u) = event.get("usage") {
let round_output = u
.get("output_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0)
as u32;
output_tokens += round_output;
if let Ok(round_usage) =
serde_json::from_value::<CliUsage>(u.clone())
{
let round_input = round_usage.total_input();
if round_input > input_tokens {
input_tokens = round_input;
}
cache_creation_tokens_last =
round_usage.cache_creation_input_tokens;
cache_read_tokens_last =
round_usage.cache_read_input_tokens;
cache_creation_tokens_billing +=
round_usage.cache_creation_input_tokens;
cache_read_tokens_billing +=
round_usage.cache_read_input_tokens;
}
}
if tx.send(Ok(StreamEvent::Ping)).await.is_err() {
break;
}
}
"message_stop" => {
if tx.send(Ok(StreamEvent::Ping)).await.is_err() {
break;
}
}
"content_block_start"
if event
.get("content_block")
.and_then(|b| b.get("type"))
.and_then(|t| t.as_str())
== Some("tool_use") =>
{
match serde_json::from_value::<StreamEvent>(event.clone()) {
Ok(se) => {
if let StreamEvent::ContentBlockStart { index, .. } = &se {
max_block_index_this_round =
max_block_index_this_round.max(index + 1);
}
let se = offset_block_index(se, block_index_offset);
if tx.send(Ok(se)).await.is_err() {
break;
}
}
Err(e) => {
tracing::debug!(
"Skipping tool_use content_block_start: {}",
e
);
}
}
}
"content_block_delta"
if event
.get("delta")
.and_then(|d| d.get("type"))
.and_then(|t| t.as_str())
== Some("input_json_delta") =>
{
match serde_json::from_value::<StreamEvent>(event.clone()) {
Ok(se) => {
let se = offset_block_index(se, block_index_offset);
if tx.send(Ok(se)).await.is_err() {
break;
}
}
Err(e) => {
tracing::debug!("Skipping input_json_delta: {}", e);
}
}
}
_ => match serde_json::from_value::<StreamEvent>(event.clone()) {
Ok(se) => {
match &se {
StreamEvent::ContentBlockStart { index, .. }
| StreamEvent::ContentBlockDelta { index, .. }
| StreamEvent::ContentBlockStop { index } => {
max_block_index_this_round =
max_block_index_this_round.max(index + 1);
}
_ => {}
}
let se = offset_block_index(se, block_index_offset);
let se = normalize_stream_event(se);
if tx.send(Ok(se)).await.is_err() {
break;
}
}
Err(e) => {
tracing::debug!(
"Skipping stream event '{}': {}",
event_type,
e
);
}
},
}
}
CliMessage::Assistant { message } => {
if streaming_via_events {
if let Some(u) = &message.usage {
output_tokens = u.output_tokens;
}
continue;
}
if !started {
started = true;
let msg_id = message.id.unwrap_or_else(|| {
format!("msg_{}", uuid::Uuid::new_v4().simple())
});
if let Some(ref raw) = message.model {
Self::record_observed_model(raw);
}
let msg_model = message
.model
.map(|m| Self::normalize_model(&m))
.unwrap_or_else(|| original_model.clone());
let (input_tokens, cc, cr) = message
.usage
.as_ref()
.map(|u| {
(
u.total_input(),
u.cache_creation_input_tokens,
u.cache_read_input_tokens,
)
})
.unwrap_or((0, 0, 0));
let _ = tx
.send(Ok(StreamEvent::MessageStart {
message: StreamMessage {
id: msg_id,
model: msg_model,
role: Role::Assistant,
usage: TokenUsage {
input_tokens,
output_tokens: 0,
cache_creation_tokens: cc,
cache_read_tokens: cr,
..Default::default()
},
},
}))
.await;
}
let num_blocks = message.content.len();
for (i, block) in message.content.iter().enumerate() {
let is_last = i == num_blocks - 1;
if i < completed_blocks {
continue;
}
if i > completed_blocks {
if current_block_started {
let _ = tx
.send(Ok(StreamEvent::ContentBlockStop {
index: completed_blocks,
}))
.await;
completed_blocks += 1;
current_block_chars = 0;
current_block_started = false;
}
while completed_blocks < i {
emit_full_block(
&tx,
&message.content[completed_blocks],
completed_blocks,
)
.await;
completed_blocks += 1;
}
}
let full_text = cli_block_text(block);
if !current_block_started {
let empty = cli_empty_block(block);
let _ = tx
.send(Ok(StreamEvent::ContentBlockStart {
index: i,
content_block: empty,
}))
.await;
current_block_started = true;
current_block_chars = 0;
}
if full_text.len() > current_block_chars {
let new_text = &full_text[current_block_chars..];
if !new_text.is_empty() {
let delta = cli_block_delta(block, new_text);
let _ = tx
.send(Ok(StreamEvent::ContentBlockDelta {
index: i,
delta,
}))
.await;
current_block_chars = full_text.len();
}
}
if !is_last {
let _ = tx
.send(Ok(StreamEvent::ContentBlockStop { index: i }))
.await;
completed_blocks += 1;
current_block_chars = 0;
current_block_started = false;
}
}
if let Some(u) = &message.usage {
output_tokens = u.output_tokens;
}
}
CliMessage::Result {
stop_reason,
usage,
is_error,
result,
} => {
if is_error {
let error_text =
result.unwrap_or_else(|| "CLI returned an error".to_string());
tracing::error!("CLI result is_error=true: {}", error_text);
let error_lower = error_text.to_lowercase();
if error_lower.contains("prompt is too long")
|| error_lower.contains("too many tokens")
|| error_lower.contains("context length")
{
let _ = tx.send(Err(ProviderError::ContextLengthExceeded(0))).await;
break;
}
if error_lower.contains("rate limit")
|| error_lower.contains("hit your limit")
|| error_lower.contains("overloaded")
|| error_lower.contains("too many requests")
|| error_lower.contains("capacity")
|| error_lower.contains("429")
{
tracing::warn!(
"CLI rate/account limit detected — returning RateLimitExceeded for fallback"
);
let _ = tx
.send(Err(ProviderError::RateLimitExceeded(error_text)))
.await;
break;
}
if !started {
started = true;
let _ = tx
.send(Ok(StreamEvent::MessageStart {
message: StreamMessage {
id: format!("msg_{}", uuid::Uuid::new_v4().simple()),
model: original_model.clone(),
role: Role::Assistant,
usage: TokenUsage {
input_tokens: 0,
output_tokens: 0,
..Default::default()
},
},
}))
.await;
}
if current_block_started {
let _ = tx
.send(Ok(StreamEvent::ContentBlockStop {
index: completed_blocks,
}))
.await;
completed_blocks += 1;
}
let error_idx = completed_blocks + block_index_offset;
let _ = tx
.send(Ok(StreamEvent::ContentBlockStart {
index: error_idx,
content_block: ContentBlock::Text {
text: String::new(),
},
}))
.await;
let _ = tx
.send(Ok(StreamEvent::ContentBlockDelta {
index: error_idx,
delta: ContentDelta::TextDelta {
text: format!("\n\n⚠️ CLI error: {}", error_text),
},
}))
.await;
let _ = tx
.send(Ok(StreamEvent::ContentBlockStop { index: error_idx }))
.await;
} else {
if current_block_started {
let _ = tx
.send(Ok(StreamEvent::ContentBlockStop {
index: completed_blocks,
}))
.await;
}
}
let reason = stop_reason.map(|r| match r.as_str() {
"end_turn" => StopReason::EndTurn,
"tool_use" => StopReason::ToolUse,
"max_tokens" => StopReason::MaxTokens,
_ => StopReason::EndTurn,
});
let final_output = usage
.as_ref()
.map(|u| u.output_tokens)
.unwrap_or(output_tokens);
let final_input = usage
.as_ref()
.map(|u| u.total_input())
.unwrap_or(input_tokens);
if let Some(ref u) = usage {
tracing::debug!(
"CLI session complete: input={}, cache_create={}, cache_read={}, total={}",
u.input_tokens,
u.cache_creation_input_tokens,
u.cache_read_input_tokens,
u.total_input()
);
}
let (result_cc, result_cr) = usage
.as_ref()
.map(|u| (u.cache_creation_input_tokens, u.cache_read_input_tokens))
.unwrap_or((0, 0));
let ctx_cache_creation = if cache_creation_tokens_last > 0 {
cache_creation_tokens_last
} else {
result_cc
};
let ctx_cache_read = if cache_read_tokens_last > 0 {
cache_read_tokens_last
} else {
result_cr
};
let billing_cache_creation = if cache_creation_tokens_billing > 0 {
cache_creation_tokens_billing
} else {
result_cc
};
let billing_cache_read = if cache_read_tokens_billing > 0 {
cache_read_tokens_billing
} else {
result_cr
};
tracing::info!(
"CLI token split: context={}+{}+{}={}, billing={}+{}+{}={}",
final_input,
ctx_cache_creation,
ctx_cache_read,
final_input + ctx_cache_creation + ctx_cache_read,
final_input,
billing_cache_creation,
billing_cache_read,
final_input + billing_cache_creation + billing_cache_read,
);
let _ = tx
.send(Ok(StreamEvent::MessageDelta {
delta: MessageDelta {
stop_reason: reason,
stop_sequence: None,
},
usage: TokenUsage {
input_tokens: final_input,
output_tokens: final_output,
cache_creation_tokens: ctx_cache_creation,
cache_read_tokens: ctx_cache_read,
billing_cache_creation,
billing_cache_read,
},
}))
.await;
let _ = tx.send(Ok(StreamEvent::MessageStop)).await;
result_received = true;
break;
}
CliMessage::User { .. } => {
tracing::debug!("CLI → user turn (tool_result)");
if tx.send(Ok(StreamEvent::Ping)).await.is_err() {
break;
}
}
CliMessage::RateLimitEvent {} => {
tracing::warn!("CLI → rate_limit_event");
if tx.send(Ok(StreamEvent::Ping)).await.is_err() {
break;
}
}
}
}
if started && !result_received && (input_tokens > 0 || output_tokens > 0) {
tracing::info!(
"CLI EOF without Result — flushing accumulated usage: input={}, output={}",
input_tokens,
output_tokens
);
let _ = tx
.send(Ok(StreamEvent::MessageDelta {
delta: MessageDelta {
stop_reason: Some(StopReason::EndTurn),
stop_sequence: None,
},
usage: TokenUsage {
input_tokens,
output_tokens,
cache_creation_tokens: cache_creation_tokens_last,
cache_read_tokens: cache_read_tokens_last,
..Default::default()
},
}))
.await;
let _ = tx.send(Ok(StreamEvent::MessageStop)).await;
}
let exit_status = child.wait().await;
match &exit_status {
Ok(status) if !status.success() => {
tracing::warn!("claude CLI exited with status: {}", status);
if !started {
let _ = tx
.send(Err(ProviderError::Internal(format!(
"claude CLI exited with {} before producing any output",
status
))))
.await;
}
}
Err(e) => {
tracing::error!("Failed to wait on claude CLI: {}", e);
}
Ok(_) => {
if !started {
tracing::warn!(
"claude CLI exited successfully but produced no stream events"
);
}
}
}
});
let stream = futures::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|item| (item, rx))
});
Ok(Box::pin(stream))
}
fn name(&self) -> &str {
"claude-cli"
}
fn default_model(&self) -> &str {
&self.default_model
}
fn supported_models(&self) -> Vec<String> {
available_models()
}
fn configured_context_window(&self) -> Option<u32> {
self.configured_context_window
}
fn context_window(&self, _model: &str) -> Option<u32> {
Some(200_000) }
fn calculate_cost(&self, model: &str, input_tokens: u32, output_tokens: u32) -> f64 {
crate::usage::pricing::PricingConfig::load()
.map(|cfg| cfg.calculate_cost(model, input_tokens, output_tokens))
.unwrap_or(0.0)
}
fn supports_tools(&self) -> bool {
true }
fn supports_vision(&self) -> bool {
false }
fn cli_handles_tools(&self) -> bool {
true }
fn cli_manages_context(&self) -> bool {
false
}
}
fn cli_block_text(block: &CliContentBlock) -> &str {
match block {
CliContentBlock::Text { text } => text.as_str(),
CliContentBlock::Thinking { thinking } => thinking.as_str(),
_ => "",
}
}
fn cli_empty_block(block: &CliContentBlock) -> ContentBlock {
match block {
CliContentBlock::Text { .. } => ContentBlock::Text {
text: String::new(),
},
CliContentBlock::Thinking { .. } => ContentBlock::Thinking {
thinking: String::new(),
signature: None,
},
CliContentBlock::ToolUse { id, name, .. } => ContentBlock::ToolUse {
id: id.clone(),
name: normalize_cli_tool_name(name),
input: serde_json::json!({}),
},
CliContentBlock::Unknown => ContentBlock::Text {
text: String::new(),
},
}
}
fn cli_block_delta(block: &CliContentBlock, new_text: &str) -> ContentDelta {
match block {
CliContentBlock::Thinking { .. } => ContentDelta::ThinkingDelta {
thinking: new_text.to_string(),
},
_ => ContentDelta::TextDelta {
text: new_text.to_string(),
},
}
}
async fn emit_full_block(
tx: &tokio::sync::mpsc::Sender<super::error::Result<StreamEvent>>,
block: &CliContentBlock,
index: usize,
) {
let empty = cli_empty_block(block);
let _ = tx
.send(Ok(StreamEvent::ContentBlockStart {
index,
content_block: empty,
}))
.await;
match block {
CliContentBlock::Text { text } => {
let _ = tx
.send(Ok(StreamEvent::ContentBlockDelta {
index,
delta: ContentDelta::TextDelta { text: text.clone() },
}))
.await;
}
CliContentBlock::Thinking { thinking } => {
let _ = tx
.send(Ok(StreamEvent::ContentBlockDelta {
index,
delta: ContentDelta::ThinkingDelta {
thinking: thinking.clone(),
},
}))
.await;
}
CliContentBlock::ToolUse { input, .. } => {
let input_str = serde_json::to_string(input).unwrap_or_default();
let _ = tx
.send(Ok(StreamEvent::ContentBlockDelta {
index,
delta: ContentDelta::InputJsonDelta {
partial_json: input_str,
},
}))
.await;
}
CliContentBlock::Unknown => {}
}
let _ = tx.send(Ok(StreamEvent::ContentBlockStop { index })).await;
}
fn normalize_cli_tool_name(name: &str) -> String {
match name {
"Bash" => "bash".to_string(),
"Read" => "read_file".to_string(),
"Write" => "write_file".to_string(),
"Edit" => "edit_file".to_string(),
"Grep" => "grep".to_string(),
"Glob" => "glob".to_string(),
"LSP" => "lsp".to_string(),
"WebSearch" => "web_search".to_string(),
"WebFetch" => "http_request".to_string(),
"Agent" => "agent".to_string(),
"NotebookEdit" => "notebook_edit".to_string(),
other => other.to_string(),
}
}
fn offset_block_index(event: StreamEvent, offset: usize) -> StreamEvent {
if offset == 0 {
return event;
}
match event {
StreamEvent::ContentBlockStart {
index,
content_block,
} => StreamEvent::ContentBlockStart {
index: index + offset,
content_block,
},
StreamEvent::ContentBlockDelta { index, delta } => StreamEvent::ContentBlockDelta {
index: index + offset,
delta,
},
StreamEvent::ContentBlockStop { index } => StreamEvent::ContentBlockStop {
index: index + offset,
},
other => other,
}
}
fn normalize_stream_event(event: StreamEvent) -> StreamEvent {
match event {
StreamEvent::ContentBlockStart {
index,
content_block: ContentBlock::ToolUse { id, name, input },
} => StreamEvent::ContentBlockStart {
index,
content_block: ContentBlock::ToolUse {
id,
name: normalize_cli_tool_name(&name),
input,
},
},
other => other,
}
}
pub(crate) fn strip_claude_date_suffix(s: &str) -> &str {
if let Some(idx) = s.rfind('-') {
let suffix = &s[idx + 1..];
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
return &s[..idx];
}
}
s
}