1use serde::ser::SerializeStruct;
10use serde::{Deserialize, Serialize, Serializer};
11
12use crate::model::{Ceiling, Effort};
13use crate::reach::Reach;
14
15#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24pub struct ModelFacts {
25 pub id: String,
26 pub context_tokens: Option<u64>,
28 pub max_output_tokens: Option<Ceiling>,
30 pub input_modalities: Vec<String>,
33 pub input_price: Option<String>,
36 pub output_price: Option<String>,
38 #[serde(default)]
43 pub thinking: Option<ThinkingStatement>,
44 #[serde(default)]
47 pub canonical: Option<String>,
48 #[serde(default)]
53 pub listing: Listing,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60pub enum Listing {
61 #[default]
63 Listed,
64 Named,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74pub struct ThinkingStatement {
75 #[serde(default)]
79 pub levels: Vec<Effort>,
80 #[serde(default)]
82 pub default: Option<Effort>,
83 #[serde(default)]
85 pub on: Option<bool>,
86 #[serde(default)]
88 pub default_on: Option<bool>,
89}
90
91impl Serialize for ModelFacts {
92 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
93 let absence = Absence::of(&serializer);
94 let mut row = serializer.serialize_struct("ModelFacts", 9)?;
95 row.serialize_field("id", &self.id)?;
96 row.serialize_field("context_tokens", &self.context_tokens)?;
97 row.serialize_field("max_output_tokens", &self.max_output_tokens)?;
98 row.serialize_field("input_modalities", &self.input_modalities)?;
99 row.serialize_field("input_price", &self.input_price)?;
100 row.serialize_field("output_price", &self.output_price)?;
101 absence.option(&mut row, "thinking", &self.thinking)?;
102 absence.option(&mut row, "canonical", &self.canonical)?;
103 absence.listing(&mut row, self.listing)?;
104 row.end()
105 }
106}
107
108impl Serialize for ThinkingStatement {
109 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
110 let absence = Absence::of(&serializer);
111 let mut statement = serializer.serialize_struct("ThinkingStatement", 4)?;
112 absence.list(&mut statement, "levels", &self.levels)?;
113 absence.option(&mut statement, "default", &self.default)?;
114 absence.option(&mut statement, "on", &self.on)?;
115 absence.option(&mut statement, "default_on", &self.default_on)?;
116 statement.end()
117 }
118}
119
120#[derive(Clone, Copy)]
125enum Absence {
126 Omitted,
127 Written,
128}
129
130impl Absence {
131 fn of<S: Serializer>(serializer: &S) -> Absence {
132 if serializer.is_human_readable() {
133 Absence::Omitted
134 } else {
135 Absence::Written
136 }
137 }
138
139 fn option<S: SerializeStruct, T: Serialize>(
140 self,
141 fields: &mut S,
142 key: &'static str,
143 value: &Option<T>,
144 ) -> Result<(), S::Error> {
145 match (self, value) {
146 (Absence::Omitted, None) => fields.skip_field(key),
147 (Absence::Omitted, Some(_)) | (Absence::Written, None | Some(_)) => {
148 fields.serialize_field(key, value)
149 }
150 }
151 }
152
153 fn listing<S: SerializeStruct>(self, fields: &mut S, value: Listing) -> Result<(), S::Error> {
157 match (self, value) {
158 (Absence::Omitted, Listing::Listed) => fields.skip_field("listing"),
159 (Absence::Omitted, Listing::Named)
160 | (Absence::Written, Listing::Listed | Listing::Named) => {
161 fields.serialize_field("listing", &value)
162 }
163 }
164 }
165
166 fn list<S: SerializeStruct, T: Serialize>(
167 self,
168 fields: &mut S,
169 key: &'static str,
170 value: &[T],
171 ) -> Result<(), S::Error> {
172 match self {
173 Absence::Omitted if value.is_empty() => fields.skip_field(key),
174 Absence::Omitted | Absence::Written => fields.serialize_field(key, value),
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
187pub struct EndpointProbed {
188 pub name: String,
189 pub base_url: String,
190 pub reach: Reach,
192 pub models: Vec<String>,
193 pub facts: Vec<ModelFacts>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub failed: Option<ProbeFailure>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
202pub struct ProbeFailure {
203 pub code: String,
204 pub subject: String,
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used, reason = "test code")]
209mod tests {
210 use super::*;
211 use crate::event::Payload;
212
213 #[test]
217 fn a_probed_line_keeps_the_bytes_its_ledger_already_holds() {
218 let line = concat!(
219 r#"{"base_url":"http://127.0.0.1:1","facts":[],"#,
220 r#""failed":{"code":"E_PROVIDER","subject":"m"},"models":[],"name":"local","#,
221 r#""reach":{"answered":{"detail":"no socket","state":"unreachable"},"#,
222 r#""connected":{"state":"refused"},"elapsed_ms":3,"host":"127.0.0.1","#,
223 r#""named":{"detail":1,"state":"resolved"},"through":{"state":"direct"}}}"#,
224 );
225 let read: EndpointProbed = serde_json::from_str::<Payload>(line)
226 .unwrap()
227 .read()
228 .unwrap();
229 assert_eq!(
230 serde_json::to_string(&Payload::of(&read).unwrap()).unwrap(),
231 line
232 );
233 }
234
235 #[test]
240 fn a_probed_line_with_thinking_facts_keeps_its_bytes() {
241 let line = concat!(
242 r#"{"base_url":"https://api.example.test/v1","facts":["#,
243 r#"{"context_tokens":null,"id":"m-old","input_modalities":[],"#,
244 r#""input_price":null,"max_output_tokens":null,"output_price":null},"#,
245 r#"{"canonical":"vendor/m-new","context_tokens":200000,"id":"m-new","#,
246 r#""input_modalities":["text"],"input_price":null,"max_output_tokens":null,"#,
247 r#""output_price":null,"thinking":{"default":"medium","levels":["low","medium","high"]}},"#,
248 r#"{"context_tokens":null,"id":"m-switch","input_modalities":[],"#,
249 r#""input_price":null,"max_output_tokens":null,"output_price":null,"#,
250 r#""thinking":{"on":true}}],"#,
251 r#""models":["m-old","m-new","m-switch"],"name":"listed","#,
252 r#""reach":{"answered":{"detail":"no socket","state":"unreachable"},"#,
253 r#""connected":{"state":"refused"},"elapsed_ms":3,"host":"127.0.0.1","#,
254 r#""named":{"detail":1,"state":"resolved"},"through":{"state":"direct"}}}"#,
255 );
256 let read: EndpointProbed = serde_json::from_str::<Payload>(line)
257 .unwrap()
258 .read()
259 .unwrap();
260 assert_eq!(
261 serde_json::to_string(&Payload::of(&read).unwrap()).unwrap(),
262 line
263 );
264 }
265}