1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition, StartupExamples,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::anthropic::actions::{action, parameter};
8use crate::state::app_state::AppState;
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Map, Value};
11use std::sync::LazyLock;
12
13pub const API_VERSION: &str = "2023-06-01";
15pub const DEFAULT_MAX_TOKENS: u64 = 1024;
17
18#[derive(Default)]
19pub struct AnthropicClientProtocol;
20impl AnthropicClientProtocol {
21 pub fn new() -> Self {
22 Self
23 }
24}
25
26fn message_parameters(with_max_tokens: bool) -> Vec<crate::llm::actions::Parameter> {
27 let mut p = vec![
28 parameter("prompt", "string", "Shorthand for one user message with this text; give prompt or messages", false),
29 parameter("messages", "array", "The conversation: each {role: user|assistant, content: string or [blocks]} (tool_result blocks answer tool_use)", false),
30 parameter("system", "string", "System prompt: instructions the model follows for the whole conversation", false),
31 parameter("model", "string", "Model id; defaults to the client's model startup parameter", false),
32 parameter("tools", "array", "Tools to offer: each {name, description, input_schema}", false),
33 parameter("tool_choice", "object", "{type: auto|any|none} or {type: tool, name}", false),
34 ];
35 if with_max_tokens {
36 p.extend([
37 parameter(
38 "max_tokens",
39 "number",
40 "Most output tokens (default 1024)",
41 false,
42 ),
43 parameter("temperature", "number", "Sampling temperature, 0..1", false),
44 parameter(
45 "stop_sequences",
46 "array",
47 "Strings that end generation",
48 false,
49 ),
50 parameter(
51 "stream",
52 "boolean",
53 "Ask for server-sent events; the stream is reassembled into one message either way",
54 false,
55 ),
56 ]);
57 }
58 p
59}
60
61pub fn actions() -> Vec<ActionDefinition> {
62 vec![
63 action(
64 "anthropic_create_message",
65 "POST /v1/messages: ask the server for the assistant's next message; its text, tool calls, stop reason and usage come back.",
66 message_parameters(true),
67 json!({"type":"anthropic_create_message","prompt":"Say hello in five words","max_tokens":64}),
68 "-> Anthropic messages {preview(prompt,60)}",
69 ),
70 action(
71 "anthropic_count_tokens",
72 "POST /v1/messages/count_tokens: how many input tokens the server counts for these messages.",
73 message_parameters(false),
74 json!({"type":"anthropic_count_tokens","prompt":"How many tokens is this?"}),
75 "-> Anthropic count_tokens",
76 ),
77 action(
78 "anthropic_list_models",
79 "GET /v1/models: the models the server offers.",
80 vec![],
81 json!({"type":"anthropic_list_models"}),
82 "-> Anthropic models",
83 ),
84 action(
85 "anthropic_get_model",
86 "GET /v1/models/{model_id}: one model, or not_found_error.",
87 vec![parameter("model_id", "string", "The model id to look up", true)],
88 json!({"type":"anthropic_get_model","model_id":"claude-netget-1"}),
89 "-> Anthropic model {model_id}",
90 ),
91 action(
92 "disconnect",
93 "Stop this client; no further requests are sent.",
94 vec![],
95 json!({"type":"disconnect"}),
96 "-> Anthropic client stop",
97 ),
98 ]
99}
100
101pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
102 EventType::new(
103 "anthropic_connected",
104 "The client is ready to send requests to the API.",
105 json!({"type":"anthropic_create_message","prompt":"Hello"}),
106 )
107 .with_parameters(vec![parameter(
108 "remote_addr",
109 "string",
110 "The API's origin",
111 true,
112 )])
113 .with_actions(actions())
114});
115
116pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
117 EventType::new(
118 "anthropic_response",
119 "The API answered a request.",
120 json!({"type":"anthropic_create_message","messages":[{"role":"user","content":"Thanks"}]}),
121 )
122 .with_parameters(vec![
123 parameter("operation", "string", "The action that was sent", true),
124 parameter(
125 "status",
126 "number",
127 "HTTP status code of the answer: 200 on success, else see error",
128 true,
129 ),
130 parameter(
131 "message",
132 "object",
133 "For a message: {id, model, text, content, stop_reason, stop_sequence, usage}",
134 false,
135 ),
136 parameter(
137 "streamed",
138 "object",
139 "For a streamed message: how many of each event type arrived",
140 false,
141 ),
142 parameter("input_tokens", "number", "For count_tokens", false),
143 parameter(
144 "models",
145 "array",
146 "For the models list (ids), or the one model looked up",
147 false,
148 ),
149 parameter(
150 "error",
151 "object",
152 "When refused: {type, message} from Anthropic's error envelope",
153 false,
154 ),
155 ])
156 .with_actions(actions())
157});
158
159impl Protocol for AnthropicClientProtocol {
160 fn protocol_name(&self) -> &'static str {
161 "Anthropic"
162 }
163 fn stack_name(&self) -> &'static str {
164 "ETH>IP>TCP>HTTP>Anthropic"
165 }
166 fn keywords(&self) -> Vec<&'static str> {
167 vec!["anthropic", "claude", "messages api"]
168 }
169 fn description(&self) -> &'static str {
170 "Anthropic Messages API client: messages (plain or streamed), count_tokens and models"
171 }
172 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
173 actions()
174 }
175 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
176 vec![]
177 }
178 fn get_event_types(&self) -> Vec<EventType> {
179 vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
180 }
181 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
182 vec![
183 ParameterDefinition {
184 name: "api_key".into(),
185 type_hint: "string".into(),
186 description: "Sent as x-api-key on every request; never shown in events".into(),
187 required: false,
188 example: json!("sk-ant-netget-test"),
189 default: None,
190 },
191 ParameterDefinition {
192 name: "anthropic_version".into(),
193 type_hint: "string".into(),
194 description: "The anthropic-version header".into(),
195 required: false,
196 example: json!("2023-06-01"),
197 default: Some(json!(API_VERSION)),
198 },
199 ParameterDefinition {
200 name: "model".into(),
201 type_hint: "string".into(),
202 description: "Model id used when an action names none".into(),
203 required: false,
204 example: json!("claude-sonnet-4-5"),
205 default: None,
206 },
207 ]
208 }
209 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
210 use crate::protocol::metadata::*;
211 ProtocolMetadataV2::builder()
212 .state(DevelopmentState::Experimental)
213 .privilege_requirement(PrivilegeRequirement::None)
214 .implementation("HTTP/1.1 via hyper, one connection per request: /v1/messages (JSON or server-sent events, reassembled into one message), /v1/messages/count_tokens, /v1/models, with x-api-key and anthropic-version")
215 .llm_control("What to ask and with which tools, and what to do with each answer — including answering a tool_use with a tool_result")
216 .e2e_testing("tests/client/anthropic: NetGet's own server; llama.cpp's llama-server (its Anthropic-compatible /v1/messages, with a 260K-parameter test model) as an independent server")
217 .notes("Plain HTTP only (no TLS), so it reaches a local or proxied server, not api.anthropic.com directly. Responses are capped at 8 MiB and 120 s. Image and document blocks pass through as given. A handler chain stops after 8 follow-ups.")
218 .max_inbound_bytes(crate::server::anthropic::wire::MAX_BODY)
219 .build()
220 }
221 fn example_prompt(&self) -> &'static str {
222 "Ask the Anthropic-compatible API at 127.0.0.1:8000 for a haiku, streamed"
223 }
224 fn get_startup_examples(&self) -> StartupExamples {
225 let llm = json!({"type":"open_client","protocol":"anthropic","remote_addr":"127.0.0.1:8000","instruction":"Ask for a haiku about networks"});
226 let mut static_example = llm.clone();
227 static_example["event_handlers"] = json!([{"event_pattern":"anthropic_connected","handler":{"type":"static","actions":[{"type":"anthropic_create_message","model":"claude-netget-1","prompt":"Write a haiku about packets","stream":true}]}}]);
228 let mut scripted = llm.clone();
229 scripted["event_handlers"] = json!([{"event_pattern":"anthropic_response","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[]}))"}}]);
230 StartupExamples::new(llm, scripted, static_example)
231 }
232 fn group_name(&self) -> &'static str {
233 "AI & API"
234 }
235}
236
237#[derive(Clone, Default)]
239pub struct Defaults {
240 pub api_key: Option<String>,
241 pub version: String,
242 pub model: Option<String>,
243}
244
245fn messages_of(v: &Value) -> Result<Value> {
246 match (&v["messages"], v["prompt"].as_str()) {
247 (Value::Array(m), None) => {
248 ensure!(!m.is_empty(), "messages must not be empty");
249 ensure!(m.len() <= 1000, "at most 1000 messages");
250 for (i, msg) in m.iter().enumerate() {
251 let role = msg["role"].as_str().unwrap_or_default();
252 ensure!(
253 role == "user" || role == "assistant",
254 "messages.{i}.role must be user or assistant"
255 );
256 ensure!(
257 msg["content"].is_string() || msg["content"].is_array(),
258 "messages.{i}.content must be text or blocks"
259 );
260 }
261 Ok(Value::Array(m.clone()))
262 }
263 (Value::Null, Some(p)) => Ok(json!([{"role": "user", "content": p}])),
264 (Value::Null, None) => bail!("give prompt or messages"),
265 _ => bail!("give prompt or messages, not both"),
266 }
267}
268
269pub fn request(v: &Value, d: &Defaults) -> Result<(&'static str, String, Option<Value>)> {
271 let kind = v["type"].as_str().unwrap_or_default();
272 let model = || -> Result<String> {
273 v["model"]
274 .as_str()
275 .map(str::to_string)
276 .or_else(|| d.model.clone())
277 .filter(|m| !m.is_empty())
278 .context("model required: name one in the action or the model startup parameter")
279 };
280 Ok(match kind {
281 "anthropic_create_message" | "anthropic_count_tokens" => {
282 let mut body = Map::new();
283 body.insert("model".into(), json!(model()?));
284 body.insert("messages".into(), messages_of(v)?);
285 if let Some(s) = v["system"].as_str() {
286 body.insert("system".into(), json!(s));
287 }
288 if !v["tools"].is_null() {
289 let tools = v["tools"].as_array().context("tools must be an array")?;
290 ensure!(tools.len() <= 256, "at most 256 tools");
291 for t in tools {
292 ensure!(t["name"].is_string(), "each tool needs a name");
293 }
294 body.insert("tools".into(), v["tools"].clone());
295 }
296 if !v["tool_choice"].is_null() {
297 ensure!(
298 v["tool_choice"].is_object(),
299 "tool_choice must be an object"
300 );
301 body.insert("tool_choice".into(), v["tool_choice"].clone());
302 }
303 if kind == "anthropic_count_tokens" {
304 return Ok((
305 "POST",
306 "/v1/messages/count_tokens".into(),
307 Some(Value::Object(body)),
308 ));
309 }
310 let max = match &v["max_tokens"] {
311 Value::Null => DEFAULT_MAX_TOKENS,
312 m => m
313 .as_u64()
314 .filter(|n| *n >= 1)
315 .context("max_tokens must be a positive integer")?,
316 };
317 body.insert("max_tokens".into(), json!(max));
318 if !v["temperature"].is_null() {
319 let t = v["temperature"]
320 .as_f64()
321 .context("temperature must be a number")?;
322 ensure!(
323 (0.0..=1.0).contains(&t),
324 "temperature must be between 0 and 1"
325 );
326 body.insert("temperature".into(), json!(t));
327 }
328 if !v["stop_sequences"].is_null() {
329 let s = v["stop_sequences"]
330 .as_array()
331 .context("stop_sequences must be an array")?;
332 ensure!(
333 s.iter().all(Value::is_string),
334 "stop_sequences must be strings"
335 );
336 body.insert("stop_sequences".into(), v["stop_sequences"].clone());
337 }
338 if v["stream"] == true {
339 body.insert("stream".into(), json!(true));
340 }
341 ("POST", "/v1/messages".into(), Some(Value::Object(body)))
342 }
343 "anthropic_list_models" => ("GET", "/v1/models".into(), None),
344 "anthropic_get_model" => {
345 let id = v["model_id"].as_str().context("model_id required")?;
346 ensure!(
347 !id.is_empty()
348 && id.len() <= 256
349 && id
350 .bytes()
351 .all(|b| b.is_ascii_alphanumeric() || b"-_.:@".contains(&b)),
352 "model_id must be 1..=256 characters of [A-Za-z0-9-_.:@]"
353 );
354 ("GET", format!("/v1/models/{id}"), None)
355 }
356 other => bail!("Unknown Anthropic client action {other}"),
357 })
358}
359
360impl Client for AnthropicClientProtocol {
361 fn connect(
362 &self,
363 ctx: ConnectContext,
364 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
365 {
366 Box::pin(super::connect(ctx))
367 }
368 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
369 let name = v["type"].as_str().unwrap_or_default().to_string();
370 if name == "disconnect" {
371 return Ok(ClientActionResult::Disconnect);
372 }
373 let d = Defaults {
375 model: Some("m".into()),
376 ..Defaults::default()
377 };
378 request(&v, &d)?;
379 Ok(ClientActionResult::Custom { name, data: v })
380 }
381}