Skip to main content

netget/client/anthropic/
actions.rs

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
13/// The `anthropic-version` header sent unless the startup parameter names another.
14pub const API_VERSION: &str = "2023-06-01";
15/// max_tokens sent when an action names none.
16pub 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/// The client's defaults, from its startup parameters.
238#[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
269/// The request one action sends: method, path and JSON body.
270pub 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        // Checked against a placeholder model: the session checks again with its own defaults.
374        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}