mirror of
https://github.com/katanemo/plano.git
synced 2026-06-29 15:49:40 +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
114
crates/hermesllm/tests/claude_cli_fixtures.rs
Normal file
114
crates/hermesllm/tests/claude_cli_fixtures.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! End-to-end fixture tests for `apis::claude_cli`. Each NDJSON file under
|
||||
//! `tests/fixtures/claude_cli/` represents one canned subprocess output. We
|
||||
//! parse it line-by-line and feed it through the same translation entry points
|
||||
//! the brightstaff bridge uses at runtime.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use hermesllm::apis::anthropic::{
|
||||
MessagesContentBlock, MessagesContentDelta, MessagesStopReason, MessagesStreamEvent,
|
||||
};
|
||||
use hermesllm::apis::claude_cli::{
|
||||
cli_event_to_messages_stream_event, collect_to_messages_response, parse_ndjson_line,
|
||||
ClaudeCliEvent, ClaudeCliTranslationError,
|
||||
};
|
||||
|
||||
fn fixture_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join("claude_cli")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
fn load_events(name: &str) -> Vec<ClaudeCliEvent> {
|
||||
let body = fs::read_to_string(fixture_path(name))
|
||||
.unwrap_or_else(|e| panic!("read fixture {name}: {e}"));
|
||||
body.lines()
|
||||
.filter_map(|line| parse_ndjson_line(line).map(|r| r.unwrap_or_else(|e| panic!("{e}"))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_response_aggregates_into_messages_response() {
|
||||
let events = load_events("text_response.ndjson");
|
||||
let resp = collect_to_messages_response("claude-cli/sonnet", events.clone()).unwrap();
|
||||
assert_eq!(resp.id, "msg_01ABC");
|
||||
assert_eq!(resp.model, "claude-sonnet-4-6");
|
||||
assert_eq!(resp.usage.input_tokens, 12);
|
||||
assert_eq!(resp.usage.output_tokens, 4);
|
||||
assert!(matches!(resp.stop_reason, MessagesStopReason::EndTurn));
|
||||
match &resp.content[..] {
|
||||
[MessagesContentBlock::Text { text, .. }] => assert_eq!(text, "Hello, world!"),
|
||||
other => panic!("expected single Text, got {other:?}"),
|
||||
}
|
||||
|
||||
// Verify the streaming projection emits exactly the events the Anthropic
|
||||
// SSE wire protocol expects, in order.
|
||||
let stream: Vec<MessagesStreamEvent> = events
|
||||
.iter()
|
||||
.filter_map(cli_event_to_messages_stream_event)
|
||||
.collect();
|
||||
assert!(matches!(
|
||||
stream[0],
|
||||
MessagesStreamEvent::MessageStart { .. }
|
||||
));
|
||||
let final_event = stream.last().unwrap();
|
||||
assert!(matches!(final_event, MessagesStreamEvent::MessageStop));
|
||||
let text_deltas = stream
|
||||
.iter()
|
||||
.filter_map(|ev| match ev {
|
||||
MessagesStreamEvent::ContentBlockDelta {
|
||||
delta: MessagesContentDelta::TextDelta { text },
|
||||
..
|
||||
} => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
assert_eq!(text_deltas, "Hello, world!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_use_response_assembles_partial_json() {
|
||||
let events = load_events("tool_use_response.ndjson");
|
||||
let resp = collect_to_messages_response("sonnet", events).unwrap();
|
||||
assert!(matches!(resp.stop_reason, MessagesStopReason::ToolUse));
|
||||
match &resp.content[..] {
|
||||
[MessagesContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
}] => {
|
||||
assert_eq!(id, "toolu_W");
|
||||
assert_eq!(name, "get_weather");
|
||||
assert_eq!(input["city"], "Seattle");
|
||||
}
|
||||
other => panic!("expected single ToolUse block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_response_returns_cli_error() {
|
||||
let events = load_events("error_response.ndjson");
|
||||
let err = collect_to_messages_response("sonnet", events).unwrap_err();
|
||||
match err {
|
||||
ClaudeCliTranslationError::CliError { message } => {
|
||||
assert!(
|
||||
message.contains("529"),
|
||||
"expected 529 in error message, got: {message}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected CliError, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_then_success_is_treated_as_success() {
|
||||
let events = load_events("retry_then_success.ndjson");
|
||||
let resp = collect_to_messages_response("sonnet", events).unwrap();
|
||||
assert!(matches!(resp.stop_reason, MessagesStopReason::EndTurn));
|
||||
match &resp.content[..] {
|
||||
[MessagesContentBlock::Text { text, .. }] => assert_eq!(text, "ok"),
|
||||
other => panic!("expected Text block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
3
crates/hermesllm/tests/fixtures/claude_cli/error_response.ndjson
vendored
Normal file
3
crates/hermesllm/tests/fixtures/claude_cli/error_response.ndjson
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{"type":"system","subtype":"init","session_id":"err-1","model":"sonnet","cwd":"/tmp","tools":[]}
|
||||
{"type":"system","subtype":"api_retry","attempt":1,"reason":"529 overloaded"}
|
||||
{"type":"result","subtype":"error","is_error":true,"duration_ms":1200,"num_turns":0,"result":"Anthropic API returned 529 after 3 retries","total_cost_usd":0,"session_id":"err-1"}
|
||||
10
crates/hermesllm/tests/fixtures/claude_cli/retry_then_success.ndjson
vendored
Normal file
10
crates/hermesllm/tests/fixtures/claude_cli/retry_then_success.ndjson
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{"type":"system","subtype":"init","session_id":"retry-1","model":"sonnet","cwd":"/tmp","tools":[]}
|
||||
{"type":"system","subtype":"api_retry","attempt":1,"reason":"529 overloaded"}
|
||||
{"type":"system","subtype":"rate_limit_event","reset_at":"2026-05-04T18:30:00Z"}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_retry","type":"message","role":"assistant","content":[],"model":"sonnet","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":"ok"}}}
|
||||
{"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":1}}}
|
||||
{"type":"stream_event","event":{"type":"message_stop"}}
|
||||
{"type":"result","subtype":"success","is_error":false,"duration_ms":2100,"num_turns":1,"result":"ok","total_cost_usd":0.00009,"usage":{"input_tokens":3,"output_tokens":1},"session_id":"retry-1"}
|
||||
10
crates/hermesllm/tests/fixtures/claude_cli/text_response.ndjson
vendored
Normal file
10
crates/hermesllm/tests/fixtures/claude_cli/text_response.ndjson
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{"type":"system","subtype":"init","session_id":"a1b2c3","model":"claude-sonnet-4-6","cwd":"/tmp","tools":["Bash","Read"]}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_01ABC","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"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":"assistant","message":{"id":"msg_01ABC","type":"message","role":"assistant","model":"claude-sonnet-4-6","content":[{"type":"text","text":"Hello, world!"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":4}}}
|
||||
{"type":"result","subtype":"success","is_error":false,"duration_ms":521,"num_turns":1,"result":"Hello, world!","total_cost_usd":0.00012,"usage":{"input_tokens":12,"output_tokens":4},"session_id":"a1b2c3"}
|
||||
9
crates/hermesllm/tests/fixtures/claude_cli/tool_use_response.ndjson
vendored
Normal file
9
crates/hermesllm/tests/fixtures/claude_cli/tool_use_response.ndjson
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{"type":"system","subtype":"init","session_id":"tool-1","model":"sonnet","cwd":"/tmp","tools":[]}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_tool","type":"message","role":"assistant","content":[],"model":"sonnet","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":0}}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_W","name":"get_weather","input":{}}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\""}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"Seattle\"}"}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":0}}
|
||||
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":7}}}
|
||||
{"type":"stream_event","event":{"type":"message_stop"}}
|
||||
{"type":"result","subtype":"success","is_error":false,"duration_ms":701,"num_turns":1,"result":null,"total_cost_usd":0.00021,"usage":{"input_tokens":20,"output_tokens":7},"session_id":"tool-1"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue