use rpi_ai::Content;
use serde_json::{json, Value};
use std::io::{self, Write};
pub const MAX_CONTENT_BLOCKS: usize = 64;
pub const MAX_IMAGES: usize = 4;
pub const MAX_TOTAL_IMAGE_BYTES: usize = 2 * 1024 * 1024;
pub const MAX_TEXT_BYTES: usize = 32 * 1024;
pub const MAX_PROMPT_BYTES: usize = 3 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedPrompt {
Legacy(String),
Blocks(Vec<Content>),
}
impl ParsedPrompt {
pub fn has_images(&self) -> bool {
matches!(self, Self::Blocks(blocks) if blocks.iter().any(|b| matches!(b, Content::Image(_))))
}
}
pub fn validate_prompt_shape(command: &Value) -> Result<(), String> {
let object = command
.as_object()
.ok_or("prompt command must be an object")?;
if object.contains_key("images") {
return Err("prompt `images` is unsupported; use ordered `contentBlocks`".into());
}
let Some(value) = object.get("contentBlocks") else {
return object
.get("message")
.or_else(|| object.get("content"))
.or_else(|| object.get("prompt"))
.and_then(Value::as_str)
.map(|_| ())
.ok_or_else(|| "prompt requires a string `message`".into());
};
if ["message", "content", "prompt"]
.iter()
.any(|key| object.contains_key(*key))
{
return Err("contentBlocks cannot be combined with message/content/prompt".into());
}
let blocks = value
.as_array()
.filter(|b| !b.is_empty() && b.len() <= MAX_CONTENT_BLOCKS)
.ok_or("contentBlocks must contain 1..64 blocks")?;
for (index, block) in blocks.iter().enumerate() {
let valid = block.as_object().is_some_and(|fields| {
match fields.get("type").and_then(Value::as_str) {
Some("text") => {
fields.len() == 2 && fields.get("text").is_some_and(Value::is_string)
}
Some("image") => {
fields.len() == 3
&& fields.get("data").is_some_and(Value::is_string)
&& fields.get("mimeType").is_some_and(Value::is_string)
}
_ => false,
}
});
if !valid {
return Err(format!(
"contentBlocks[{index}]: invalid plain text/image block"
));
}
}
Ok(())
}
pub fn parse_prompt_content(command: &Value) -> Result<ParsedPrompt, String> {
#[cfg(test)]
let _worker = validation_test_hook::for_command(command).map(|gate| gate.enter_worker());
let object = command
.as_object()
.ok_or_else(|| "prompt command must be an object".to_string())?;
if object.contains_key("images") {
return Err("prompt `images` is unsupported; use ordered `contentBlocks`".into());
}
let Some(value) = object.get("contentBlocks") else {
return object
.get("message")
.or_else(|| object.get("content"))
.or_else(|| object.get("prompt"))
.and_then(Value::as_str)
.map(|s| ParsedPrompt::Legacy(s.to_owned()))
.ok_or_else(|| "prompt requires a string `message`".to_string());
};
if ["message", "content", "prompt"]
.iter()
.any(|key| object.contains_key(*key))
{
return Err("contentBlocks cannot be combined with message/content/prompt".into());
}
let blocks = value
.as_array()
.filter(|b| !b.is_empty() && b.len() <= MAX_CONTENT_BLOCKS)
.ok_or_else(|| "contentBlocks must contain 1..64 blocks".to_string())?;
validate_command_size(command)?;
let mut text_bytes = 0usize;
let mut image_count = 0usize;
let mut image_bytes = 0usize;
for (index, block) in blocks.iter().enumerate() {
let mut check = || -> Result<(), String> {
let fields = block.as_object().ok_or("block must be an object")?;
match fields.get("type").and_then(Value::as_str) {
Some("text") => {
if fields.len() != 2 || !fields.contains_key("text") {
return Err("text block permits only type and text".into());
}
let text = fields["text"].as_str().ok_or("text must be a string")?;
text_bytes = text_bytes
.checked_add(text.len())
.ok_or("text budget overflow")?;
if text_bytes > MAX_TEXT_BYTES {
return Err("aggregate text exceeds 32768 UTF-8 bytes".into());
}
}
Some("image") => {
if fields.len() != 3
|| !fields.contains_key("data")
|| !fields.contains_key("mimeType")
{
return Err("image block permits only type, data and mimeType".into());
}
let data = fields["data"]
.as_str()
.ok_or("image data must be a string")?;
let mime = fields["mimeType"]
.as_str()
.ok_or("image MIME must be a string")?;
if !matches!(mime, "image/png" | "image/jpeg") {
return Err("image MIME must be image/png or image/jpeg".into());
}
image_count += 1;
if image_count > MAX_IMAGES {
return Err("prompt exceeds 4 images".into());
}
use base64::{engine::general_purpose::STANDARD, Engine};
use rpi_tools::image_processing::{
RPC_IMAGE_MAX_BASE64_BYTES, RPC_IMAGE_MAX_BYTES,
};
if data.is_empty() || data.len() > RPC_IMAGE_MAX_BASE64_BYTES {
return Err(format!(
"image data must contain 1..{} decoded bytes",
RPC_IMAGE_MAX_BYTES
));
}
let bytes = STANDARD
.decode(data)
.map_err(|_| "image data must be canonical standard base64")?;
if bytes.is_empty() || bytes.len() > RPC_IMAGE_MAX_BYTES {
return Err(format!(
"image data must contain 1..{} decoded bytes",
RPC_IMAGE_MAX_BYTES
));
}
if STANDARD.encode(&bytes) != data {
return Err("image data must be canonical standard base64".into());
}
image_bytes = image_bytes
.checked_add(bytes.len())
.ok_or("image budget overflow")?;
if image_bytes > MAX_TOTAL_IMAGE_BYTES {
return Err(format!(
"aggregate image data exceeds {} decoded bytes",
MAX_TOTAL_IMAGE_BYTES
));
}
}
_ => return Err("block type must be text or image".into()),
}
Ok(())
};
check().map_err(|e| format!("contentBlocks[{index}]: {e}"))?;
}
let mut content = Vec::with_capacity(blocks.len());
for (index, block) in blocks.iter().enumerate() {
if block["type"] == "image" {
rpi_tools::image_processing::validate_rpc_inline_image(
block["data"].as_str().expect("validated image data"),
block["mimeType"].as_str().expect("validated image MIME"),
)
.map_err(|e| format!("contentBlocks[{index}]: {e}"))?;
}
content.push(
serde_json::from_value::<Content>(block.clone())
.map_err(|_| format!("contentBlocks[{index}]: invalid plain content block"))?,
);
}
Ok(ParsedPrompt::Blocks(content))
}
fn validate_command_size(command: &Value) -> Result<(), String> {
struct Budget(usize);
impl Write for Budget {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0 = self
.0
.checked_add(bytes.len())
.filter(|n| *n <= MAX_PROMPT_BYTES - 1)
.ok_or_else(|| io::Error::other("prompt frame budget exceeded"))?;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
serde_json::to_writer(Budget(0), command).map_err(|_| {
format!(
"serialized contentBlocks command plus LF exceeds {} bytes",
MAX_PROMPT_BYTES
)
.into()
})
}
pub fn prompt_capabilities() -> Value {
use rpi_tools::image_processing as image;
json!({
"contentBlocksVersion": 1,
"image": true,
"imageMimeTypes": ["image/png", "image/jpeg"],
"maxImages": MAX_IMAGES,
"maxImageBytes": image::RPC_IMAGE_MAX_BYTES,
"maxTotalImageBytes": MAX_TOTAL_IMAGE_BYTES,
"maxImageBase64Bytes": image::RPC_IMAGE_MAX_BASE64_BYTES,
"maxPromptBytes": MAX_PROMPT_BYTES,
"maxTextBytes": MAX_TEXT_BYTES,
"maxContentBlocks": MAX_CONTENT_BLOCKS,
"maxImageSide": image::INLINE_IMAGE_MAX_SIDE,
"maxImagePixels": image::RPC_IMAGE_MAX_PIXELS,
"imageDecodeAllocBudgetBytes": image::RPC_IMAGE_MAX_ALLOC,
"frameBytes": super::protocol::MAX_FRAME_BYTES
})
}
#[cfg(test)]
pub(crate) mod validation_test_hook {
use super::Value;
use std::collections::HashMap;
use std::sync::{Arc, Condvar, Mutex, OnceLock, Weak};
use tokio_util::sync::CancellationToken;
#[derive(Default)]
pub(crate) struct Gate {
pub entered: CancellationToken,
pub worker_finished: CancellationToken,
pub run_finished: CancellationToken,
released: Mutex<bool>,
release_changed: Condvar,
}
fn gates() -> &'static Mutex<HashMap<String, Weak<Gate>>> {
static GATES: OnceLock<Mutex<HashMap<String, Weak<Gate>>>> = OnceLock::new();
GATES.get_or_init(|| Mutex::new(HashMap::new()))
}
impl Gate {
pub fn register(id: &str) -> Arc<Self> {
let gate = Arc::new(Self::default());
let mut gates = gates().lock().unwrap();
gates.retain(|_, gate| gate.strong_count() > 0);
assert!(gates.insert(id.to_owned(), Arc::downgrade(&gate)).is_none());
gate
}
pub fn release(&self) {
*self.released.lock().unwrap() = true;
self.release_changed.notify_all();
}
pub fn enter_worker(self: Arc<Self>) -> WorkerFinished {
self.entered.cancel();
let mut released = self.released.lock().unwrap();
while !*released {
released = self.release_changed.wait(released).unwrap();
}
drop(released);
WorkerFinished(self)
}
}
pub(crate) fn for_command(command: &Value) -> Option<Arc<Gate>> {
let id = command.get("id")?.as_str()?;
gates().lock().unwrap().get(id)?.upgrade()
}
pub(crate) struct WorkerFinished(Arc<Gate>);
impl Drop for WorkerFinished {
fn drop(&mut self) {
self.0.worker_finished.cancel();
}
}
pub(crate) struct RunFinished(pub Arc<Gate>);
impl Drop for RunFinished {
fn drop(&mut self) {
self.0.run_finished.cancel();
}
}
pub(crate) struct ReleaseOnDrop(pub Arc<Gate>);
impl Drop for ReleaseOnDrop {
fn drop(&mut self) {
self.0.release();
}
}
}
#[cfg(test)]
#[path = "prompt_content_tests.rs"]
mod tests;