Skip to main content

gglib_core/request_pipeline/
content.rs

1//! The two shapes a message's `content` takes, read in one place.
2//!
3//! An `OpenAI` message's `content` is a string, or an array of parts in which
4//! the text parts carry a `text` field beside parts that are not text
5//! (`image_url`, …). VS Code's LLM gateway sends the array form on every
6//! message. Each stage that reads or rewrites message text has to handle
7//! both, and until now each walked them inline where it needed them:
8//! canonicalisation rewrote each shape's text in place, and truncation
9//! measured the string and skipped the array. The walk lives here so a stage
10//! that handles one shape handles the other.
11//!
12//! Everything here works on a raw [`serde_json::Value`], never on a typed
13//! message, because the callers forward the body they were given: a round
14//! trip through a typed message re-serialises it, and the byte stability of
15//! the forwarded prompt is what canonicalisation exists to protect.
16
17use serde_json::Value;
18
19/// The number of characters of text `content` carries, in either shape.
20///
21/// A string is its own length; an array is the sum of its parts' `text`
22/// fields, with the parts that are not text counting for nothing; any other
23/// shape is 0.
24#[must_use]
25pub fn text_len(content: &Value) -> usize {
26    match content {
27        Value::String(text) => text.len(),
28        Value::Array(parts) => parts
29            .iter()
30            .filter_map(|part| part.get("text").and_then(Value::as_str))
31            .map(str::len)
32            .sum(),
33        _ => 0,
34    }
35}
36
37/// The number of pieces of text `content` carries: 1 for a string, one per
38/// text part for an array, 0 for anything else.
39#[must_use]
40pub fn text_parts(content: &Value) -> usize {
41    match content {
42        Value::String(_) => 1,
43        Value::Array(parts) => parts
44            .iter()
45            .filter(|part| part.get("text").is_some_and(Value::is_string))
46            .count(),
47        _ => 0,
48    }
49}
50
51/// Append `text` to `content` as a trailing piece of text, in either shape,
52/// and say whether it could be.
53///
54/// A string gains `text` after a blank line. An array gains one more
55/// `{"type": "text", "text": …}` part at the end, beside whatever parts it
56/// already has — the walks above can only rewrite text that is already there,
57/// so adding a piece is its own operation rather than a use of them. Any
58/// other shape — `null`, absent, a number — is **left untouched** and reports
59/// `false`, which is the caller's signal that this message cannot carry the
60/// text and something else must.
61///
62/// The blank line matters: the appended text has to read as a separate
63/// paragraph to a model that is about to be handed the whole thing as one
64/// string, and the shapes must agree about that, because a template is free
65/// to join an array's text parts with nothing between them.
66pub fn append_text(content: &mut Value, text: &str) -> bool {
67    match content {
68        Value::String(existing) => {
69            if !existing.is_empty() {
70                existing.push_str("\n\n");
71            }
72            existing.push_str(text);
73            true
74        }
75        Value::Array(parts) => {
76            parts.push(serde_json::json!({ "type": "text", "text": text }));
77            true
78        }
79        _ => false,
80    }
81}
82
83/// Apply `f` to every piece of text in `content`, in either shape, and say
84/// how many pieces it visited.
85///
86/// The shape is kept: a string stays a string, and an array keeps its parts
87/// in order with the ones that are not text untouched. A `content` of any
88/// other shape is left alone and reports 0.
89pub fn for_each_text_mut(content: &mut Value, f: &mut dyn FnMut(&mut String)) -> usize {
90    match content {
91        Value::String(text) => {
92            f(text);
93            1
94        }
95        Value::Array(parts) => {
96            let mut visited = 0;
97            for part in parts.iter_mut() {
98                if let Some(Value::String(text)) = part.get_mut("text") {
99                    f(text);
100                    visited += 1;
101                }
102            }
103            visited
104        }
105        _ => 0,
106    }
107}
108
109#[cfg(test)]
110#[path = "content_tests.rs"]
111mod content_tests;