Skip to main content

leviath_core/mime/
part.rs

1//! A part: one typed piece of content, and the reference a stored one carries.
2
3use serde::{Deserialize, Serialize};
4
5use super::registry::MimeRegistry;
6use super::{MimeType, text_plain};
7
8/// How a stored part should reach a model, overriding the registry's default.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum Delivery {
12    /// As the provider's native block for its family, when the model takes it.
13    Native,
14    /// As text, decoded from UTF-8, whatever the model declares.
15    Text,
16    /// Only the stand-in, never the bytes.
17    StandIn,
18}
19
20impl Delivery {
21    /// The word a tool argument, an `--attach` segment or an upload request
22    /// uses: `native`, `text` or `stand_in`.
23    pub fn from_arg(word: &str) -> Result<Self, String> {
24        match word {
25            "native" => Ok(Self::Native),
26            "text" => Ok(Self::Text),
27            "stand_in" => Ok(Self::StandIn),
28            other => Err(format!(
29                "'deliver' must be native, text or stand_in, not '{other}'"
30            )),
31        }
32    }
33}
34
35/// What a stored part carries instead of its bytes.
36///
37/// Small and serialisable, so a journal record, a snapshot or an event holds
38/// this while the file sits once in the run's blob store under `sha256`.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct BlobRef {
41    /// Lowercase hex SHA-256 of the bytes; the store's key.
42    pub sha256: String,
43    /// The type the bytes were stored as.
44    pub mime_type: MimeType,
45    /// Size in bytes.
46    pub size: u64,
47    /// Pixel width, when a probe could read it.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub width: Option<u32>,
50    /// Pixel height, when a probe could read it.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub height: Option<u32>,
53    /// Duration in milliseconds, when a probe could read it.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub duration_ms: Option<u64>,
56    /// The registry's token estimate at ingest, charged to the region.
57    #[serde(default)]
58    pub tokens: usize,
59    /// The text a consumer that cannot take the part sees, rendered by the
60    /// registry at ingest so nothing downstream needs the registry to read
61    /// an entry: `[image/png 1024x768, 240 KB] hero.png`.
62    #[serde(default)]
63    pub stand_in: String,
64}
65
66impl BlobRef {
67    /// Width and height together, when both are known.
68    pub fn dims(&self) -> Option<(u32, u32)> {
69        Some((self.width?, self.height?))
70    }
71
72    /// A short prefix of the hash, enough to name it in a line of text.
73    pub fn short_sha(&self) -> &str {
74        self.sha256.get(..12).unwrap_or(&self.sha256)
75    }
76}
77
78/// Bytes with a type, in flight. Never serialised: a `Blob` becomes a
79/// [`BlobRef`] the moment it is stored.
80#[derive(Clone, PartialEq, Eq)]
81pub struct Blob {
82    /// The type the bytes are.
83    pub mime_type: MimeType,
84    /// The bytes.
85    pub bytes: Vec<u8>,
86    /// A file name to show and to resolve `@name` references by.
87    pub name: Option<String>,
88}
89
90impl std::fmt::Debug for Blob {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("Blob")
93            .field("mime_type", &self.mime_type)
94            .field("bytes", &format_args!("{} bytes", self.bytes.len()))
95            .field("name", &self.name)
96            .finish()
97    }
98}
99
100impl Blob {
101    /// A blob of `bytes` typed `mime_type`.
102    pub fn new(mime_type: MimeType, bytes: Vec<u8>) -> Self {
103        Self {
104            mime_type,
105            bytes,
106            name: None,
107        }
108    }
109
110    /// The same blob, named.
111    pub fn named(mut self, name: impl Into<String>) -> Self {
112        self.name = Some(name.into());
113        self
114    }
115
116    /// The reference this blob would be stored under: hash, probes, estimate,
117    /// and the stand-in text rendered from the registry's row.
118    pub fn describe(&self, reg: &MimeRegistry) -> BlobRef {
119        let info = reg.info(&self.mime_type);
120        let dims = super::probe::dimensions(&self.mime_type, &self.bytes);
121        let duration_ms = super::probe::duration_ms(&self.mime_type, &self.bytes);
122        let pages = super::probe::pages(&self.mime_type, &self.bytes);
123        let size = self.bytes.len() as u64;
124        BlobRef {
125            sha256: super::store::sha256_hex(&self.bytes),
126            mime_type: self.mime_type.clone(),
127            size,
128            width: dims.map(|d| d.0),
129            height: dims.map(|d| d.1),
130            duration_ms,
131            tokens: info.tokens.estimate(size, dims, duration_ms, pages),
132            stand_in: info.render_stand_in(self.name.as_deref(), size, dims, duration_ms, pages),
133        }
134    }
135}
136
137/// Where a part's bytes are.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(untagged)]
140pub enum PartBody {
141    /// UTF-8 text carried in the entry itself.
142    Inline(String),
143    /// Bytes in the run's blob store, by reference.
144    Stored(BlobRef),
145}
146
147/// One typed piece of content.
148///
149/// A region entry, a tool output, a user message, a model reply and a final
150/// output are each a list of these. Text is a part like any other; its body is
151/// inline because its registry row says it is text, and that is the whole
152/// difference.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct Part {
155    /// The part's type.
156    pub mime_type: MimeType,
157    /// Its bytes, inline or by reference.
158    pub body: PartBody,
159    /// A name: the file it came from, the artifact it is, what `@name` finds.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub name: Option<String>,
162    /// How a stored part reaches a model, when the default is not wanted.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub deliver: Option<Delivery>,
165}
166
167impl Part {
168    /// A `text/plain` part.
169    pub fn text(s: impl Into<String>) -> Self {
170        Self::inline(text_plain(), s)
171    }
172
173    /// An inline part of any text-family type.
174    pub fn inline(mime_type: MimeType, s: impl Into<String>) -> Self {
175        Self {
176            mime_type,
177            body: PartBody::Inline(s.into()),
178            name: None,
179            deliver: None,
180        }
181    }
182
183    /// A stored part from its reference.
184    pub fn stored(blob: BlobRef) -> Self {
185        Self {
186            mime_type: blob.mime_type.clone(),
187            body: PartBody::Stored(blob),
188            name: None,
189            deliver: None,
190        }
191    }
192
193    /// The same part, named.
194    pub fn named(mut self, name: impl Into<String>) -> Self {
195        self.name = Some(name.into());
196        self
197    }
198
199    /// The same part, with a delivery override.
200    pub fn delivered(mut self, deliver: Delivery) -> Self {
201        self.deliver = Some(deliver);
202        self
203    }
204
205    /// The inline text, if this part carries any.
206    pub fn inline_text(&self) -> Option<&str> {
207        match &self.body {
208            PartBody::Inline(s) => Some(s),
209            PartBody::Stored(_) => None,
210        }
211    }
212
213    /// The reference, if this part is stored.
214    pub fn blob(&self) -> Option<&BlobRef> {
215        match &self.body {
216            PartBody::Inline(_) => None,
217            PartBody::Stored(b) => Some(b),
218        }
219    }
220
221    /// Whether this part's bytes are in the store rather than inline.
222    pub fn is_stored(&self) -> bool {
223        matches!(self.body, PartBody::Stored(_))
224    }
225
226    /// Whether `needle`, as a model or a person would type it, names this
227    /// stored part: its exact name, the file name of a path-shaped needle
228    /// (`out/hero.png` for `hero.png`), or at least six characters of its
229    /// hash in either case. One rule for every tool that takes a part by
230    /// name, so `context_export` and `submit_output` cannot disagree about
231    /// which part `hero` means. An inline part is never named.
232    pub fn is_named(&self, needle: &str) -> bool {
233        let Some(blob) = self.blob() else {
234            return false;
235        };
236        if self.name.as_deref() == Some(needle) {
237            return true;
238        }
239        let basename = std::path::Path::new(needle)
240            .file_name()
241            .and_then(|n| n.to_str());
242        if basename.is_some_and(|b| b != needle && self.name.as_deref() == Some(b)) {
243            return true;
244        }
245        let lower = needle.to_ascii_lowercase();
246        lower.len() >= 6 && blob.sha256.starts_with(&lower)
247    }
248
249    /// What this part costs a region.
250    ///
251    /// A stored part is charged its stand-in, not its native token estimate.
252    /// In a region a stored blob is only a reference plus the short stand-in
253    /// line that stands for it in the assembled prompt; the native estimate
254    /// on the blob ref (a per-byte rule over a multi-megabyte model is
255    /// millions of tokens) is the cost of sending the bytes to a model that
256    /// can read them, charged at request-build time. Charged here instead, no
257    /// reasonably sized region could hold real media at all.
258    pub fn tokens(&self, reg: &MimeRegistry) -> usize {
259        match &self.body {
260            PartBody::Inline(s) => {
261                let info = reg.info(&self.mime_type);
262                match info.tokens {
263                    super::TokenRule::PerByte(rate) if (rate - 0.25).abs() < f64::EPSILON => {
264                        crate::text::estimate_tokens(s)
265                    }
266                    rule => rule.estimate(s.len() as u64, None, None, None),
267                }
268            }
269            PartBody::Stored(b) => crate::text::estimate_tokens(&b.stand_in),
270        }
271    }
272
273    /// The text a consumer that cannot take a stored part sees, as rendered
274    /// at ingest. An inline part's stand-in is its own text.
275    pub fn stand_in(&self) -> String {
276        match &self.body {
277            PartBody::Inline(s) => s.clone(),
278            PartBody::Stored(b) => b.stand_in.clone(),
279        }
280    }
281
282    /// The name this part answers to for an `@name` or tool-argument lookup:
283    /// its `name`, else its short hash for a stored part.
284    pub fn handle(&self) -> Option<String> {
285        self.name
286            .clone()
287            .or_else(|| self.blob().map(|b| b.short_sha().to_string()))
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn a_delivery_word_parses_or_names_the_three_words() {
297        assert_eq!(Delivery::from_arg("native"), Ok(Delivery::Native));
298        assert_eq!(Delivery::from_arg("text"), Ok(Delivery::Text));
299        assert_eq!(Delivery::from_arg("stand_in"), Ok(Delivery::StandIn));
300        let err = Delivery::from_arg("loud").unwrap_err();
301        assert!(
302            err.contains("native, text or stand_in, not 'loud'"),
303            "{err}"
304        );
305    }
306
307    /// The one naming rule: exact name, the file name of a path, or six or
308    /// more hash characters in either case. An inline part is never named.
309    #[test]
310    fn a_part_is_named_by_its_name_its_file_name_or_its_hash() {
311        let reg = MimeRegistry::builtin();
312        let blob = Blob::new(mt("image/png"), b"\x89PNG\r\n\x1a\nbytes".to_vec()).describe(&reg);
313        let sha = blob.sha256.clone();
314        let part = Part::stored(blob).named("hero.png");
315        assert!(part.is_named("hero.png"));
316        assert!(part.is_named("out/hero.png"));
317        assert!(!part.is_named("hero"));
318        assert!(part.is_named(&sha.get(..6).unwrap().to_ascii_uppercase()));
319        assert!(!part.is_named(sha.get(..5).unwrap()));
320        assert!(!Part::text("hero.png").is_named("hero.png"));
321    }
322
323    fn mt(s: &str) -> MimeType {
324        MimeType::parse(s).unwrap()
325    }
326
327    #[test]
328    fn text_parts_are_inline_text_plain() {
329        let p = Part::text("hello");
330        assert_eq!(p.mime_type.as_str(), "text/plain");
331        assert_eq!(p.inline_text(), Some("hello"));
332        assert!(p.blob().is_none());
333        assert!(!p.is_stored());
334        let reg = MimeRegistry::builtin();
335        assert_eq!(p.tokens(&reg), 2);
336        assert_eq!(p.stand_in(), "hello");
337        assert!(p.handle().is_none());
338        let json = serde_json::to_string(&p).unwrap();
339        assert_eq!(json, r#"{"mime_type":"text/plain","body":"hello"}"#);
340        let back: Part = serde_json::from_str(&json).unwrap();
341        assert_eq!(back, p);
342    }
343
344    #[test]
345    fn inline_parts_of_other_text_types() {
346        let reg = MimeRegistry::builtin();
347        let p = Part::inline(mt("model/obj"), "v 1 2 3\n").named("cube.obj");
348        assert_eq!(p.handle().as_deref(), Some("cube.obj"));
349        assert_eq!(p.tokens(&reg), 2);
350        let mut reg2 = MimeRegistry::empty();
351        let t: toml::Table =
352            toml::from_str("[\"x/fixed\"]\ntext = true\ntokens = { fixed = 9 }").unwrap();
353        reg2.layer(&t, "t").unwrap();
354        let q = Part::inline(mt("x/fixed"), "anything");
355        assert_eq!(q.tokens(&reg2), 9);
356    }
357
358    #[test]
359    fn stored_parts_carry_their_reference() {
360        let reg = MimeRegistry::builtin();
361        let blob = Blob::new(mt("image/png"), b"\x89PNG\r\n\x1a\nxxxx".to_vec()).named("a.png");
362        assert!(format!("{blob:?}").contains("12 bytes"));
363        let r = blob.describe(&reg);
364        assert_eq!(r.size, 12);
365        assert_eq!(r.sha256.len(), 64);
366        assert_eq!(r.short_sha().len(), 12);
367        assert!(r.dims().is_none());
368        assert_eq!(r.tokens, 1600);
369        let p = Part::stored(r.clone())
370            .named("a.png")
371            .delivered(Delivery::Text);
372        assert!(p.is_stored());
373        assert_eq!(p.blob(), Some(&r));
374        assert!(p.inline_text().is_none());
375        // A stored part is charged its stand-in against a region, not its native
376        // token estimate (1600): in a region it is only a ref plus the short
377        // stand-in line, and the native cost is charged at request-build time.
378        assert_eq!(p.tokens(&reg), crate::text::estimate_tokens(&r.stand_in));
379        assert!(p.tokens(&reg) < r.tokens);
380        assert_eq!(p.stand_in(), "[image/png, 12 B] a.png");
381        assert_eq!(r.stand_in, "[image/png, 12 B] a.png");
382        assert_eq!(p.deliver, Some(Delivery::Text));
383        let json = serde_json::to_string(&p).unwrap();
384        assert!(json.contains("\"deliver\":\"text\""));
385        let back: Part = serde_json::from_str(&json).unwrap();
386        assert_eq!(back, p);
387        let unnamed = Part::stored(r.clone());
388        assert_eq!(unnamed.handle().unwrap(), r.short_sha());
389        let short = BlobRef {
390            sha256: "abc".into(),
391            width: Some(4),
392            ..r
393        };
394        assert_eq!(short.short_sha(), "abc");
395        assert_eq!(short.dims(), None, "a width without a height is not a size");
396    }
397
398    #[test]
399    fn dims_and_duration_flow_through() {
400        let reg = MimeRegistry::builtin();
401        let mut png = vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
402        png.extend_from_slice(&[0, 0, 0, 13, b'I', b'H', b'D', b'R']);
403        png.extend_from_slice(&[0, 0, 0x04, 0x00, 0, 0, 0x03, 0x00]);
404        let r = Blob::new(mt("image/png"), png)
405            .named("hero.png")
406            .describe(&reg);
407        assert_eq!(r.dims(), Some((1024, 768)));
408        assert_eq!(r.tokens, 1049);
409        let p = Part::stored(r).named("hero.png");
410        assert_eq!(p.stand_in(), "[image/png 1024x768, 24 B] hero.png");
411        let unnamed = Blob::new(mt("audio/wav"), vec![1, 2, 3]).describe(&reg);
412        // A document is billed by its pages, not its bytes: a 2 MB brochure
413        // was charged over a million tokens under a per-byte rule and could
414        // not be sent to any model.
415        let mut pdf =
416            b"%PDF-1.7\n<< /Type /Pages /Count 2 >> << /Type /Page >> << /Type /Page >>".to_vec();
417        pdf.extend(std::iter::repeat_n(b' ', 2 * 1024 * 1024));
418        let brochure = Blob::new(mt("application/pdf"), pdf)
419            .named("brochure.pdf")
420            .describe(&reg);
421        assert_eq!(brochure.tokens, 4000);
422        assert!(
423            brochure.stand_in.contains("2 pages"),
424            "{}",
425            brochure.stand_in
426        );
427        assert!(
428            brochure.stand_in.contains("brochure.pdf"),
429            "{}",
430            brochure.stand_in
431        );
432        assert_eq!(Part::stored(unnamed).stand_in(), "[audio/wav, 3 B]");
433    }
434
435    #[test]
436    fn delivery_serialises_snake_case() {
437        assert_eq!(
438            serde_json::to_string(&Delivery::StandIn).unwrap(),
439            "\"stand_in\""
440        );
441        assert_eq!(
442            serde_json::from_str::<Delivery>("\"native\"").unwrap(),
443            Delivery::Native
444        );
445    }
446}