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