mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(inference): add claude_agent_sdk provider (Claude CLI subprocess) (#2746)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user