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 #[serde(
22 default,
23 skip_serializing_if = "Option::is_none",
24 deserialize_with = "present"
25 )]
26 #[schemars(with = "Ranking")]
27 pub ranking: Option<Ranking>,
28}
29
30crate::document::document!(
31 BackendDescriptor,
32 schema::BACKEND,
33 DESCRIPTOR_V1,
34 DescriptorId
35);
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
39#[serde(try_from = "OperationSupportWire")]
40pub struct OperationSupport {
41 pub operation: Operation,
42 pub output: OutputKind,
43 pub max_options: u64,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub isolation: Option<Isolation>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub screen: Option<ScreenProfile>,
49}
50
51#[derive(
53 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
54)]
55#[serde(rename_all = "snake_case", deny_unknown_fields)]
56pub enum OutputKind {
57 Ranking,
58 Label,
59 Scores,
60 Distribution,
61 Logits,
62 Screen,
63 TypedRecord {
64 structure: TextStructure,
65 #[serde(deserialize_with = "crate::generation::bound::<_, { u64::MAX }>")]
66 max_output_bytes: u64,
67 #[serde(deserialize_with = "crate::generation::bound::<_, 32>")]
68 max_options: u64,
69 option_logprobs: bool,
70 },
71 TextRecord {
72 structure: TextStructure,
73 max_output_bytes: u64,
74 languages: Vec<String>,
75 streaming: bool,
76 },
77}
78
79#[derive(
80 Debug,
81 Clone,
82 Copy,
83 PartialEq,
84 Eq,
85 PartialOrd,
86 Ord,
87 Hash,
88 Serialize,
89 Deserialize,
90 schemars::JsonSchema,
91)]
92#[serde(rename_all = "snake_case")]
93pub enum TextStructure {
94 JsonSchema,
95 ToolCall,
96 Grammar,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
100#[serde(deny_unknown_fields)]
101pub struct InputLimit {
102 pub max_bytes: u64,
104 pub on_excess: InputExcess,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
108#[serde(rename_all = "snake_case")]
109pub enum InputExcess {
110 Refuse,
111 TruncateStart,
112 TruncateEnd,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
117#[serde(rename_all = "snake_case")]
118pub enum Isolation {
119 PerItem,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
123#[serde(deny_unknown_fields)]
124pub struct ScreenProfile {
125 pub policy_version: String,
126 pub categories: Vec<String>,
127 pub max_text_bytes: u64,
128 pub text_unit_bytes: u64,
129}
130
131#[derive(Deserialize, schemars::JsonSchema)]
132#[serde(deny_unknown_fields)]
133#[schemars(rename = "OperationSupport")]
134struct OperationSupportWire {
135 operation: Operation,
136 output: OutputKind,
137 max_options: u64,
139 #[serde(default, deserialize_with = "present")]
142 #[schemars(with = "Isolation")]
143 isolation: Option<Isolation>,
144 #[serde(default, deserialize_with = "present")]
145 #[schemars(with = "ScreenProfile")]
146 screen: Option<ScreenProfile>,
147}
148
149fn present<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
151 deserializer: D,
152) -> Result<Option<T>, D::Error> {
153 T::deserialize(deserializer).map(Some)
154}
155
156impl TryFrom<OperationSupportWire> for OperationSupport {
157 type Error = String;
158
159 fn try_from(w: OperationSupportWire) -> Result<Self, Self::Error> {
160 if (w.output == OutputKind::Screen) != w.screen.is_some() {
161 return Err("screen output requires exactly a screen profile".into());
162 }
163 if let Some(p) = &w.screen
164 && (w.operation != Operation::Proposition
165 || p.policy_version.is_empty()
166 || p.categories.is_empty()
167 || p.categories.len() > 64
168 || p.categories.iter().any(String::is_empty)
169 || p.categories.windows(2).any(|w| w[0] >= w[1])
170 || p.max_text_bytes == 0
171 || p.text_unit_bytes == 0)
172 {
173 return Err("invalid proposition screen profile".into());
174 }
175 if let OutputKind::TextRecord {
176 max_output_bytes,
177 languages,
178 ..
179 } = &w.output
180 {
181 if w.operation != Operation::Generate
182 || *max_output_bytes == 0
183 || w.max_options == 0
184 || languages.is_empty()
185 || languages.iter().any(|s| !language_tag(s))
186 || languages
187 .iter()
188 .map(|s| s.to_ascii_lowercase())
189 .collect::<std::collections::BTreeSet<_>>()
190 .len()
191 != languages.len()
192 {
193 return Err("invalid generate text_record capability".into());
194 }
195 } else if w.operation == Operation::Generate {
196 return Err("generate requires text_record output".into());
197 }
198 if matches!(w.output, OutputKind::TypedRecord { .. }) != (w.operation == Operation::Extract)
199 {
200 return Err("extract requires typed_record output exclusively".into());
201 }
202 Ok(Self {
203 operation: w.operation,
204 output: w.output,
205 max_options: w.max_options,
206 isolation: w.isolation,
207 screen: w.screen,
208 })
209 }
210}
211
212impl OperationSupport {
213 pub fn satisfies_screen_verdict(&self) -> bool {
215 self.operation == Operation::Proposition
216 && self.output == OutputKind::Screen
217 && self.screen.is_some()
218 }
219}
220
221fn language_tag(s: &str) -> bool {
224 let lower = s.to_ascii_lowercase();
225 if [
226 "en-gb-oed",
227 "i-ami",
228 "i-bnn",
229 "i-default",
230 "i-enochian",
231 "i-hak",
232 "i-klingon",
233 "i-lux",
234 "i-mingo",
235 "i-navajo",
236 "i-pwn",
237 "i-tao",
238 "i-tay",
239 "i-tsu",
240 "sgn-be-fr",
241 "sgn-be-nl",
242 "sgn-ch-de",
243 ]
244 .contains(&lower.as_str())
245 {
246 return true;
247 }
248 let parts: Vec<_> = lower.split('-').collect();
249 let alpha = |p: &str| p.bytes().all(|b| b.is_ascii_alphabetic());
250 let alnum = |p: &str| p.bytes().all(|b| b.is_ascii_alphanumeric());
251 if parts
252 .iter()
253 .any(|p| p.is_empty() || p.len() > 8 || !alnum(p))
254 {
255 return false;
256 }
257 if parts[0] == "x" {
258 return parts.len() > 1;
259 }
260 if !(2..=8).contains(&parts[0].len()) || !alpha(parts[0]) {
261 return false;
262 }
263 let mut i = 1;
264 if parts[0].len() <= 3 {
265 for _ in 0..3 {
266 if i < parts.len() && parts[i].len() == 3 && alpha(parts[i]) {
267 i += 1;
268 } else {
269 break;
270 }
271 }
272 }
273 if i < parts.len() && parts[i].len() == 4 && alpha(parts[i]) {
274 i += 1;
275 }
276 if i < parts.len()
277 && ((parts[i].len() == 2 && alpha(parts[i]))
278 || (parts[i].len() == 3 && parts[i].bytes().all(|b| b.is_ascii_digit())))
279 {
280 i += 1;
281 }
282 let mut variants = std::collections::BTreeSet::new();
283 while i < parts.len()
284 && (parts[i].len() >= 5 || (parts[i].len() == 4 && parts[i].as_bytes()[0].is_ascii_digit()))
285 {
286 if !variants.insert(parts[i]) {
287 return false;
288 }
289 i += 1;
290 }
291 let mut extensions = std::collections::BTreeSet::new();
292 while i < parts.len() && parts[i].len() == 1 && parts[i] != "x" {
293 if !extensions.insert(parts[i]) {
294 return false;
295 }
296 i += 1;
297 let start = i;
298 while i < parts.len() && parts[i].len() >= 2 {
299 i += 1;
300 }
301 if start == i {
302 return false;
303 }
304 }
305 if i < parts.len() && parts[i] == "x" {
306 return i + 1 < parts.len();
307 }
308 i == parts.len()
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
313#[serde(deny_unknown_fields)]
314pub struct Ranking {
315 #[serde(deserialize_with = "ranking_modes")]
316 pub modes: Vec<RankingMode>,
317 #[serde(deserialize_with = "crate::ranking::bound::<_, 1, 1024>")]
318 pub max_candidates: u64,
319 pub listwise_scores: ListwiseScores,
320 pub listwise_partial: ListwisePartial,
321}
322#[derive(
323 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
324)]
325#[serde(rename_all = "snake_case")]
326pub enum RankingMode {
327 Listwise,
328 Pointwise,
329 Pairwise,
330}
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
332#[serde(rename_all = "snake_case")]
333pub enum ListwiseScores {
334 Never,
335 Always,
336 Optional,
337}
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
339#[serde(rename_all = "snake_case")]
340pub enum ListwisePartial {
341 Possible,
342 Never,
343}
344fn ranking_modes<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<RankingMode>, D::Error> {
345 let modes = Vec::<RankingMode>::deserialize(d)?;
346 if modes.is_empty() || modes.windows(2).any(|w| w[0] >= w[1]) {
347 return Err(serde::de::Error::custom(
348 "ranking modes must be nonempty, ordered and unique",
349 ));
350 }
351 Ok(modes)
352}