feat(inference): add claude_agent_sdk provider (Claude CLI subprocess) (#2746)

This commit is contained in:
Mega Mind
2026-05-27 23:56:55 +05:30
committed by GitHub
parent 7fc88c18d8
commit cde5f657ec
10 changed files with 449 additions and 1 deletions
@@ -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<f64>,
}
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,
}
}
}
+2
View File
@@ -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;
+6
View File
@@ -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,
+93
View File
@@ -80,6 +80,7 @@ pub fn run(config: &Config) -> Result<DoctorReport> {
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<DiagnosticItem>
}
}
// ── Claude Agent SDK check ───────────────────────────────────────
fn check_claude_agent_sdk(config: &Config, items: &mut Vec<DiagnosticItem>) {
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 `<binary> --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<DateTime<Utc>> {
@@ -0,0 +1,6 @@
//! Provider that routes inference through the `claude -p` CLI subprocess.
mod protocol;
pub mod subprocess;
pub use subprocess::ClaudeAgentSdkProvider;
@@ -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<String>,
#[serde(rename = "is_error")]
is_error: bool,
#[serde(default)]
total_cost_usd: Option<f64>,
},
Error {
error: SdkError,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize)]
pub struct SdkError {
pub message: String,
}
@@ -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<String> {
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<String> = Vec::new();
let mut result_text: Option<String> = None;
let mut error_message: Option<String> = 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::<SdkMessage>(&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());
}
}
+21 -1
View File
@@ -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:<model>"`.
pub const LM_STUDIO_PROVIDER_PREFIX: &str = "lmstudio:";
/// Prefix for the Claude Agent SDK subprocess provider: `"claude_agent_sdk:<model>"`.
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: "<slug>:<model>[@<temp>]"
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:<model>, lmstudio:<model>, <slug>:<model>. \
Valid forms: openhuman, ollama:<model>, lmstudio:<model>, claude_agent_sdk, \
claude_agent_sdk:<model>, <slug>:<model>. \
Configured slugs: [{}]",
p,
role,
@@ -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:<model> 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]
+1
View File
@@ -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;