Skip to main content

starweaver_runtime/output/
types.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use starweaver_model::ModelResponsePart;
4
5use super::{OutputValidationError, OutputValidationResult};
6
7fn typed_schema_name<T>() -> String {
8    let raw = std::any::type_name::<T>()
9        .rsplit("::")
10        .next()
11        .unwrap_or("output");
12    let mut name = String::new();
13    let mut previous_was_separator = false;
14    for character in raw.chars() {
15        if character.is_ascii_alphanumeric() {
16            name.push(character.to_ascii_lowercase());
17            previous_was_separator = false;
18        } else if !previous_was_separator && !name.is_empty() {
19            name.push('_');
20            previous_was_separator = true;
21        }
22    }
23    while name.ends_with('_') {
24        name.pop();
25    }
26    if name.is_empty() {
27        "output".to_string()
28    } else {
29        name
30    }
31}
32
33/// Structured output schema passed to the model and used for runtime parsing.
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub struct OutputSchema {
36    /// Output schema name.
37    pub name: String,
38    /// Output schema description.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub description: Option<String>,
41    /// JSON schema for the final output value.
42    pub schema: Value,
43    /// Whether schema validation should be strict when provider support exists.
44    #[serde(default)]
45    pub strict: bool,
46}
47
48impl OutputSchema {
49    /// Build a named output schema.
50    #[must_use]
51    pub fn new(name: impl Into<String>, schema: Value) -> Self {
52        Self {
53            name: name.into(),
54            description: None,
55            schema,
56            strict: true,
57        }
58    }
59
60    /// Build an output schema from a Rust type.
61    ///
62    /// The generated schema name is derived from the Rust type name and normalized for
63    /// provider request formats. Use [`Self::typed_named`] when the public schema name
64    /// needs to be stable across type renames.
65    #[must_use]
66    pub fn typed<T>() -> Self
67    where
68        T: schemars::JsonSchema,
69    {
70        Self::typed_named::<T>(typed_schema_name::<T>())
71    }
72
73    /// Build a named output schema from a Rust type.
74    #[must_use]
75    pub fn typed_named<T>(name: impl Into<String>) -> Self
76    where
77        T: schemars::JsonSchema,
78    {
79        let schema = schemars::schema_for!(T);
80        let schema = serde_json::to_value(schema).unwrap_or_else(|_| {
81            serde_json::json!({
82                "type": "object",
83                "properties": {},
84            })
85        });
86        Self::new(name, schema)
87    }
88
89    /// Add a description.
90    #[must_use]
91    pub fn with_description(mut self, description: impl Into<String>) -> Self {
92        self.description = Some(description.into());
93        self
94    }
95
96    /// Set strict provider validation preference.
97    #[must_use]
98    pub const fn with_strict(mut self, strict: bool) -> Self {
99        self.strict = strict;
100        self
101    }
102
103    /// Return provider-neutral request schema metadata.
104    #[must_use]
105    pub fn request_schema(&self) -> Value {
106        serde_json::json!({
107            "name": self.name,
108            "description": self.description,
109            "schema": self.schema,
110            "strict": self.strict,
111        })
112    }
113}
114
115/// Media/file output returned by the model.
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117pub struct OutputMedia {
118    /// Output URL or provider resource URI.
119    pub url: String,
120    /// Media type reported by the provider.
121    pub media_type: String,
122}
123
124impl OutputMedia {
125    /// Build an output media wrapper.
126    #[must_use]
127    pub fn new(url: impl Into<String>, media_type: impl Into<String>) -> Self {
128        Self {
129            url: url.into(),
130            media_type: media_type.into(),
131        }
132    }
133
134    /// Return true when this media output is an image.
135    #[must_use]
136    pub fn is_image(&self) -> bool {
137        self.media_type.starts_with("image/")
138    }
139
140    /// Return true when this media output is a video.
141    #[must_use]
142    pub fn is_video(&self) -> bool {
143        self.media_type.starts_with("video/")
144    }
145
146    /// Return true when this media output is audio.
147    #[must_use]
148    pub fn is_audio(&self) -> bool {
149        self.media_type.starts_with("audio/")
150    }
151
152    /// Return true when this media output looks like a document or generic file.
153    #[must_use]
154    pub fn is_file(&self) -> bool {
155        !self.is_image() && !self.is_video() && !self.is_audio()
156    }
157
158    /// Build an output media wrapper from a canonical model response part.
159    #[must_use]
160    pub fn from_response_part(part: &ModelResponsePart) -> Option<Self> {
161        match part {
162            ModelResponsePart::File { url, media_type } => Some(Self::new(url, media_type)),
163            ModelResponsePart::Text { .. }
164            | ModelResponsePart::ProviderText { .. }
165            | ModelResponsePart::Thinking { .. }
166            | ModelResponsePart::ProviderThinking { .. }
167            | ModelResponsePart::ToolCall(_)
168            | ModelResponsePart::ProviderToolCall { .. }
169            | ModelResponsePart::NativeToolCall { .. }
170            | ModelResponsePart::NativeToolReturn { .. }
171            | ModelResponsePart::Compaction { .. }
172            | ModelResponsePart::ProviderOpaque { .. } => None,
173        }
174    }
175}
176
177/// Parsed final output value.
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179pub enum OutputValue {
180    /// Plain text output.
181    Text(String),
182    /// Structured JSON output.
183    Json(Value),
184    /// Media/file outputs.
185    Media(Vec<OutputMedia>),
186}
187
188impl OutputValue {
189    /// Return text output, serializing JSON when needed.
190    #[must_use]
191    pub fn as_text(&self) -> String {
192        match self {
193            Self::Text(text) => text.clone(),
194            Self::Json(value) => value.to_string(),
195            Self::Media(media) => serde_json::to_string(media).unwrap_or_else(|_| "[]".to_string()),
196        }
197    }
198
199    /// Return JSON output when this value is structured.
200    #[must_use]
201    pub const fn as_json(&self) -> Option<&Value> {
202        match self {
203            Self::Json(value) => Some(value),
204            Self::Text(_) | Self::Media(_) => None,
205        }
206    }
207
208    /// Parse this output value into a Rust type.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error when text is not valid JSON or deserialization fails.
213    pub fn parse<T>(&self) -> OutputValidationResult<T>
214    where
215        T: serde::de::DeserializeOwned,
216    {
217        let value = match self {
218            Self::Text(text) => serde_json::from_str(text)
219                .map_err(|error| OutputValidationError::InvalidJson(error.to_string()))?,
220            Self::Json(value) => value.clone(),
221            Self::Media(media) => serde_json::to_value(media)
222                .map_err(|error| OutputValidationError::Schema(error.to_string()))?,
223        };
224        serde_json::from_value(value)
225            .map_err(|error| OutputValidationError::Schema(error.to_string()))
226    }
227}