mirror of
https://github.com/katanemo/plano.git
synced 2026-07-05 15:52:12 +02:00
feat(claude-cli): add local Claude Code CLI provider bridge
Spawn the local `claude` binary as a subprocess and expose it as an
Anthropic Messages-compatible provider. Hosted in brightstaff
(`CLAUDE_CLI_LISTEN_ADDR`), with session reuse, idle TTL, and watchdog.
User-facing surface is `model_providers: [{ model: claude-cli/* }]` —
the Python CLI auto-fills name/provider_interface/base_url/access_key
and the launcher (native + supervisord) enables the bridge listener
only when at least one claude-cli provider is present.
This commit is contained in:
parent
b71a555f19
commit
9fdfeb7cbf
26 changed files with 2847 additions and 2 deletions
190
crates/brightstaff/tests/claude_cli_bridge.rs
Normal file
190
crates/brightstaff/tests/claude_cli_bridge.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
//! Integration test for the claude-cli bridge. Spins up the listener with a
|
||||
//! fake `claude` shell script that emits a canned NDJSON sequence, then
|
||||
//! verifies both the streaming SSE and non-streaming JSON code paths produce
|
||||
//! the expected Anthropic Messages output.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use brightstaff::handlers::claude_cli::{
|
||||
self, ClaudeCliConfig, SessionManager, SessionManagerConfig,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
fn fake_claude_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join("fake_claude.sh")
|
||||
}
|
||||
|
||||
async fn pick_free_addr() -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
addr
|
||||
}
|
||||
|
||||
struct BridgeFixture {
|
||||
addr: std::net::SocketAddr,
|
||||
shutdown: Option<oneshot::Sender<()>>,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl BridgeFixture {
|
||||
async fn start() -> Self {
|
||||
let addr = pick_free_addr().await;
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
|
||||
let manager = SessionManager::new(SessionManagerConfig {
|
||||
max_sessions: 4,
|
||||
process: ClaudeCliConfig {
|
||||
binary: fake_claude_path().to_string_lossy().to_string(),
|
||||
permission_mode: "bypassPermissions".to_string(),
|
||||
session_ttl: Duration::from_secs(60),
|
||||
watchdog: Duration::from_secs(5),
|
||||
},
|
||||
});
|
||||
|
||||
let manager_for_listener = Arc::clone(&manager);
|
||||
let handle = tokio::spawn(async move {
|
||||
let shutdown = async move {
|
||||
let _ = shutdown_rx.await;
|
||||
};
|
||||
if let Err(err) = claude_cli::run_listener(addr, manager_for_listener, shutdown).await {
|
||||
eprintln!("listener exited with error: {err}");
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the listener to bind. Loop until we can connect.
|
||||
for _ in 0..50 {
|
||||
if tokio::net::TcpStream::connect(addr).await.is_ok() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
Self {
|
||||
addr,
|
||||
shutdown: Some(shutdown_tx),
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop(mut self) {
|
||||
if let Some(tx) = self.shutdown.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(h) = self.handle.take() {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), h).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_request(stream: bool) -> Value {
|
||||
json!({
|
||||
"model": "claude-cli/sonnet",
|
||||
"max_tokens": 64,
|
||||
"stream": stream,
|
||||
"messages": [
|
||||
{"role": "user", "content": "say hi"}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_request_emits_anthropic_sse() {
|
||||
let fixture = BridgeFixture::start().await;
|
||||
let url = format!("http://{}/v1/messages", fixture.addr);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&anthropic_request(true))
|
||||
.send()
|
||||
.await
|
||||
.expect("send request");
|
||||
assert_eq!(resp.status(), 200);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
assert!(
|
||||
ct.starts_with("text/event-stream"),
|
||||
"expected text/event-stream, got {ct}"
|
||||
);
|
||||
let body = resp.text().await.expect("read body");
|
||||
|
||||
// SSE event names should mirror Anthropic's wire format, in order.
|
||||
let events: Vec<&str> = body
|
||||
.lines()
|
||||
.filter_map(|l| l.strip_prefix("event: "))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
"message_start",
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
],
|
||||
"unexpected SSE event sequence:\n{body}"
|
||||
);
|
||||
|
||||
// The two text deltas should reconstruct "Hello, world!".
|
||||
let mut combined = String::new();
|
||||
for line in body.lines() {
|
||||
if let Some(payload) = line.strip_prefix("data: ") {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(payload) {
|
||||
if v.get("type").and_then(|t| t.as_str()) == Some("content_block_delta") {
|
||||
if let Some(text) = v
|
||||
.get("delta")
|
||||
.and_then(|d| d.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
{
|
||||
combined.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(combined, "Hello, world!");
|
||||
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_streaming_request_returns_messages_response() {
|
||||
let fixture = BridgeFixture::start().await;
|
||||
let url = format!("http://{}/v1/messages", fixture.addr);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&anthropic_request(false))
|
||||
.send()
|
||||
.await
|
||||
.expect("send request");
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: Value = resp.json().await.expect("parse json");
|
||||
|
||||
assert_eq!(body["type"], "message");
|
||||
assert_eq!(body["role"], "assistant");
|
||||
assert_eq!(body["stop_reason"], "end_turn");
|
||||
assert_eq!(body["usage"]["input_tokens"], 3);
|
||||
assert_eq!(body["usage"]["output_tokens"], 4);
|
||||
let content = body["content"].as_array().expect("content array");
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert_eq!(content[0]["text"], "Hello, world!");
|
||||
|
||||
fixture.stop().await;
|
||||
}
|
||||
26
crates/brightstaff/tests/fixtures/fake_claude.sh
vendored
Executable file
26
crates/brightstaff/tests/fixtures/fake_claude.sh
vendored
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
# Stand-in for the real `claude` CLI used by the brightstaff integration test.
|
||||
# Reads stdin (so it does not exit early when the bridge writes the user
|
||||
# JSONL turn) and emits a canned `--output-format stream-json` NDJSON
|
||||
# sequence that mirrors a one-turn "Hello, world!" response.
|
||||
#
|
||||
# All CLI flags are accepted and ignored; only the NDJSON output matters for
|
||||
# the bridge-side translation.
|
||||
set -euo pipefail
|
||||
|
||||
# Drain any stdin the parent writes so it does not see EPIPE.
|
||||
( cat > /dev/null ) &
|
||||
DRAIN_PID=$!
|
||||
trap 'kill ${DRAIN_PID} 2>/dev/null || true' EXIT
|
||||
|
||||
cat <<'EOF'
|
||||
{"type":"system","subtype":"init","session_id":"fake-session","model":"sonnet","cwd":"/tmp","tools":[]}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_fake","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"output_tokens":0}}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", world!"}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":0}}
|
||||
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":4}}}
|
||||
{"type":"stream_event","event":{"type":"message_stop"}}
|
||||
{"type":"result","subtype":"success","is_error":false,"duration_ms":12,"num_turns":1,"result":"Hello, world!","total_cost_usd":0.0001,"usage":{"input_tokens":3,"output_tokens":4},"session_id":"fake-session"}
|
||||
EOF
|
||||
Loading…
Add table
Add a link
Reference in a new issue