1use std::collections::BTreeMap;
9
10use serde::Deserialize;
11use serde::Serialize;
12
13use super::{ByteEstimator, RequestEstimate, Role, ToolUseId};
14
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18#[serde(tag = "kind", rename_all = "snake_case")]
19pub enum ContentSource {
20 SystemPrompt,
21 ContextBlock {
23 block_id: String,
24 },
25 ToolDefinitions,
26 UserMessage,
27 SessionMessage,
28 AssistantHistory,
30 ToolOutput {
31 tool_name: String,
32 },
33 SystemReminder {
35 origin: String,
36 },
37 Hook {
38 hook_name: String,
39 },
40 CompactionSummary,
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[serde(tag = "type", rename_all = "snake_case")]
47pub enum ContentBlock {
48 Text {
49 text: String,
50 },
51 Reasoning {
55 text: String,
56 },
57 Image {
58 media_type: String,
59 base64_data: String,
60 },
61 ToolUse {
62 tool_use_id: ToolUseId,
63 tool_name: String,
64 arguments: serde_json::Value,
65 },
66 ToolResult {
67 tool_use_id: ToolUseId,
68 content: String,
69 is_error: bool,
70 },
71}
72
73impl ContentBlock {
74 pub fn byte_len(&self) -> usize {
75 match self {
76 ContentBlock::Text { text } => text.len(),
77 ContentBlock::Reasoning { text } => text.len(),
78 ContentBlock::Image { base64_data, .. } => base64_data.len(),
79 ContentBlock::ToolUse { arguments, .. } => arguments.to_string().len(),
80 ContentBlock::ToolResult { content, .. } => content.len(),
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94pub struct PromptFragment {
95 source: ContentSource,
96 role: Role,
97 content: Vec<ContentBlock>,
98 byte_count: usize,
99}
100
101impl PromptFragment {
102 pub fn new(source: ContentSource, role: Role, content: Vec<ContentBlock>) -> Self {
103 let byte_count = content.iter().map(ContentBlock::byte_len).sum();
104 Self {
105 source,
106 role,
107 content,
108 byte_count,
109 }
110 }
111
112 pub fn text(source: ContentSource, role: Role, text: impl Into<String>) -> Self {
113 Self::new(source, role, vec![ContentBlock::Text { text: text.into() }])
114 }
115
116 pub fn checked(source: ContentSource, role: Role, rendered: &crate::RenderedPrompt) -> Self {
118 Self::text(source, role, rendered.text())
119 }
120
121 pub fn source(&self) -> &ContentSource {
122 &self.source
123 }
124
125 pub fn role(&self) -> Role {
126 self.role
127 }
128
129 pub fn content(&self) -> &[ContentBlock] {
130 &self.content
131 }
132
133 pub fn byte_count(&self) -> usize {
134 self.byte_count
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct ToolDefinition {
143 pub name: String,
144 pub description: String,
145 pub input_schema: serde_json::Value,
146}
147
148impl ToolDefinition {
149 pub fn byte_len(&self) -> usize {
150 self.name.len() + self.description.len() + self.input_schema.to_string().len()
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158#[serde(rename_all = "lowercase")]
159pub enum ThinkingLevel {
160 Off,
161 Low,
162 Medium,
163 #[default]
164 High,
165 XHigh,
167 Max,
169}
170
171impl ThinkingLevel {
172 pub fn as_str(&self) -> &'static str {
176 match self {
177 ThinkingLevel::Off => "off",
178 ThinkingLevel::Low => "low",
179 ThinkingLevel::Medium => "medium",
180 ThinkingLevel::High => "high",
181 ThinkingLevel::XHigh => "xhigh",
182 ThinkingLevel::Max => "max",
183 }
184 }
185}
186
187impl std::fmt::Display for ThinkingLevel {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 f.write_str(self.as_str())
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize)]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196pub struct ModelRequest {
197 fragments: Vec<PromptFragment>,
198 tools: Vec<ToolDefinition>,
199 pub model: String,
200 pub max_output_tokens: Option<u32>,
201 pub thinking_level: ThinkingLevel,
202}
203
204impl ModelRequest {
205 pub fn builder(model: impl Into<String>) -> ModelRequestBuilder {
206 ModelRequestBuilder {
207 fragments: Vec::new(),
208 tools: Vec::new(),
209 model: model.into(),
210 max_output_tokens: None,
211 thinking_level: ThinkingLevel::default(),
212 }
213 }
214
215 pub fn fragments(&self) -> &[PromptFragment] {
216 &self.fragments
217 }
218
219 pub fn tools(&self) -> &[ToolDefinition] {
220 &self.tools
221 }
222
223 pub fn bytes_by_source(&self) -> BTreeMap<ContentSource, usize> {
227 let mut result: BTreeMap<ContentSource, usize> = BTreeMap::new();
228 for fragment in &self.fragments {
229 *result.entry(fragment.source.clone()).or_default() += fragment.byte_count;
230 }
231 let tool_bytes: usize = self.tools.iter().map(ToolDefinition::byte_len).sum();
232 if tool_bytes > 0 {
233 *result.entry(ContentSource::ToolDefinitions).or_default() += tool_bytes;
234 }
235 result
236 }
237
238 pub fn estimate(&self) -> RequestEstimate {
241 ByteEstimator::default().estimate(self)
242 }
243
244 pub fn total_bytes(&self) -> usize {
245 self.bytes_by_source().values().sum()
246 }
247
248 pub fn with_suffix_instruction(&self, source: ContentSource, instruction: &str) -> Self {
252 let mut request = self.clone();
253 request.tools = Vec::new();
254 request
255 .fragments
256 .push(PromptFragment::text(source, Role::User, instruction));
257 request
258 }
259
260 pub fn with_warm_probe(&self) -> Self {
268 let mut request = self.clone();
269 request.max_output_tokens = Some(1);
270 request.fragments.push(PromptFragment::text(
271 ContentSource::SystemReminder {
272 origin: "cache-warm".to_string(),
273 },
274 Role::User,
275 "ping",
276 ));
277 request
278 }
279
280 pub fn without_image_blocks(&self) -> Self {
288 let mut request = self.clone();
289 for fragment in &mut request.fragments {
290 if !fragment
291 .content()
292 .iter()
293 .any(|block| matches!(block, ContentBlock::Image { .. }))
294 {
295 continue;
296 }
297 let replaced = fragment
298 .content()
299 .iter()
300 .map(|block| match block {
301 ContentBlock::Image { .. } => ContentBlock::Text {
302 text: TEXT_ONLY_IMAGE_NOTE.to_string(),
303 },
304 other => other.clone(),
305 })
306 .collect();
307 *fragment = PromptFragment::new(fragment.source().clone(), fragment.role(), replaced);
308 }
309 request
310 }
311}
312
313pub const TEXT_ONLY_IMAGE_NOTE: &str = "[image not shown: this model does not support image input]";
317
318pub struct ModelRequestBuilder {
319 fragments: Vec<PromptFragment>,
320 tools: Vec<ToolDefinition>,
321 model: String,
322 max_output_tokens: Option<u32>,
323 thinking_level: ThinkingLevel,
324}
325
326impl ModelRequestBuilder {
327 pub fn fragment(mut self, fragment: PromptFragment) -> Self {
329 self.fragments.push(fragment);
330 self
331 }
332
333 pub fn tool(mut self, tool: ToolDefinition) -> Self {
334 self.tools.push(tool);
335 self
336 }
337
338 pub fn max_output_tokens(mut self, tokens: u32) -> Self {
339 self.max_output_tokens = Some(tokens);
340 self
341 }
342
343 pub fn thinking_level(mut self, level: ThinkingLevel) -> Self {
344 self.thinking_level = level;
345 self
346 }
347
348 pub fn build(self) -> ModelRequest {
349 ModelRequest {
350 fragments: self.fragments,
351 tools: self.tools,
352 model: self.model,
353 max_output_tokens: self.max_output_tokens,
354 thinking_level: self.thinking_level,
355 }
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn image_transport_bytes_do_not_trigger_text_sized_compaction() {
365 let request = ModelRequest::builder("vision")
366 .fragment(PromptFragment::new(
367 ContentSource::ToolOutput {
368 tool_name: "read_file".into(),
369 },
370 Role::User,
371 vec![ContentBlock::Image {
372 media_type: "image/jpeg".into(),
373 base64_data: "A".repeat(1_300_000),
374 }],
375 ))
376 .build();
377 assert_eq!(
378 request.total_bytes(),
379 1_300_000,
380 "raw byte attribution is exact"
381 );
382 assert_eq!(request.estimate().total_tokens(), 8192);
383 }
384
385 #[test]
386 fn bytes_by_source_attributes_every_category() {
387 let request = ModelRequest::builder("test-model")
388 .fragment(PromptFragment::text(
389 ContentSource::SystemPrompt,
390 Role::System,
391 "base",
392 ))
393 .fragment(PromptFragment::text(
394 ContentSource::ContextBlock {
395 block_id: "workspace".to_string(),
396 },
397 Role::System,
398 "cwd:/repo",
399 ))
400 .fragment(PromptFragment::text(
401 ContentSource::UserMessage,
402 Role::User,
403 "hello!",
404 ))
405 .tool(ToolDefinition {
406 name: "bash".to_string(),
407 description: "run".to_string(),
408 input_schema: serde_json::json!({}),
409 })
410 .build();
411
412 let bytes = request.bytes_by_source();
413 assert_eq!(
414 bytes[&ContentSource::SystemPrompt],
415 4,
416 "system prompt bytes = len('base')"
417 );
418 assert_eq!(
419 bytes[&ContentSource::ContextBlock {
420 block_id: "workspace".to_string()
421 }],
422 9,
423 "context block bytes = len('cwd:/repo')"
424 );
425 assert_eq!(
426 bytes[&ContentSource::UserMessage],
427 6,
428 "user bytes = len('hello!')"
429 );
430 assert!(
431 bytes[&ContentSource::ToolDefinitions] >= 9,
432 "tool defs counted (name+description+schema)"
433 );
434 assert_eq!(
435 bytes.values().sum::<usize>(),
436 request.total_bytes(),
437 "totals agree"
438 );
439 }
440
441 #[test]
442 fn without_image_blocks_swaps_notes_and_keeps_attribution() {
443 let request = ModelRequest::builder("zai/glm-5.3")
444 .fragment(PromptFragment::new(
445 ContentSource::UserMessage,
446 Role::User,
447 vec![
448 ContentBlock::Text {
449 text: "what is this?".into(),
450 },
451 ContentBlock::Image {
452 media_type: "image/png".into(),
453 base64_data: "aGVsbG8=".into(),
454 },
455 ],
456 ))
457 .fragment(PromptFragment::text(
458 ContentSource::SystemPrompt,
459 Role::System,
460 "sys",
461 ))
462 .build();
463
464 let replaced = request.without_image_blocks();
465 let blocks = replaced.fragments()[0].content();
466 assert_eq!(
467 blocks[0],
468 ContentBlock::Text {
469 text: "what is this?".into()
470 },
471 "neighboring text blocks pass through"
472 );
473 assert_eq!(
474 blocks[1],
475 ContentBlock::Text {
476 text: TEXT_ONLY_IMAGE_NOTE.to_string()
477 },
478 "the image block becomes the note"
479 );
480 assert_eq!(blocks.len(), replaced.fragments()[0].content().len());
481 assert_eq!(
482 replaced.fragments()[0].source(),
483 &ContentSource::UserMessage,
484 "attribution is preserved"
485 );
486 assert_eq!(
487 replaced.fragments()[0].role(),
488 Role::User,
489 "role is preserved"
490 );
491 assert_eq!(
492 replaced.fragments()[1],
493 request.fragments()[1],
494 "fragments without images are untouched"
495 );
496 }
497}