netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
use crate::llm::actions::{
    protocol_trait::{ActionResult, Protocol, Server},
    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
};
use crate::protocol::log_template::LogTemplate;
use crate::protocol::{EventType, SpawnContext};
use crate::state::app_state::AppState;
use anyhow::{bail, ensure, Context, Result};
use serde_json::{json, Value};
use std::sync::LazyLock;

use super::wire;

#[derive(Default)]
pub struct AnthropicProtocol;
impl AnthropicProtocol {
    pub fn new() -> Self {
        Self
    }
}

pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
    Parameter {
        name: name.into(),
        type_hint: kind.into(),
        description: description.into(),
        required,
    }
}

pub fn action(
    name: &str,
    description: &str,
    parameters: Vec<Parameter>,
    example: Value,
    log: &str,
) -> ActionDefinition {
    ActionDefinition {
        name: name.into(),
        description: description.into(),
        parameters,
        example,
        log_template: Some(LogTemplate::new().with_info(log)),
    }
}

fn reply_action() -> ActionDefinition {
    action(
        "anthropic_reply",
        "Answer the request with the assistant's message: text, tool calls, or both. NetGet adds the id, model and usage, and streams it when the caller asked to.",
        vec![
            parameter("text", "string", "The assistant's text (one text block); use content for several blocks or tool calls", false),
            parameter("content", "array", "Content blocks in order: {type: text, text} or {type: tool_use, name, input (object), id?}", false),
            parameter("stop_reason", "string", "end_turn, max_tokens, stop_sequence, tool_use, pause_turn or refusal; defaults to tool_use when a tool is called, else end_turn", false),
            parameter("stop_sequence", "string", "The stop sequence that ended the text, with stop_reason stop_sequence", false),
            parameter("input_tokens", "number", "Reported input tokens; estimated as characters/4 when omitted", false),
            parameter("output_tokens", "number", "Reported output tokens; estimated as characters/4 when omitted", false),
        ],
        json!({"type":"anthropic_reply","text":"Hello! How can I help?"}),
        "-> Anthropic reply {preview(text,80)}",
    )
}

fn error_action() -> ActionDefinition {
    action(
        "anthropic_error",
        "Refuse the request with one of Anthropic's error types; the HTTP status follows from the type.",
        vec![
            parameter("error_type", "string", "invalid_request_error (400), authentication_error (401), permission_error (403), not_found_error (404), request_too_large (413), rate_limit_error (429), api_error (500) or overloaded_error (529)", true),
            parameter("message", "string", "The error message the caller will read", true),
        ],
        json!({"type":"anthropic_error","error_type":"rate_limit_error","message":"Number of requests has exceeded your rate limit"}),
        "-> Anthropic error {error_type}",
    )
}

pub fn sync_actions() -> Vec<ActionDefinition> {
    vec![reply_action(), error_action()]
}

pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "anthropic_message",
        "A client asked POST /v1/messages for the assistant's next message.",
        json!({"type":"anthropic_reply","text":"Hello! How can I help?"}),
    )
    .with_parameters(vec![
        parameter("model", "string", "The model the caller named", true),
        parameter("max_tokens", "number", "The most output tokens the caller allows", true),
        parameter("system", "string", "The system prompt, or null", false),
        parameter("messages", "array", "The conversation: each {role, content: [blocks]}; text, tool_use, tool_result, and image/document described by type and size only", true),
        parameter("tools", "array", "Tools the caller offers: each {name, description, input_schema}", false),
        parameter("tool_choice", "object", "auto, any, tool {name} or none, as the caller sent it", false),
        parameter("stop_sequences", "array", "Strings that end the text when generated", false),
        parameter("stream", "boolean", "Whether the caller asked for server-sent events (NetGet streams the reply either way)", true),
        parameter("api_key_present", "boolean", "Whether the request carried x-api-key or a bearer token (its value is never shown)", true),
    ])
    .with_actions(sync_actions())
});

impl Protocol for AnthropicProtocol {
    fn protocol_name(&self) -> &'static str {
        "Anthropic"
    }
    fn stack_name(&self) -> &'static str {
        "ETH>IP>TCP>HTTP>Anthropic"
    }
    fn keywords(&self) -> Vec<&'static str> {
        vec!["anthropic", "claude", "messages api", "llm api"]
    }
    fn description(&self) -> &'static str {
        "Anthropic Messages API server: /v1/messages (plain and streaming), count_tokens and models, answered by the handler"
    }
    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
        vec![]
    }
    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        sync_actions()
    }
    fn get_event_types(&self) -> Vec<EventType> {
        vec![MESSAGE_EVENT.clone()]
    }
    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
        vec![
            ParameterDefinition {
                name: "api_key".into(),
                type_hint: "string".into(),
                description: "When set, every request must carry this key in x-api-key (or Authorization: Bearer); any other gets 401 authentication_error without asking the handler".into(),
                required: false,
                example: json!("sk-ant-netget-test"),
                default: None,
            },
            ParameterDefinition {
                name: "models".into(),
                type_hint: "array".into(),
                description: "Model ids /v1/models lists and /v1/models/{id} finds".into(),
                required: false,
                example: json!(["claude-netget-1", "claude-netget-fast"]),
                default: Some(json!([wire::DEFAULT_MODEL])),
            },
        ]
    }
    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::*;
        ProtocolMetadataV2::builder()
            .state(DevelopmentState::Experimental)
            .privilege_requirement(PrivilegeRequirement::None)
            .implementation("HTTP/1.1 via hyper: POST /v1/messages (JSON, or server-sent events with message_start, ping, content_block_start/delta/stop with text_delta and input_json_delta, message_delta, message_stop), POST /v1/messages/count_tokens, GET /v1/models and /v1/models/{id}; Anthropic's request validation and error envelope, msg_/toolu_/req_ ids")
            .llm_control("Every assistant message — text, tool calls and stop reason — or a refusal with one of Anthropic's error types")
            .e2e_testing("tests/server/anthropic: the official anthropic Python SDK and @anthropic-ai/sdk (TypeScript) as independent clients, plain and streaming, with tool use; raw HTTP for validation, bounds and failure")
            .notes("Token counts are estimates (characters/4) unless the handler gives them, count_tokens included. The reply is computed before anything is sent, so a stream is the whole message cut into deltas, and an error arrives as an HTTP status rather than mid-stream. No batches, files, prompt caching or extended thinking output. Image and document blocks are described to the handler by type and size, never their bytes. A handler failure answers 529 overloaded_error or 500 api_error, never a fabricated message.")
            .request_only("Every response answers an HTTP request")
            .answers_on_failure()
            .max_inbound_bytes(wire::MAX_BODY)
            .build()
    }
    fn example_prompt(&self) -> &'static str {
        "Anthropic-compatible API on port 8000 that answers as a terse pirate"
    }
    fn get_startup_examples(&self) -> StartupExamples {
        let llm = json!({"type":"open_server","base_stack":"anthropic","port":8000,"instruction":"Answer every message briefly, as a pirate"});
        let mut scripted = llm.clone();
        scripted["event_handlers"] = json!([{"event_pattern":"anthropic_message","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nlast=e['messages'][-1]['content'][-1].get('text','')\nprint(json.dumps({'actions':[{'type':'anthropic_reply','text':'You said: '+last}]}))"}}]);
        let mut static_example = llm.clone();
        static_example["event_handlers"] = json!([{"event_pattern":"anthropic_message","handler":{"type":"static","actions":[{"type":"anthropic_reply","text":"Arr."}]}}]);
        StartupExamples::new(llm, scripted, static_example)
    }
    fn group_name(&self) -> &'static str {
        "AI & API"
    }
}

/// Check an `anthropic_error`.
pub fn check_error(v: &Value) -> Result<()> {
    let t = v["error_type"].as_str().context("error_type required")?;
    ensure!(
        wire::status_of(t).is_some(),
        "error_type must be one of Anthropic's error types"
    );
    let m = v["message"].as_str().context("message required")?;
    ensure!(m.len() <= 4096, "message at most 4096 bytes");
    Ok(())
}

impl Server for AnthropicProtocol {
    fn spawn(
        &self,
        ctx: SpawnContext,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
    {
        Box::pin(super::spawn(ctx))
    }
    fn execute_action(&self, v: Value) -> Result<ActionResult> {
        let name = v["type"].as_str().unwrap_or_default().to_string();
        match name.as_str() {
            "anthropic_reply" => {
                let content = wire::reply_content(&v)?;
                wire::stop_reason(&v, &content)?;
                for k in ["input_tokens", "output_tokens"] {
                    ensure!(
                        v[k].is_null() || v[k].as_u64().is_some(),
                        "{k} must be a non-negative integer"
                    );
                }
            }
            "anthropic_error" => check_error(&v)?,
            _ => bail!("Unknown Anthropic server action"),
        }
        Ok(ActionResult::Custom { name, data: v })
    }
}