1use serde::{Deserialize, Serialize};
5
6use crate::definition::{Determinism, Operation};
7use crate::ids::{ArtifactId, DescriptorId};
8use crate::limits::DESCRIPTOR_V1;
9use crate::schema;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12#[serde(deny_unknown_fields)]
13pub struct BackendDescriptor {
14 pub schema: String,
15 pub backend_id: String,
16 pub artifact: ArtifactId,
17 pub operations: Vec<OperationSupport>,
18 pub input_limit: InputLimit,
19 pub determinism: Determinism,
20}
21
22crate::document::document!(
23 BackendDescriptor,
24 schema::BACKEND,
25 DESCRIPTOR_V1,
26 DescriptorId
27);
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
31#[serde(try_from = "OperationSupportWire")]
32pub struct OperationSupport {
33 pub operation: Operation,
34 pub output: OutputKind,
35 pub max_options: u64,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub isolation: Option<Isolation>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub screen: Option<ScreenProfile>,
41}
42
43#[derive(
45 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
46)]
47#[serde(rename_all = "snake_case", deny_unknown_fields)]
48pub enum OutputKind {
49 Label,
50 Scores,
51 Distribution,
52 Logits,
53 Screen,
54 TypedRecord {
55 structure: TextStructure,
56 #[serde(deserialize_with = "crate::generation::bound::<_, { u64::MAX }>")]
57 max_output_bytes: u64,
58 #[serde(deserialize_with = "crate::generation::bound::<_, 32>")]
59 max_options: u64,
60 option_logprobs: bool,
61 },
62 TextRecord {
63 structure: TextStructure,
64 max_output_bytes: u64,
65 languages: Vec<String>,
66 streaming: bool,
67 },
68}
69
70#[derive(
71 Debug,
72 Clone,
73 Copy,
74 PartialEq,
75 Eq,
76 PartialOrd,
77 Ord,
78 Hash,
79 Serialize,
80 Deserialize,
81 schemars::JsonSchema,
82)]
83#[serde(rename_all = "snake_case")]
84pub enum TextStructure {
85 JsonSchema,
86 ToolCall,
87 Grammar,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
91#[serde(deny_unknown_fields)]
92pub struct InputLimit {
93 pub max_bytes: u64,
95 pub on_excess: InputExcess,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
99#[serde(rename_all = "snake_case")]
100pub enum InputExcess {
101 Refuse,
102 TruncateStart,
103 TruncateEnd,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
108#[serde(rename_all = "snake_case")]
109pub enum Isolation {
110 PerItem,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
114#[serde(deny_unknown_fields)]
115pub struct ScreenProfile {
116 pub policy_version: String,
117 pub categories: Vec<String>,
118 pub max_text_bytes: u64,
119 pub text_unit_bytes: u64,
120}
121
122#[derive(Deserialize, schemars::JsonSchema)]
123#[serde(deny_unknown_fields)]
124#[schemars(rename = "OperationSupport")]
125struct OperationSupportWire {
126 operation: Operation,
127 output: OutputKind,
128 max_options: u64,
130 #[serde(default, deserialize_with = "present")]
133 #[schemars(with = "Isolation")]
134 isolation: Option<Isolation>,
135 #[serde(default, deserialize_with = "present")]
136 #[schemars(with = "ScreenProfile")]
137 screen: Option<ScreenProfile>,
138}
139
140fn present<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
142 deserializer: D,
143) -> Result<Option<T>, D::Error> {
144 T::deserialize(deserializer).map(Some)
145}
146
147impl TryFrom<OperationSupportWire> for OperationSupport {
148 type Error = String;
149
150 fn try_from(w: OperationSupportWire) -> Result<Self, Self::Error> {
151 if (w.output == OutputKind::Screen) != w.screen.is_some() {
152 return Err("screen output requires exactly a screen profile".into());
153 }
154 if let Some(p) = &w.screen
155 && (w.operation != Operation::Proposition
156 || p.policy_version.is_empty()
157 || p.categories.is_empty()
158 || p.categories.len() > 64
159 || p.categories.iter().any(String::is_empty)
160 || p.categories.windows(2).any(|w| w[0] >= w[1])
161 || p.max_text_bytes == 0
162 || p.text_unit_bytes == 0)
163 {
164 return Err("invalid proposition screen profile".into());
165 }
166 if let OutputKind::TextRecord {
167 max_output_bytes,
168 languages,
169 ..
170 } = &w.output
171 {
172 if w.operation != Operation::Generate
173 || *max_output_bytes == 0
174 || w.max_options == 0
175 || languages.is_empty()
176 || languages.iter().any(|s| !language_tag(s))
177 || languages
178 .iter()
179 .map(|s| s.to_ascii_lowercase())
180 .collect::<std::collections::BTreeSet<_>>()
181 .len()
182 != languages.len()
183 {
184 return Err("invalid generate text_record capability".into());
185 }
186 } else if w.operation == Operation::Generate {
187 return Err("generate requires text_record output".into());
188 }
189 if matches!(w.output, OutputKind::TypedRecord { .. }) != (w.operation == Operation::Extract)
190 {
191 return Err("extract requires typed_record output exclusively".into());
192 }
193 Ok(Self {
194 operation: w.operation,
195 output: w.output,
196 max_options: w.max_options,
197 isolation: w.isolation,
198 screen: w.screen,
199 })
200 }
201}
202
203impl OperationSupport {
204 pub fn satisfies_screen_verdict(&self) -> bool {
206 self.operation == Operation::Proposition
207 && self.output == OutputKind::Screen
208 && self.screen.is_some()
209 }
210}
211
212fn language_tag(s: &str) -> bool {
215 let lower = s.to_ascii_lowercase();
216 if [
217 "en-gb-oed",
218 "i-ami",
219 "i-bnn",
220 "i-default",
221 "i-enochian",
222 "i-hak",
223 "i-klingon",
224 "i-lux",
225 "i-mingo",
226 "i-navajo",
227 "i-pwn",
228 "i-tao",
229 "i-tay",
230 "i-tsu",
231 "sgn-be-fr",
232 "sgn-be-nl",
233 "sgn-ch-de",
234 ]
235 .contains(&lower.as_str())
236 {
237 return true;
238 }
239 let parts: Vec<_> = lower.split('-').collect();
240 let alpha = |p: &str| p.bytes().all(|b| b.is_ascii_alphabetic());
241 let alnum = |p: &str| p.bytes().all(|b| b.is_ascii_alphanumeric());
242 if parts
243 .iter()
244 .any(|p| p.is_empty() || p.len() > 8 || !alnum(p))
245 {
246 return false;
247 }
248 if parts[0] == "x" {
249 return parts.len() > 1;
250 }
251 if !(2..=8).contains(&parts[0].len()) || !alpha(parts[0]) {
252 return false;
253 }
254 let mut i = 1;
255 if parts[0].len() <= 3 {
256 for _ in 0..3 {
257 if i < parts.len() && parts[i].len() == 3 && alpha(parts[i]) {
258 i += 1;
259 } else {
260 break;
261 }
262 }
263 }
264 if i < parts.len() && parts[i].len() == 4 && alpha(parts[i]) {
265 i += 1;
266 }
267 if i < parts.len()
268 && ((parts[i].len() == 2 && alpha(parts[i]))
269 || (parts[i].len() == 3 && parts[i].bytes().all(|b| b.is_ascii_digit())))
270 {
271 i += 1;
272 }
273 let mut variants = std::collections::BTreeSet::new();
274 while i < parts.len()
275 && (parts[i].len() >= 5 || (parts[i].len() == 4 && parts[i].as_bytes()[0].is_ascii_digit()))
276 {
277 if !variants.insert(parts[i]) {
278 return false;
279 }
280 i += 1;
281 }
282 let mut extensions = std::collections::BTreeSet::new();
283 while i < parts.len() && parts[i].len() == 1 && parts[i] != "x" {
284 if !extensions.insert(parts[i]) {
285 return false;
286 }
287 i += 1;
288 let start = i;
289 while i < parts.len() && parts[i].len() >= 2 {
290 i += 1;
291 }
292 if start == i {
293 return false;
294 }
295 }
296 if i < parts.len() && parts[i] == "x" {
297 return i + 1 < parts.len();
298 }
299 i == parts.len()
300}