From cde5f657ec23ffa1c6fdf473891ecee3c43fa2c3 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 27 May 2026 23:56:55 +0530 Subject: [PATCH] feat(inference): add claude_agent_sdk provider (Claude CLI subprocess) (#2746) --- .../config/schema/claude_agent_sdk.rs | 44 ++++ src/openhuman/config/schema/mod.rs | 2 + src/openhuman/config/schema/types.rs | 6 + src/openhuman/doctor/core.rs | 93 ++++++++ .../provider/claude_agent_sdk/mod.rs | 6 + .../provider/claude_agent_sdk/protocol.rs | 28 +++ .../provider/claude_agent_sdk/subprocess.rs | 216 ++++++++++++++++++ src/openhuman/inference/provider/factory.rs | 22 +- .../inference/provider/factory_test.rs | 32 +++ src/openhuman/inference/provider/mod.rs | 1 + 10 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 src/openhuman/config/schema/claude_agent_sdk.rs create mode 100644 src/openhuman/inference/provider/claude_agent_sdk/mod.rs create mode 100644 src/openhuman/inference/provider/claude_agent_sdk/protocol.rs create mode 100644 src/openhuman/inference/provider/claude_agent_sdk/subprocess.rs diff --git a/src/openhuman/config/schema/claude_agent_sdk.rs b/src/openhuman/config/schema/claude_agent_sdk.rs new file mode 100644 index 000000000..8979791a0 --- /dev/null +++ b/src/openhuman/config/schema/claude_agent_sdk.rs @@ -0,0 +1,44 @@ +//! Configuration for the Claude Agent SDK subprocess provider. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ClaudeAgentSdkConfig { + /// Whether the Claude Agent SDK provider is enabled. + #[serde(default)] + pub enabled: bool, + + /// Path to the `claude` CLI binary. Defaults to `"claude"` (PATH lookup). + #[serde(default = "default_claude_binary")] + pub binary: String, + + /// Default model passed via `--model` when the provider string is just + /// `"claude_agent_sdk"` with no model suffix. + #[serde(default = "default_claude_model")] + pub default_model: String, + + /// Maximum spend in USD before aborting a request (`--max-turns` controls + /// loop depth; `--budget` controls cost). `None` means no cap. + #[serde(default)] + pub max_budget_usd: Option, +} + +fn default_claude_binary() -> String { + "claude".to_string() +} + +fn default_claude_model() -> String { + "claude-sonnet-4-6".to_string() +} + +impl Default for ClaudeAgentSdkConfig { + fn default() -> Self { + Self { + enabled: false, + binary: default_claude_binary(), + default_model: default_claude_model(), + max_budget_usd: None, + } + } +} diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 7722e1f8b..310cf93f1 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -23,6 +23,8 @@ pub use load::{ clear_active_user, default_projects_dir, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id, user_openhuman_dir, write_active_user_id, PRE_LOGIN_USER_ID, }; +pub mod claude_agent_sdk; +pub use claude_agent_sdk::ClaudeAgentSdkConfig; mod local_ai; mod meet; mod node; diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 81999c11d..e536127ad 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -208,6 +208,11 @@ pub struct Config { #[serde(default)] pub local_ai: LocalAiConfig, + /// Claude Agent SDK provider configuration — routes inference through the + /// `claude -p` CLI subprocess using the subscriber's Claude plan credit. + #[serde(default)] + pub claude_agent_sdk: ClaudeAgentSdkConfig, + // ── Unified AI provider routing ────────────────────────────────────────── // // Provider-string grammar (consumed by `providers::factory`): @@ -628,6 +633,7 @@ impl Default for Config { computer_control: ComputerControlConfig::default(), agents: HashMap::new(), local_ai: LocalAiConfig::default(), + claude_agent_sdk: ClaudeAgentSdkConfig::default(), cloud_providers: Vec::new(), primary_cloud: None, chat_provider: None, diff --git a/src/openhuman/doctor/core.rs b/src/openhuman/doctor/core.rs index 0a5b2a6fa..38dcf93cb 100644 --- a/src/openhuman/doctor/core.rs +++ b/src/openhuman/doctor/core.rs @@ -80,6 +80,7 @@ pub fn run(config: &Config) -> Result { check_environment(&mut items); check_memory_tree_db(config, &mut items); check_embedding_model_health(config, &mut items); + check_claude_agent_sdk(config, &mut items); let errors = items .iter() @@ -991,6 +992,98 @@ fn check_embedding_model_health(config: &Config, items: &mut Vec } } +// ── Claude Agent SDK check ─────────────────────────────────────── + +fn check_claude_agent_sdk(config: &Config, items: &mut Vec) { + let sdk = &config.claude_agent_sdk; + if !sdk.enabled { + return; + } + + tracing::debug!("probe:claude_agent_sdk:entry binary={}", sdk.binary); + + // Probe the configured binary by running ` --version`. + let mut cmd = std::process::Command::new(&sdk.binary); + cmd.arg("--version") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + + tracing::debug!( + "probe:claude_agent_sdk:exec binary={} cmd=--version", + sdk.binary + ); + + match cmd.output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("(unknown version)") + .to_string(); + tracing::info!( + "probe:claude_agent_sdk:ok binary={} version={}", + sdk.binary, + version + ); + items.push(DiagnosticItem::ok( + "claude_agent_sdk", + format!("claude CLI found (binary='{}'): {version}", sdk.binary), + )); + tracing::debug!( + "probe:claude_agent_sdk:exit binary={} result=ok", + sdk.binary + ); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let preview = stderr.lines().next().unwrap_or("(no stderr)"); + tracing::warn!( + "probe:claude_agent_sdk:warn binary={} status={:?} stderr={}", + sdk.binary, + output.status, + truncate_for_display(preview, COMMAND_VERSION_PREVIEW_CHARS) + ); + items.push(DiagnosticItem::warn( + "claude_agent_sdk", + format!( + "claude CLI execution failed (binary='{}', status={}). {}", + sdk.binary, + output.status, + truncate_for_display(preview, COMMAND_VERSION_PREVIEW_CHARS) + ), + )); + tracing::debug!( + "probe:claude_agent_sdk:exit binary={} result=warn", + sdk.binary + ); + } + Err(err) => { + tracing::warn!( + "probe:claude_agent_sdk:warn binary={} err={}", + sdk.binary, + err + ); + items.push(DiagnosticItem::warn( + "claude_agent_sdk", + format!( + "claude CLI not found or not executable (configured binary='{}'): {}. \ + Install from https://claude.ai/code or set claude_agent_sdk.binary in config.", + sdk.binary, err + ), + )); + tracing::debug!( + "probe:claude_agent_sdk:exit binary={} result=warn", + sdk.binary + ); + } + } +} + // ── Helpers ────────────────────────────────────────────────────── fn parse_rfc3339(input: &str) -> Option> { diff --git a/src/openhuman/inference/provider/claude_agent_sdk/mod.rs b/src/openhuman/inference/provider/claude_agent_sdk/mod.rs new file mode 100644 index 000000000..76c73e070 --- /dev/null +++ b/src/openhuman/inference/provider/claude_agent_sdk/mod.rs @@ -0,0 +1,6 @@ +//! Provider that routes inference through the `claude -p` CLI subprocess. + +mod protocol; +pub mod subprocess; + +pub use subprocess::ClaudeAgentSdkProvider; diff --git a/src/openhuman/inference/provider/claude_agent_sdk/protocol.rs b/src/openhuman/inference/provider/claude_agent_sdk/protocol.rs new file mode 100644 index 000000000..3b48aae91 --- /dev/null +++ b/src/openhuman/inference/provider/claude_agent_sdk/protocol.rs @@ -0,0 +1,28 @@ +//! Wire types for the `claude --output-format stream-json` NDJSON protocol. + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SdkMessage { + Text { + text: String, + }, + Result { + result: Option, + #[serde(rename = "is_error")] + is_error: bool, + #[serde(default)] + total_cost_usd: Option, + }, + Error { + error: SdkError, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +pub struct SdkError { + pub message: String, +} diff --git a/src/openhuman/inference/provider/claude_agent_sdk/subprocess.rs b/src/openhuman/inference/provider/claude_agent_sdk/subprocess.rs new file mode 100644 index 000000000..39ab70fbf --- /dev/null +++ b/src/openhuman/inference/provider/claude_agent_sdk/subprocess.rs @@ -0,0 +1,216 @@ +//! Subprocess lifecycle for the Claude Agent SDK provider. + +use anyhow::Context; +use async_trait::async_trait; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::time::{timeout, Duration}; + +use crate::openhuman::config::schema::claude_agent_sdk::ClaudeAgentSdkConfig; +use crate::openhuman::inference::provider::traits::Provider; + +use super::protocol::SdkMessage; + +pub struct ClaudeAgentSdkProvider { + pub(super) config: ClaudeAgentSdkConfig, +} + +impl ClaudeAgentSdkProvider { + pub fn new(config: ClaudeAgentSdkConfig) -> Self { + Self { config } + } +} + +#[async_trait] +impl Provider for ClaudeAgentSdkProvider { + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + model: &str, + _temperature: f64, + ) -> anyhow::Result { + let model = if model.is_empty() { + &self.config.default_model + } else { + model + }; + + // Prepend system prompt inline — claude -p has no separate system flag. + let full_message = match system_prompt { + Some(s) if !s.trim().is_empty() => { + format!("[SYSTEM]\n{s}\n[/SYSTEM]\n\n{message}") + } + _ => message.to_string(), + }; + + let mut cmd = Command::new(&self.config.binary); + cmd.arg("-p") + .arg(&full_message) + .arg("--model") + .arg(model) + .arg("--output-format") + .arg("stream-json") + .arg("--no-color") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .stdin(std::process::Stdio::null()); + + if let Some(budget) = self.config.max_budget_usd { + cmd.arg("--max-turns").arg("10"); + // Note: --budget flag controls the spend cap in the Claude CLI + cmd.arg("--budget").arg(format!("{budget:.4}")); + } + + tracing::debug!( + "[claude_agent_sdk] spawning claude binary={} model={} message_len={}", + self.config.binary, + model, + full_message.len() + ); + + let mut child = cmd + .spawn() + .with_context(|| format!("failed to spawn claude binary '{}'", self.config.binary))?; + + let stdout = child + .stdout + .take() + .context("claude subprocess has no stdout")?; + let stderr = child + .stderr + .take() + .context("claude subprocess has no stderr")?; + + // Drain stderr concurrently to prevent pipe-buffer stalls and capture failure context. + let stderr_task = tokio::spawn(async move { + let mut err_lines = BufReader::new(stderr).lines(); + let mut buf = String::new(); + while let Ok(Some(line)) = err_lines.next_line().await { + if !buf.is_empty() { + buf.push('\n'); + } + buf.push_str(&line); + } + buf + }); + + let mut lines = BufReader::new(stdout).lines(); + let mut text_parts: Vec = Vec::new(); + let mut result_text: Option = None; + let mut error_message: Option = None; + + let read_result = timeout(Duration::from_secs(120), async { + while let Some(line) = lines.next_line().await? { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + tracing::trace!( + "[claude_agent_sdk] ndjson line received line_len={}", + line.len() + ); + match serde_json::from_str::(&line) { + Ok(SdkMessage::Text { text }) => { + text_parts.push(text); + } + Ok(SdkMessage::Result { + result, + is_error, + total_cost_usd, + }) => { + if let Some(cost) = total_cost_usd { + tracing::debug!( + "[claude_agent_sdk] request completed total_cost_usd={:.6}", + cost + ); + } + if is_error { + error_message = Some(result.unwrap_or_else(|| { + "claude subprocess returned an error".to_string() + })); + } else { + result_text = result; + } + } + Ok(SdkMessage::Error { error }) => { + error_message = Some(error.message); + } + Ok(SdkMessage::Unknown) => { + tracing::trace!("[claude_agent_sdk] unknown ndjson message type, skipping"); + } + Err(e) => { + tracing::warn!( + error = %e, + line_len = line.len(), + "[claude_agent_sdk] failed to parse ndjson line" + ); + } + } + } + anyhow::Ok(()) + }) + .await; + + match read_result { + Ok(inner) => inner?, + Err(_) => { + let _ = child.kill().await; + anyhow::bail!("[claude_agent_sdk] subprocess timed out while reading output"); + } + } + + let status = timeout(Duration::from_secs(30), child.wait()) + .await + .map_err(|_| { + anyhow::anyhow!("[claude_agent_sdk] subprocess timed out while waiting for exit") + })??; + let stderr_output = stderr_task.await.unwrap_or_default(); + tracing::debug!("[claude_agent_sdk] subprocess exited status={}", status); + + if let Some(err) = error_message { + anyhow::bail!("[claude_agent_sdk] error from claude CLI: {err}"); + } + + // Use the final result message if present; otherwise join streaming text parts. + let output = result_text + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| text_parts.join("")); + + if !status.success() && output.is_empty() { + anyhow::bail!( + "[claude_agent_sdk] claude subprocess exited with non-zero status {} and no output; stderr={}", + status, + stderr_output + ); + } + + tracing::debug!( + "[claude_agent_sdk] response collected output_len={}", + output.len() + ); + + Ok(output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::schema::claude_agent_sdk::ClaudeAgentSdkConfig; + + #[test] + fn provider_constructs_with_default_config() { + let config = ClaudeAgentSdkConfig::default(); + let provider = ClaudeAgentSdkProvider::new(config); + assert_eq!(provider.config.binary, "claude"); + assert_eq!(provider.config.default_model, "claude-sonnet-4-6"); + } + + #[test] + fn config_default_disabled() { + let config = ClaudeAgentSdkConfig::default(); + assert!(!config.enabled); + assert!(config.max_budget_usd.is_none()); + } +} diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 96f24962c..2fd5bfe7f 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -24,6 +24,7 @@ use crate::openhuman::config::schema::cloud_providers::AuthStyle; use crate::openhuman::config::Config; use crate::openhuman::credentials::AuthService; +use crate::openhuman::inference::provider::claude_agent_sdk::subprocess::ClaudeAgentSdkProvider; use crate::openhuman::inference::provider::compatible::{ AuthStyle as CompatAuthStyle, OpenAiCompatibleProvider, }; @@ -37,6 +38,10 @@ pub const PROVIDER_OPENHUMAN: &str = "openhuman"; pub const OLLAMA_PROVIDER_PREFIX: &str = "ollama:"; /// Prefix for LM Studio-local providers: `"lmstudio:"`. pub const LM_STUDIO_PROVIDER_PREFIX: &str = "lmstudio:"; +/// Prefix for the Claude Agent SDK subprocess provider: `"claude_agent_sdk:"`. +pub const CLAUDE_AGENT_SDK_PREFIX: &str = "claude_agent_sdk:"; +/// Sentinel for the Claude Agent SDK provider without a model suffix. +pub const CLAUDE_AGENT_SDK_PROVIDER: &str = "claude_agent_sdk"; /// Sentinel returned when a user has expressed custom/BYOK inference intent /// (via a non-openhuman `inference_url`) but no matching `cloud_providers` /// entry was found. Passed through `provider_for_role` and caught early in @@ -291,6 +296,20 @@ pub fn create_chat_provider_from_string( return make_lm_studio_provider(&model, temperature_override, config); } + if p == CLAUDE_AGENT_SDK_PROVIDER || p.starts_with(CLAUDE_AGENT_SDK_PREFIX) { + let model = if let Some(m) = p.strip_prefix(CLAUDE_AGENT_SDK_PREFIX) { + m.trim().to_string() + } else { + config.claude_agent_sdk.default_model.clone() + }; + tracing::debug!( + "[providers][chat-factory] creating claude_agent_sdk provider model={}", + model + ); + let provider = ClaudeAgentSdkProvider::new(config.claude_agent_sdk.clone()); + return Ok((Box::new(provider), model)); + } + // New grammar: ":[@]" if let Some(colon_pos) = p.find(':') { let slug = p[..colon_pos].trim(); @@ -312,7 +331,8 @@ pub fn create_chat_provider_from_string( // than an opaque parse failure. anyhow::bail!( "[chat-factory] unrecognised provider string '{}' for role '{}'. \ - Valid forms: openhuman, ollama:, lmstudio:, :. \ + Valid forms: openhuman, ollama:, lmstudio:, claude_agent_sdk, \ + claude_agent_sdk:, :. \ Configured slugs: [{}]", p, role, diff --git a/src/openhuman/inference/provider/factory_test.rs b/src/openhuman/inference/provider/factory_test.rs index b4d9abe37..9bb7712c5 100644 --- a/src/openhuman/inference/provider/factory_test.rs +++ b/src/openhuman/inference/provider/factory_test.rs @@ -1088,6 +1088,38 @@ fn byok_fallback_empty_string_treated_as_unset() { ); } +// ── claude_agent_sdk provider factory tests ─────────────────────────────────── + +#[test] +fn claude_agent_sdk_bare_provider_string_uses_default_model() { + let config = Config::default(); + let (_, model) = create_chat_provider_from_string("reasoning", "claude_agent_sdk", &config) + .expect("claude_agent_sdk must build without a model suffix"); + // Default model from ClaudeAgentSdkConfig + assert_eq!( + model, "claude-sonnet-4-6", + "claude_agent_sdk with no suffix must use the default model" + ); +} + +#[test] +fn claude_agent_sdk_with_model_suffix() { + let config = Config::default(); + let (_, model) = + create_chat_provider_from_string("reasoning", "claude_agent_sdk:claude-opus-4-7", &config) + .expect("claude_agent_sdk: must build"); + assert_eq!(model, "claude-opus-4-7"); +} + +#[test] +fn claude_agent_sdk_with_custom_default_model_in_config() { + let mut config = Config::default(); + config.claude_agent_sdk.default_model = "claude-haiku-4-5".to_string(); + let (_, model) = create_chat_provider_from_string("chat", "claude_agent_sdk", &config) + .expect("claude_agent_sdk must build with config default model"); + assert_eq!(model, "claude-haiku-4-5"); +} + // ── resolve_byok_fallback_provider_string direct tests ─────────────────────── #[test] diff --git a/src/openhuman/inference/provider/mod.rs b/src/openhuman/inference/provider/mod.rs index fee0201d2..040b476e7 100644 --- a/src/openhuman/inference/provider/mod.rs +++ b/src/openhuman/inference/provider/mod.rs @@ -5,6 +5,7 @@ //! providers, HTTP endpoint) share a single domain root. pub mod billing_error; +pub mod claude_agent_sdk; pub mod compatible; pub mod compatible_dump; pub mod compatible_parse;