asimov_anthropic_module/
lib.rs

1// This is free and unencumbered software released into the public domain.
2
3#![no_std]
4#![forbid(unsafe_code)]
5
6use asimov_module::{
7    prelude::*,
8    secrecy::{ExposeSecret, SecretString},
9    tracing,
10};
11use core::error::Error;
12use serde_json::{Value, json};
13
14#[derive(Clone, Debug, bon::Builder)]
15#[builder(on(String, into))]
16pub struct Options {
17    #[builder(default = "https://api.anthropic.com")]
18    pub endpoint: String,
19
20    #[builder(default = "claude-opus-4-1-20250805")]
21    pub model: String,
22
23    #[builder(default = 1024)]
24    pub max_tokens: usize,
25
26    #[builder(into)]
27    pub api_key: SecretString,
28}
29
30pub fn chat(input: impl AsRef<str>, options: &Options) -> Result<Vec<String>, Box<dyn Error>> {
31    let req = json!({
32        "model": options.model,
33        "max_tokens": options.max_tokens,
34        "messages": [
35            {"role": "user", "content": input.as_ref() }
36        ]
37    });
38
39    let mut resp = ureq::Agent::config_builder()
40        .http_status_as_error(false)
41        .user_agent("asimov-anthropic-module")
42        .build()
43        .new_agent()
44        .post(options.endpoint.clone() + "/v1/messages")
45        .header("x-api-key", options.api_key.expose_secret())
46        .header("anthropic-version", "2023-06-01")
47        .header("content-type", "application/json")
48        .send_json(&req)
49        .inspect_err(|e| tracing::error!("HTTP request failed: {e}"))?;
50    tracing::debug!(response = ?resp);
51
52    let status = resp.status();
53    tracing::debug!(status = status.to_string());
54
55    let resp: Value = resp
56        .body_mut()
57        .read_json()
58        .inspect_err(|e| tracing::error!("unable to read HTTP response body: {e}"))?;
59    tracing::debug!(body = ?resp);
60
61    if !status.is_success() {
62        tracing::error!("Received an error response: {status}");
63
64        // {
65        //   "type": "error",
66        //   "error": {
67        //     "type": "not_found_error",
68        //     "message": "The requested resource could not be found."
69        //   },
70        //   "request_id": "req_..."
71        // }
72        if let Some(message) = resp["error"]["message"].as_str() {
73            return Err(message.into());
74        }
75    }
76
77    // {
78    //   "id": "msg_...",
79    //   "type": "message",
80    //   "role": "assistant",
81    //   "model": "claude-opus-4-1-20250805",
82    //   "content": [
83    //     {
84    //       "type": "text",
85    //       "text": "..."
86    //     }
87    //   ],
88    //   "stop_reason": "max_tokens",
89    //   "stop_sequence": null,
90    //   "usage": {
91    //     "input_tokens": 12,
92    //     "cache_creation_input_tokens": 0,
93    //     "cache_read_input_tokens": 0,
94    //     "cache_creation": {
95    //       "ephemeral_5m_input_tokens": 0,
96    //       "ephemeral_1h_input_tokens": 0
97    //     },
98    //     "output_tokens": 32,
99    //     "service_tier": "standard"
100    //   }
101    // }
102
103    if let Some(stop_reason) = resp["stop_reason"].as_str() {
104        tracing::debug!(stop_reason);
105    }
106
107    let responses = resp["content"]
108        .as_array()
109        .into_iter()
110        .flatten()
111        .filter(|c| c["type"].as_str().is_some_and(|t| t == "text"))
112        .flat_map(|c| c["text"].as_str().map(ToString::to_string))
113        .collect();
114
115    Ok(responses)
116}