starweaver_runtime/output/
types.rs1use 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub struct OutputSchema {
36 pub name: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub description: Option<String>,
41 pub schema: Value,
43 #[serde(default)]
45 pub strict: bool,
46}
47
48impl OutputSchema {
49 #[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 #[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 #[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 #[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 #[must_use]
98 pub const fn with_strict(mut self, strict: bool) -> Self {
99 self.strict = strict;
100 self
101 }
102
103 #[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117pub struct OutputMedia {
118 pub url: String,
120 pub media_type: String,
122}
123
124impl OutputMedia {
125 #[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 #[must_use]
136 pub fn is_image(&self) -> bool {
137 self.media_type.starts_with("image/")
138 }
139
140 #[must_use]
142 pub fn is_video(&self) -> bool {
143 self.media_type.starts_with("video/")
144 }
145
146 #[must_use]
148 pub fn is_audio(&self) -> bool {
149 self.media_type.starts_with("audio/")
150 }
151
152 #[must_use]
154 pub fn is_file(&self) -> bool {
155 !self.is_image() && !self.is_video() && !self.is_audio()
156 }
157
158 #[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179pub enum OutputValue {
180 Text(String),
182 Json(Value),
184 Media(Vec<OutputMedia>),
186}
187
188impl OutputValue {
189 #[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 #[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 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}