agentix/request.rs
1//! Unified request layer.
2//!
3//! `AgentRequest` is the provider-agnostic representation of a chat completion
4//! request. Each provider implements `From<AgentRequest>` (or
5//! `Into<ProviderRequest>`) in its own module so that the agent core never
6//! needs to know about provider-specific field names or structural quirks.
7
8use serde::{Deserialize, Serialize};
9use crate::raw::shared::ToolDefinition;
10
11// ─── Message ────────────────────────────────────────────────────────────────
12
13/// Image content that can be embedded in a user message.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ImageContent {
16 pub data: ImageData,
17 /// MIME type, e.g. `"image/jpeg"`, `"image/png"`.
18 pub mime_type: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ImageData {
24 Base64(String),
25 Url(String),
26}
27
28/// A single content block inside a `Message::User`.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "type", rename_all = "snake_case")]
31pub enum UserContent {
32 Text(String),
33 Image(ImageContent),
34}
35
36impl From<&str> for UserContent {
37 fn from(s: &str) -> Self {
38 UserContent::Text(s.to_string())
39 }
40}
41impl From<String> for UserContent {
42 fn from(s: String) -> Self {
43 UserContent::Text(s)
44 }
45}
46
47/// A single turn in a conversation. Every variant carries exactly the fields
48/// it needs — no invalid states are representable.
49#[derive(Debug, Clone)]
50pub enum Message {
51 /// A message from the human side, supporting text and images.
52 User(Vec<UserContent>),
53
54 /// A message produced by the model. `content` and `tool_calls` may both be
55 /// present; `content` may be absent when the model only emits tool calls.
56 Assistant {
57 content: Option<String>,
58 /// Provider-specific chain-of-thought / reasoning text, if any.
59 reasoning: Option<String>,
60 tool_calls: Vec<ToolCall>,
61 },
62
63 /// The result of a tool invocation, keyed by the call's ID.
64 ToolResult { call_id: String, content: String },
65}
66
67impl Message {
68 /// Estimate the number of tokens in this message using tiktoken.
69 ///
70 /// Note: This is an estimation. Different providers have slightly different
71 /// tokenization rules and overheads for message metadata (role, name, etc.).
72 pub fn estimate_tokens(&self) -> usize {
73 use std::sync::OnceLock;
74 static BPE: OnceLock<tiktoken_rs::CoreBPE> = OnceLock::new();
75 let bpe = BPE.get_or_init(|| tiktoken_rs::cl100k_base().unwrap());
76 let mut tokens = 0;
77
78 match self {
79 Message::User(parts) => {
80 tokens += 4; // overhead for role
81 for part in parts {
82 match part {
83 UserContent::Text(t) => tokens += bpe.encode_with_special_tokens(t).len(),
84 UserContent::Image(_) => tokens += 1000, // rough fixed cost for images
85 }
86 }
87 }
88 Message::Assistant { content, reasoning, tool_calls } => {
89 tokens += 4;
90 if let Some(c) = content { tokens += bpe.encode_with_special_tokens(c).len(); }
91 if let Some(r) = reasoning { tokens += bpe.encode_with_special_tokens(r).len(); }
92 for tc in tool_calls {
93 tokens += bpe.encode_with_special_tokens(&tc.name).len();
94 tokens += bpe.encode_with_special_tokens(&tc.arguments).len();
95 }
96 }
97 Message::ToolResult { content, .. } => {
98 tokens += 4;
99 tokens += bpe.encode_with_special_tokens(content).len();
100 }
101 }
102 tokens
103 }
104}
105
106/// A single tool invocation requested by the model.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ToolCall {
109 pub id: String,
110 pub name: String,
111 /// Raw JSON string produced by the model.
112 pub arguments: String,
113}
114
115// ─── ToolChoice ─────────────────────────────────────────────────────────────
116
117/// Provider-agnostic tool selection hint.
118#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum ToolChoice {
121 /// Let the model decide (default when tools are present).
122 #[default]
123 Auto,
124 /// The model must not call any tools.
125 None,
126 /// The model must call at least one tool.
127 Required,
128 /// Force a specific tool by name.
129 Tool(String),
130}
131
132// ─── AgentRequest ───────────────────────────────────────────────────────────
133
134/// The canonical, provider-neutral chat-completion request.
135///
136/// Fields deliberately omitted (e.g. `top_p`, `logprobs`, `prefix`) are either
137/// provider-specific or rarely needed. Pass them through the `extra_body`
138/// mechanism on the provider's raw request if you need them.
139#[derive(Debug, Clone, Default)]
140pub struct Request {
141 /// Optional system prompt. `None` means no system message is sent.
142 pub system_message: Option<String>,
143
144 /// Conversation history. Must not contain `System` messages — use
145 /// `system_message` instead.
146 pub messages: Vec<Message>,
147
148 /// Model identifier string (e.g. `"deepseek-chat"`, `"gpt-4o"`).
149 pub model: String,
150
151 /// Tools the model may call.
152 pub tools: Option<Vec<ToolDefinition>>,
153
154 /// How the model should select tools.
155 pub tool_choice: Option<ToolChoice>,
156
157 /// Whether to stream the response. Defaults to `false`.
158 pub stream: bool,
159
160 /// Sampling temperature.
161 pub temperature: Option<f32>,
162
163 /// Maximum tokens to generate.
164 pub max_tokens: Option<u32>,
165
166 /// Constrain the output format.
167 pub response_format: Option<ResponseFormat>,
168
169 /// Arbitrary extra top-level fields merged into the provider's raw request body.
170 /// Use for provider-specific options not modelled here (e.g. `prefix`, `thinking`).
171 pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
172}
173
174/// Provider-agnostic output-format hint.
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum ResponseFormat {
178 #[default]
179 Text,
180 #[serde(rename = "json_object")]
181 JsonObject,
182}