Skip to main content

sim_codec_chat/providers/anthropic/
encode.rs

1use serde_json::{Map, Value, json};
2use sim_kernel::{CodecId, Error, Expr, Result};
3
4use crate::output_grammar::reject_output_grammar;
5use crate::{is_model_request_expr, validate_chat_transcript};
6
7use super::AnthropicRequestOptions;
8use super::common::{
9    codec_error, codec_eval_to_codec, expr_entries, flatten_expr, optional_expr, optional_string,
10    required_list, required_string, required_symbol, sim_expr_to_json,
11};
12use crate::providers::model_params::attach_bridge_model_params;
13
14const RESERVED_MODEL_PARAM_FIELDS: &[&str] = &[
15    "model",
16    "max_tokens",
17    "stream",
18    "system",
19    "messages",
20    "tools",
21    "tool_choice",
22];
23
24/// Encodes a model-request transcript into Anthropic Messages JSON.
25pub fn encode_anthropic_request(expr: &Expr, options: &AnthropicRequestOptions) -> Result<Vec<u8>> {
26    if !is_model_request_expr(expr) {
27        return Err(Error::Eval(
28            "anthropic codec expects a model-request transcript".to_owned(),
29        ));
30    }
31    validate_chat_transcript(expr)?;
32    let entries = expr_entries(expr, "anthropic request transcript")?;
33    reject_output_grammar(entries, "anthropic")?;
34    let (messages, system) = transcript_messages(entries)?;
35    let mut payload = Map::new();
36    payload.insert("model".to_owned(), Value::String(options.model.clone()));
37    payload.insert("max_tokens".to_owned(), json!(options.max_tokens));
38    payload.insert("stream".to_owned(), Value::Bool(options.stream));
39    if let Some(system) = system {
40        payload.insert("system".to_owned(), Value::String(system));
41    }
42    payload.insert("messages".to_owned(), Value::Array(messages));
43    if options.tools {
44        payload.insert("tools".to_owned(), Value::Array(tool_schemas(entries)?));
45    }
46    if let Some(tool_choice) = optional_expr(entries, "tool-choice") {
47        payload.insert("tool_choice".to_owned(), sim_expr_to_json(tool_choice));
48    }
49    attach_bridge_model_params(
50        entries,
51        &mut payload,
52        RESERVED_MODEL_PARAM_FIELDS,
53        "anthropic",
54    )?;
55    serde_json::to_vec(&Value::Object(payload))
56        .map_err(|err| Error::Eval(format!("anthropic codec failed to encode request: {err}")))
57}
58
59/// Encodes a model-response transcript into Anthropic Messages JSON.
60pub fn encode_anthropic_response(expr: &Expr) -> Result<Vec<u8>> {
61    let value = response_json(expr)?;
62    serde_json::to_vec(&value)
63        .map_err(|err| Error::Eval(format!("anthropic codec failed to encode response: {err}")))
64}
65
66pub(super) fn encode_anthropic_response_for_codec(codec: CodecId, expr: &Expr) -> Result<String> {
67    validate_chat_transcript(expr).map_err(|err| codec_eval_to_codec(codec, err))?;
68    let value = response_json(expr).map_err(|err| codec_eval_to_codec(codec, err))?;
69    serde_json::to_string(&value).map_err(|err| codec_error(codec, err))
70}
71
72fn transcript_messages(entries: &[(Expr, Expr)]) -> Result<(Vec<Value>, Option<String>)> {
73    let mut messages = Vec::new();
74    let mut system = Vec::new();
75    for message in required_list(entries, "messages")? {
76        let Some(rendered) = message_to_json(message, &mut system)? else {
77            continue;
78        };
79        messages.push(rendered);
80    }
81    messages.push(json!({
82        "role": "user",
83        "content": [{
84            "type": "text",
85            "text": flatten_expr(
86                optional_expr(entries, "task")
87                    .ok_or_else(|| Error::Eval("anthropic request missing task field".to_owned()))?
88            ),
89        }],
90    }));
91    let system = (!system.is_empty()).then(|| system.join("\n\n"));
92    Ok((messages, system))
93}
94
95fn message_to_json(expr: &Expr, system: &mut Vec<String>) -> Result<Option<Value>> {
96    let entries = expr_entries(expr, "anthropic message")?;
97    let role = required_symbol(entries, "role")?;
98    if role.name.as_ref() == "system" && role.namespace.is_none() {
99        system.push(system_message_text(entries)?);
100        return Ok(None);
101    }
102    Ok(Some(json!({
103        "role": role.name.as_ref(),
104        "content": required_list(entries, "content")?
105            .iter()
106            .map(content_part_to_json)
107            .collect::<Result<Vec<_>>>()?,
108    })))
109}
110
111fn system_message_text(entries: &[(Expr, Expr)]) -> Result<String> {
112    required_list(entries, "content")?
113        .iter()
114        .map(content_part_to_text)
115        .collect::<Result<Vec<_>>>()
116        .map(|parts| parts.join("\n"))
117}
118
119fn content_part_to_text(expr: &Expr) -> Result<String> {
120    let entries = expr_entries(expr, "anthropic content part")?;
121    match required_symbol(entries, "type")?.name.as_ref() {
122        "text" => Ok(required_string(entries, "text")?.to_owned()),
123        other => Err(Error::Eval(format!(
124            "anthropic system message does not support content part type {other}"
125        ))),
126    }
127}
128
129fn content_part_to_json(expr: &Expr) -> Result<Value> {
130    let entries = expr_entries(expr, "anthropic content part")?;
131    match required_symbol(entries, "type")?.name.as_ref() {
132        "text" => Ok(json!({
133            "type": "text",
134            "text": required_string(entries, "text")?,
135        })),
136        "tool-call" => Ok(json!({
137            "type": "tool_use",
138            "id": optional_string(entries, "id")?.unwrap_or_else(|| "toolu_sim".to_owned()),
139            "name": required_string(entries, "name")?,
140            "input": optional_expr(entries, "arguments")
141                .or_else(|| optional_expr(entries, "input"))
142                .map(sim_expr_to_json)
143                .unwrap_or_else(|| Value::Object(Map::new())),
144        })),
145        "tool-result" => {
146            let mut object = Map::new();
147            object.insert("type".to_owned(), Value::String("tool_result".to_owned()));
148            object.insert(
149                "tool_use_id".to_owned(),
150                Value::String(
151                    optional_string_any(entries, &["tool-call-id", "id"])?
152                        .unwrap_or_else(|| "toolu_sim".to_owned()),
153                ),
154            );
155            object.insert(
156                "content".to_owned(),
157                Value::String(
158                    optional_expr(entries, "output")
159                        .map(flatten_expr)
160                        .unwrap_or_else(|| "".to_owned()),
161                ),
162            );
163            if let Some(Expr::Symbol(status)) = optional_expr(entries, "status")
164                && status.name.as_ref() != "ok"
165            {
166                object.insert("is_error".to_owned(), Value::Bool(true));
167            }
168            Ok(Value::Object(object))
169        }
170        other => Err(Error::Eval(format!(
171            "anthropic codec does not support content part type {other}"
172        ))),
173    }
174}
175
176fn tool_schemas(entries: &[(Expr, Expr)]) -> Result<Vec<Value>> {
177    let Some(tools) = optional_expr(entries, "tools") else {
178        return Ok(Vec::new());
179    };
180    let Expr::List(tools) = tools else {
181        return Err(Error::Eval(
182            "anthropic request tools field must be a list".to_owned(),
183        ));
184    };
185    tools.iter().map(tool_schema).collect()
186}
187
188fn tool_schema(expr: &Expr) -> Result<Value> {
189    let entries = expr_entries(expr, "anthropic tool schema")?;
190    let name = optional_string_any(entries, &["name", "openai-name", "symbol"])?
191        .ok_or_else(|| Error::Eval("anthropic tool schema missing name".to_owned()))?;
192    let description = optional_string(entries, "description")?;
193    let input_schema = optional_expr(entries, "input-schema")
194        .or_else(|| optional_expr(entries, "input_schema"))
195        .or_else(|| optional_expr(entries, "parameters"))
196        .map(sim_expr_to_json)
197        .unwrap_or_else(|| json!({"type":"object","properties":{}}));
198    let mut object = Map::new();
199    object.insert("name".to_owned(), Value::String(name));
200    if let Some(description) = description {
201        object.insert("description".to_owned(), Value::String(description));
202    }
203    object.insert("input_schema".to_owned(), input_schema);
204    Ok(Value::Object(object))
205}
206
207fn optional_string_any(entries: &[(Expr, Expr)], keys: &[&str]) -> Result<Option<String>> {
208    for key in keys {
209        if let Some(value) = optional_string(entries, key)? {
210            return Ok(Some(value));
211        }
212    }
213    Ok(None)
214}
215
216fn response_json(expr: &Expr) -> Result<Value> {
217    let entries = expr_entries(expr, "anthropic response transcript")?;
218    let model = required_string(entries, "model")?;
219    let stop_reason = required_symbol(entries, "stop-reason")?;
220    Ok(json!({
221        "id": "msg_sim",
222        "type": "message",
223        "role": "assistant",
224        "model": model,
225        "content": required_list(entries, "content")?
226            .iter()
227            .map(content_part_to_json)
228            .collect::<Result<Vec<_>>>()?,
229        "stop_reason": stop_reason.name.as_ref().replace('-', "_"),
230        "stop_sequence": Value::Null,
231        "usage": response_usage(entries)?,
232    }))
233}
234
235fn response_usage(entries: &[(Expr, Expr)]) -> Result<Value> {
236    let Some(usage) = optional_expr(entries, "usage") else {
237        return Ok(json!({"input_tokens":0,"output_tokens":0}));
238    };
239    let fields = expr_entries(usage, "anthropic usage field")?;
240    Ok(json!({
241        "input_tokens": optional_u64_field(fields, "input-tokens")?.unwrap_or(0),
242        "output_tokens": optional_u64_field(fields, "output-tokens")?.unwrap_or(0),
243    }))
244}
245
246fn optional_u64_field(entries: &[(Expr, Expr)], key: &str) -> Result<Option<u64>> {
247    let Some(value) = optional_expr(entries, key) else {
248        return Ok(None);
249    };
250    match value {
251        Expr::Number(number) => number
252            .canonical
253            .parse::<u64>()
254            .map(Some)
255            .map_err(|err| Error::Eval(format!("anthropic usage field {key} invalid: {err}"))),
256        Expr::String(text) => text
257            .parse::<u64>()
258            .map(Some)
259            .map_err(|err| Error::Eval(format!("anthropic usage field {key} invalid: {err}"))),
260        other => Err(Error::Eval(format!(
261            "anthropic usage field {key} must be a number, found {other:?}"
262        ))),
263    }
264}