Skip to main content

htmlp/runtime/
request.rs

1//! Compile-time-enforced content attribution.
2//!
3//! Everything that reaches the model flows through [`PromptFragment`], which is
4//! only constructible with a [`ContentSource`] tag. [`ModelRequest`] is
5//! assembled exclusively from fragments, so per-request byte attribution by
6//! category is always available — there is no untagged path to the LLM.
7
8use std::collections::BTreeMap;
9
10use serde::Deserialize;
11use serde::Serialize;
12
13use super::{ByteEstimator, RequestEstimate, Role, ToolUseId};
14
15/// The origin category of a piece of model-bound content.
16#[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    /// A typed context block (env details, rules, skills catalog, ...).
22    ContextBlock {
23        block_id: String,
24    },
25    ToolDefinitions,
26    UserMessage,
27    SessionMessage,
28    /// Prior assistant output replayed as history.
29    AssistantHistory,
30    ToolOutput {
31        tool_name: String,
32    },
33    /// Harness-injected content, distinct from user-authored messages.
34    SystemReminder {
35        origin: String,
36    },
37    Hook {
38        hook_name: String,
39    },
40    CompactionSummary,
41}
42
43/// Provider-neutral content block.
44#[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    /// Provider reasoning returned alongside an assistant message. Adapters
52    /// that support preserved thinking replay this through their native
53    /// field; other adapters deliberately omit it.
54    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/// A source-attributed piece of a model request. Fields are private; the only
86/// constructors require attribution at the call site.
87///
88/// ```compile_fail
89/// use htmlp::runtime::{PromptFragment, Role};
90/// let fragment = PromptFragment::text(Role::User, "untagged");
91/// ```
92#[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    /// Construct a text fragment from immutable, fully checked HTMLP output.
117    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/// A tool definition offered to the model. Counted under
139/// [`ContentSource::ToolDefinitions`] in attribution.
140#[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/// Reasoning/thinking effort. Providers map this to their native knob
155/// (Anthropic `budget_tokens`, OpenAI `reasoning_effort`).
156#[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    /// Serialized as "xhigh". Providers clamp to the model's max effort.
166    XHigh,
167    /// Provider-native maximum reasoning effort when distinct from xhigh.
168    Max,
169}
170
171impl ThinkingLevel {
172    /// The canonical lowercase name, matching the serde representation.
173    /// Provider adapters that need a different word for a level (codex
174    /// spells `Off` as "none") map it themselves.
175    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/// A complete, attributed model request. Constructed only via [`ModelRequestBuilder`].
194#[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    /// Byte attribution by source category, including tool definitions.
224    /// The heart of the attribution invariant: this is derivable for every
225    /// request because no content can bypass fragment construction.
226    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    /// Categorized heuristic estimate. Tokenizer counts and provider-reported
239    /// usage are separate measurements; this does not count wire framing.
240    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    /// Prefix-cache pattern: the live request, unchanged, with one
249    /// instruction appended as a suffix (compaction summaries, titles).
250    /// Tool definitions are dropped — suffix requests want plain text back.
251    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    /// Cache-warm probe: the request unchanged — tool definitions kept,
261    /// they are part of the provider's cached prefix (Anthropic caches
262    /// tools → system → messages) — plus one throwaway user fragment
263    /// (providers reject empty message lists). The fragment sits after the
264    /// system-block cache breakpoint, so its content never affects the
265    /// cached prefix. `max_output_tokens: 1` caps waste if the caller's
266    /// first-event abort is slow.
267    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    /// The request with every image block replaced by a text note, for
281    /// models whose endpoint accepts text content only — sending the
282    /// image block would fail the whole request at the provider. The
283    /// session log is untouched: history keeps the real image parts, so
284    /// switching to an image-capable model replays them as images again.
285    /// Attribution is preserved per fragment; only the image bytes become
286    /// the note's text.
287    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
313/// Shown to the model in place of an image the active model cannot view.
314/// Reads as a bracketed aside so tool-result descriptions ("Image foo.png:
315/// 800×600 …") still read as the metadata line it accompanies.
316pub 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    /// The only way to add content — a pre-attributed fragment.
328    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}