use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CompletionRequest {
pub model: String,
pub system: Option<String>,
pub messages: Vec<Message>,
pub tools: Vec<ToolSpec>,
pub tool_choice: ToolChoice,
pub response_format: Option<JsonSchema>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub stop: Vec<String>,
pub web_search: bool,
pub cache: CacheHint,
}
impl CompletionRequest {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
Self {
model: model.into(),
system: None,
messages: Vec::new(),
tools: Vec::new(),
tool_choice: ToolChoice::Auto,
response_format: None,
max_tokens: None,
temperature: None,
stop: Vec::new(),
web_search: false,
cache: CacheHint::None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CacheHint {
#[default]
None,
StablePrefix {
key: Option<String>,
},
}
impl CacheHint {
#[must_use]
pub fn from_key(key: String) -> Self {
if key.is_empty() {
Self::None
} else {
Self::StablePrefix { key: Some(key) }
}
}
#[must_use]
pub fn key(&self) -> Option<&str> {
match self {
Self::StablePrefix { key: Some(key) } => Some(key),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: Vec<Content>,
}
impl Message {
#[must_use]
pub fn user(text: impl Into<String>) -> Self {
Self {
role: Role::User,
content: vec![Content::Text(text.into())],
}
}
#[must_use]
pub fn assistant(text: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: vec![Content::Text(text.into())],
}
}
#[must_use]
pub fn system(text: impl Into<String>) -> Self {
Self {
role: Role::System,
content: vec![Content::Text(text.into())],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Role {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Content {
Text(String),
ToolUse(ToolCall),
ToolResult(ToolResult),
Image(ImageRef),
}
impl Content {
#[must_use]
pub fn text(s: impl Into<String>) -> Self {
Self::Text(s.into())
}
#[must_use]
pub fn tool_use(
id: impl Into<String>,
name: impl Into<String>,
args_json: impl Into<String>,
) -> Self {
Self::ToolUse(ToolCall {
id: id.into(),
name: name.into(),
args_json: args_json.into(),
signature: None,
approval_turn_id: None,
})
}
#[must_use]
pub fn tool_use_signed(
id: impl Into<String>,
name: impl Into<String>,
args_json: impl Into<String>,
signature: Option<String>,
) -> Self {
Self::ToolUse(ToolCall {
id: id.into(),
name: name.into(),
args_json: args_json.into(),
signature,
approval_turn_id: None,
})
}
#[must_use]
pub fn tool_result(
tool_call_id: impl Into<String>,
result_json: impl Into<String>,
is_error: bool,
first_party: bool,
) -> Self {
Self::ToolResult(ToolResult {
tool_call_id: tool_call_id.into(),
result_json: result_json.into(),
is_error,
first_party,
})
}
#[must_use]
pub fn image(url: impl Into<String>, mime_type: Option<String>) -> Self {
Self::Image(ImageRef {
url: url.into(),
mime_type,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub args_json: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_turn_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub tool_call_id: String,
pub result_json: String,
pub is_error: bool,
pub first_party: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ImageRef {
pub url: String,
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub schema_json: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub needs_approval: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub read_only: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub destructive: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub open_world: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub cacheable_approval: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub interactive: bool,
}
fn canonical_json(value: &serde_json::Value, out: &mut String) {
match value {
serde_json::Value::Array(items) => {
out.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
out.push(',');
}
canonical_json(item, out);
}
out.push(']');
}
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push('{');
for (index, key) in keys.iter().enumerate() {
if index > 0 {
out.push(',');
}
out.push_str(&serde_json::to_string(key).unwrap_or_default());
out.push(':');
canonical_json(&map[*key], out);
}
out.push('}');
}
other => out.push_str(&other.to_string()),
}
}
impl ToolSpec {
#[must_use]
pub fn descriptor_hash(&self) -> String {
use sha2::{Digest as _, Sha256};
let mut schema = String::new();
canonical_json(&self.schema_json, &mut schema);
let mut hasher = Sha256::new();
hasher.update(self.name.as_bytes());
hasher.update([0x1f]);
hasher.update(schema.as_bytes());
hasher.update([0x1f]);
hasher.update([
u8::from(self.needs_approval),
u8::from(self.read_only),
u8::from(self.destructive),
u8::from(self.open_world),
u8::from(self.cacheable_approval),
u8::from(self.interactive),
]);
let digest = hasher.finalize();
let hex = digest.iter().fold(String::new(), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "{b:02x}");
acc
});
format!("sha256:{hex}")
}
}
impl ToolSpec {
#[must_use]
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
schema_json: serde_json::Value,
) -> Self {
Self {
name: name.into(),
description: description.into(),
schema_json,
title: None,
needs_approval: false,
read_only: false,
destructive: false,
open_world: false,
cacheable_approval: false,
interactive: false,
}
}
#[must_use]
pub fn titled(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub const fn read_only(mut self) -> Self {
self.read_only = true;
self
}
#[must_use]
pub const fn destructive(mut self) -> Self {
self.destructive = true;
self
}
#[must_use]
pub const fn open_world(mut self) -> Self {
self.open_world = true;
self
}
#[must_use]
pub const fn cacheable_approval(mut self) -> Self {
self.cacheable_approval = true;
self
}
#[must_use]
pub const fn approval_required(mut self) -> Self {
self.needs_approval = true;
self
}
#[must_use]
pub const fn interactive(mut self) -> Self {
self.interactive = true;
self
}
}
pub static GATED_TOOL_APPROVAL_NOTE: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
format!(
"Calling this pauses while a person reviews the request. \
{APPROVAL_STATUS_GROUND_RULE} A result from this tool means it was approved and \
has already run."
)
});
pub const APPROVAL_STATUS_GROUND_RULE: &str = "Never describe approval status yourself — the \
system shows what's pending and what ran.";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ToolChoice {
Auto,
None,
Required,
Named(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct JsonSchema(
pub serde_json::Value,
);
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use serde_json::{Value, json};
use super::*;
#[test]
fn new_sets_model_and_defaults() {
let req = CompletionRequest::new("fast-2");
assert_eq!(req.model, "fast-2");
assert!(req.messages.is_empty());
assert!(req.tools.is_empty());
assert!(req.stop.is_empty());
assert!(req.system.is_none());
assert!(req.max_tokens.is_none());
assert!(req.temperature.is_none());
assert!(req.response_format.is_none());
assert_eq!(req.tool_choice, ToolChoice::Auto);
}
#[test]
fn descriptor_hash_is_deterministic_and_key_order_independent() {
let spec = ToolSpec::new(
"send_message",
"sends a message",
json!({"to": "string", "text": "string"}),
)
.destructive();
let a = spec.descriptor_hash();
let b = spec.descriptor_hash();
assert_eq!(a, b, "hashing twice must reproduce the same digest");
assert!(a.starts_with("sha256:"));
let reordered = ToolSpec::new(
"send_message",
"sends a message",
json!({"text": "string", "to": "string"}),
)
.destructive();
assert_eq!(
a,
reordered.descriptor_hash(),
"object key insertion order must not change the digest"
);
}
#[test]
fn descriptor_hash_matches_an_independently_computed_digest() {
use sha2::{Digest as _, Sha256};
let spec = ToolSpec::new("grep", "search files", json!({"pattern": "string"}));
let mut hasher = Sha256::new();
hasher.update(b"grep");
hasher.update([0x1f]);
hasher.update(br#"{"pattern":"string"}"#);
hasher.update([0x1f]);
hasher.update([0u8, 0, 0, 0, 0, 0]); let expected: String = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
assert_eq!(spec.descriptor_hash(), format!("sha256:{expected}"));
}
#[test]
fn descriptor_hash_is_sensitive_to_every_covered_field() {
let base = ToolSpec::new("grep", "search files", json!({"pattern": "string"}));
let base_hash = base.descriptor_hash();
let renamed = ToolSpec::new("grep2", "search files", json!({"pattern": "string"}));
assert_ne!(base_hash, renamed.descriptor_hash(), "name must be covered");
let reschemad = ToolSpec::new("grep", "search files", json!({"pattern": "number"}));
assert_ne!(
base_hash,
reschemad.descriptor_hash(),
"schema_json must be covered"
);
let variants = [
base.clone().approval_required(),
base.clone().read_only(),
base.clone().destructive(),
base.clone().open_world(),
base.clone().cacheable_approval(),
base.clone().interactive(),
];
for (index, variant) in variants.iter().enumerate() {
assert_ne!(
base_hash,
variant.descriptor_hash(),
"annotation index {index} must be covered"
);
}
let retitled = ToolSpec::new(
"grep",
"a different description",
json!({"pattern": "string"}),
)
.titled("Grep");
assert_eq!(
base_hash,
retitled.descriptor_hash(),
"description/title are presentation only and must not change the hash"
);
}
#[test]
fn role_serializes_to_snake_case() {
assert_eq!(serde_json::to_string(&Role::User).unwrap(), r#""user""#);
assert_eq!(
serde_json::to_string(&Role::Assistant).unwrap(),
r#""assistant""#
);
assert_eq!(serde_json::to_string(&Role::System).unwrap(), r#""system""#);
assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), r#""tool""#);
}
#[test]
fn role_round_trips() {
for role in [Role::User, Role::Assistant, Role::System, Role::Tool] {
let json = serde_json::to_string(&role).unwrap();
let back: Role = serde_json::from_str(&json).unwrap();
assert_eq!(back, role);
}
}
#[test]
fn tool_choice_unit_variants_serialize_as_strings() {
assert_eq!(
serde_json::to_string(&ToolChoice::Auto).unwrap(),
r#""auto""#
);
assert_eq!(
serde_json::to_string(&ToolChoice::None).unwrap(),
r#""none""#
);
assert_eq!(
serde_json::to_string(&ToolChoice::Required).unwrap(),
r#""required""#
);
}
#[test]
fn tool_choice_named_serializes_as_object() {
let tc = ToolChoice::Named("my_tool".to_owned());
let v: Value = serde_json::to_value(&tc).unwrap();
assert_eq!(v, json!({"named": "my_tool"}));
}
#[test]
fn tool_choice_round_trips() {
for tc in [
ToolChoice::Auto,
ToolChoice::None,
ToolChoice::Required,
ToolChoice::Named("search".to_owned()),
] {
let json = serde_json::to_string(&tc).unwrap();
let back: ToolChoice = serde_json::from_str(&json).unwrap();
assert_eq!(back, tc);
}
}
#[test]
fn content_text_constructor() {
let c = Content::text("hello");
assert!(matches!(c, Content::Text(s) if s == "hello"));
}
#[test]
fn content_tool_use_constructor() {
let c = Content::tool_use("call-1", "search", r#"{"q":"rust"}"#);
match c {
Content::ToolUse(tu) => {
assert_eq!(tu.id, "call-1");
assert_eq!(tu.name, "search");
assert_eq!(tu.args_json, r#"{"q":"rust"}"#);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn content_tool_result_constructor() {
let c = Content::tool_result("call-1", r#"{"result":"ok"}"#, false, true);
match c {
Content::ToolResult(tr) => {
assert_eq!(tr.tool_call_id, "call-1");
assert_eq!(tr.result_json, r#"{"result":"ok"}"#);
assert!(!tr.is_error);
assert!(tr.first_party);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn content_image_constructor() {
let c = Content::image("https://example.com/img.png", Some("image/png".to_owned()));
match c {
Content::Image(img) => {
assert_eq!(img.url, "https://example.com/img.png");
assert_eq!(img.mime_type.as_deref(), Some("image/png"));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn message_user_constructor() {
let m = Message::user("hi");
assert_eq!(m.role, Role::User);
assert_eq!(m.content.len(), 1);
assert!(matches!(&m.content[0], Content::Text(s) if s == "hi"));
}
#[test]
fn message_assistant_constructor() {
let m = Message::assistant("hello back");
assert_eq!(m.role, Role::Assistant);
assert_eq!(m.content.len(), 1);
assert!(matches!(&m.content[0], Content::Text(s) if s == "hello back"));
}
#[test]
fn message_system_constructor() {
let m = Message::system("You are helpful.");
assert_eq!(m.role, Role::System);
assert_eq!(m.content.len(), 1);
assert!(matches!(&m.content[0], Content::Text(_)));
}
#[test]
fn tool_use_args_json_preserved_as_opaque_string() {
let original = r#"{"nested":{"key":42},"arr":[1,2,3]}"#;
let c = Content::tool_use("id-42", "complex_tool", original);
let serialized = serde_json::to_string(&c).unwrap();
let back: Content = serde_json::from_str(&serialized).unwrap();
match back {
Content::ToolUse(tu) => assert_eq!(tu.args_json, original),
_ => panic!("wrong variant"),
}
}
#[test]
fn completion_request_round_trips_all_content_variants() {
let mut req = CompletionRequest::new("test-model");
req.system = Some("Be concise.".to_owned());
req.max_tokens = Some(256);
req.temperature = Some(0.7);
req.stop = vec!["<end>".to_owned()];
req.tool_choice = ToolChoice::Named("calculator".to_owned());
req.response_format = Some(JsonSchema(json!({"type": "object"})));
req.tools = vec![ToolSpec::new(
"calculator",
"Evaluates math expressions.",
json!({"type": "object", "properties": {"expr": {"type": "string"}}}),
)];
req.messages = vec![
Message::user("Compute 2+2"),
Message {
role: Role::Assistant,
content: vec![Content::tool_use(
"call-1",
"calculator",
r#"{"expr":"2+2"}"#,
)],
},
Message {
role: Role::Tool,
content: vec![Content::tool_result(
"call-1",
r#"{"value":4}"#,
false,
true,
)],
},
Message {
role: Role::User,
content: vec![Content::image(
"https://example.com/chart.png",
Some("image/png".to_owned()),
)],
},
];
let json_str = serde_json::to_string(&req).unwrap();
let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
assert_eq!(back.model, "test-model");
assert_eq!(back.system.as_deref(), Some("Be concise."));
assert_eq!(back.max_tokens, Some(256));
assert_eq!(back.messages.len(), 4);
assert_eq!(back.tools.len(), 1);
assert_eq!(back.tool_choice, ToolChoice::Named("calculator".to_owned()));
}
#[test]
fn cache_hint_defaults_to_none_and_round_trips_on_the_request() {
assert_eq!(CompletionRequest::new("m").cache, CacheHint::None);
let mut req = CompletionRequest::new("m");
req.cache = CacheHint::StablePrefix {
key: Some("conv-7".to_owned()),
};
let json_str = serde_json::to_string(&req).unwrap();
let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
assert_eq!(
back.cache,
CacheHint::StablePrefix {
key: Some("conv-7".to_owned())
}
);
}
#[test]
fn cache_hint_round_trips_through_its_flat_key_form() {
let keyed = CacheHint::StablePrefix {
key: Some("conv-9".to_owned()),
};
assert_eq!(keyed.key(), Some("conv-9"));
assert_eq!(CacheHint::from_key("conv-9".to_owned()), keyed);
assert_eq!(CacheHint::None.key(), None);
assert_eq!(CacheHint::from_key(String::new()), CacheHint::None);
assert_eq!((CacheHint::StablePrefix { key: None }).key(), None);
}
#[test]
fn cache_hint_snake_case_wire_shape() {
let hint = CacheHint::StablePrefix { key: None };
let v: Value = serde_json::to_value(&hint).unwrap();
assert_eq!(v, json!({"stable_prefix": {"key": null}}));
assert_eq!(
serde_json::to_value(CacheHint::None).unwrap(),
json!("none")
);
}
#[test]
fn json_schema_serializes_transparently() {
let schema = JsonSchema(json!({"type": "object", "required": ["name"]}));
let v: Value = serde_json::to_value(&schema).unwrap();
assert_eq!(v["type"], "object");
assert_eq!(v["required"][0], "name");
}
#[test]
fn json_schema_round_trips() {
let inner = json!({"type": "string", "maxLength": 100});
let schema = JsonSchema(inner.clone());
let json_str = serde_json::to_string(&schema).unwrap();
let back: JsonSchema = serde_json::from_str(&json_str).unwrap();
assert_eq!(back.0, inner);
}
#[test]
fn image_ref_default_is_sensible() {
let img = ImageRef::default();
assert!(img.url.is_empty());
assert!(img.mime_type.is_none());
}
#[test]
fn tool_spec_carries_optional_title() {
let spec = ToolSpec::new("paid_fetch", "d", json!({})).titled("Pay for & fetch a web page");
assert_eq!(spec.title.as_deref(), Some("Pay for & fetch a web page"));
}
#[test]
fn tool_spec_carries_needs_approval_flag() {
let spec = ToolSpec::new("delete_file", "d", json!({})).approval_required();
assert!(spec.needs_approval);
}
#[test]
fn tool_spec_needs_approval_defaults_false_on_deserialize() {
let payload = json!({
"name": "calculator",
"description": "math",
"schema_json": {"type": "object"}
});
let spec: ToolSpec = serde_json::from_value(payload).unwrap();
assert!(
!spec.needs_approval,
"omitted needs_approval must default to false"
);
}
#[test]
fn gated_tool_approval_note_is_clean_user_facing_copy() {
let lower = GATED_TOOL_APPROVAL_NOTE.to_lowercase();
for banned in [
"please",
"sorry",
"unfortunately",
"operator",
"sub-agent",
"lethal-trifecta",
"state-changing action",
] {
assert!(
!lower.contains(banned),
"gated-tool note leaked banned word {banned:?}: {}",
GATED_TOOL_APPROVAL_NOTE.as_str()
);
}
assert!(
GATED_TOOL_APPROVAL_NOTE.contains("pauses"),
"note must say the call pauses, not that it ran"
);
assert!(
GATED_TOOL_APPROVAL_NOTE.contains("already run"),
"note must say a result means the tool already ran"
);
assert!(
GATED_TOOL_APPROVAL_NOTE.contains(APPROVAL_STATUS_GROUND_RULE),
"the note embeds the single authoritative approval-status rule verbatim"
);
}
}