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}
49
50#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55pub struct ThinkingStatement {
56 #[serde(default)]
60 pub levels: Vec<Effort>,
61 #[serde(default)]
63 pub default: Option<Effort>,
64 #[serde(default)]
66 pub on: Option<bool>,
67 #[serde(default)]
69 pub default_on: Option<bool>,
70}
71
72impl Serialize for ModelFacts {
73 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
74 let absence = Absence::of(&serializer);
75 let mut row = serializer.serialize_struct("ModelFacts", 8)?;
76 row.serialize_field("id", &self.id)?;
77 row.serialize_field("context_tokens", &self.context_tokens)?;
78 row.serialize_field("max_output_tokens", &self.max_output_tokens)?;
79 row.serialize_field("input_modalities", &self.input_modalities)?;
80 row.serialize_field("input_price", &self.input_price)?;
81 row.serialize_field("output_price", &self.output_price)?;
82 absence.option(&mut row, "thinking", &self.thinking)?;
83 absence.option(&mut row, "canonical", &self.canonical)?;
84 row.end()
85 }
86}
87
88impl Serialize for ThinkingStatement {
89 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
90 let absence = Absence::of(&serializer);
91 let mut statement = serializer.serialize_struct("ThinkingStatement", 4)?;
92 absence.list(&mut statement, "levels", &self.levels)?;
93 absence.option(&mut statement, "default", &self.default)?;
94 absence.option(&mut statement, "on", &self.on)?;
95 absence.option(&mut statement, "default_on", &self.default_on)?;
96 statement.end()
97 }
98}
99
100#[derive(Clone, Copy)]
105enum Absence {
106 Omitted,
107 Written,
108}
109
110impl Absence {
111 fn of<S: Serializer>(serializer: &S) -> Absence {
112 if serializer.is_human_readable() {
113 Absence::Omitted
114 } else {
115 Absence::Written
116 }
117 }
118
119 fn option<S: SerializeStruct, T: Serialize>(
120 self,
121 fields: &mut S,
122 key: &'static str,
123 value: &Option<T>,
124 ) -> Result<(), S::Error> {
125 match (self, value) {
126 (Absence::Omitted, None) => fields.skip_field(key),
127 (Absence::Omitted, Some(_)) | (Absence::Written, None | Some(_)) => {
128 fields.serialize_field(key, value)
129 }
130 }
131 }
132
133 fn list<S: SerializeStruct, T: Serialize>(
134 self,
135 fields: &mut S,
136 key: &'static str,
137 value: &[T],
138 ) -> Result<(), S::Error> {
139 match self {
140 Absence::Omitted if value.is_empty() => fields.skip_field(key),
141 Absence::Omitted | Absence::Written => fields.serialize_field(key, value),
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154pub struct EndpointProbed {
155 pub name: String,
156 pub base_url: String,
157 pub reach: Reach,
159 pub models: Vec<String>,
160 pub facts: Vec<ModelFacts>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub failed: Option<ProbeFailure>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169pub struct ProbeFailure {
170 pub code: String,
171 pub subject: String,
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used, reason = "test code")]
176mod tests {
177 use super::*;
178 use crate::event::Payload;
179
180 #[test]
184 fn a_probed_line_keeps_the_bytes_its_ledger_already_holds() {
185 let line = concat!(
186 r#"{"base_url":"http://127.0.0.1:1","facts":[],"#,
187 r#""failed":{"code":"E_PROVIDER","subject":"m"},"models":[],"name":"local","#,
188 r#""reach":{"answered":{"detail":"no socket","state":"unreachable"},"#,
189 r#""connected":{"state":"refused"},"elapsed_ms":3,"host":"127.0.0.1","#,
190 r#""named":{"detail":1,"state":"resolved"},"through":{"state":"direct"}}}"#,
191 );
192 let read: EndpointProbed = serde_json::from_str::<Payload>(line)
193 .unwrap()
194 .read()
195 .unwrap();
196 assert_eq!(
197 serde_json::to_string(&Payload::of(&read).unwrap()).unwrap(),
198 line
199 );
200 }
201
202 #[test]
207 fn a_probed_line_with_thinking_facts_keeps_its_bytes() {
208 let line = concat!(
209 r#"{"base_url":"https://api.example.test/v1","facts":["#,
210 r#"{"context_tokens":null,"id":"m-old","input_modalities":[],"#,
211 r#""input_price":null,"max_output_tokens":null,"output_price":null},"#,
212 r#"{"canonical":"vendor/m-new","context_tokens":200000,"id":"m-new","#,
213 r#""input_modalities":["text"],"input_price":null,"max_output_tokens":null,"#,
214 r#""output_price":null,"thinking":{"default":"medium","levels":["low","medium","high"]}},"#,
215 r#"{"context_tokens":null,"id":"m-switch","input_modalities":[],"#,
216 r#""input_price":null,"max_output_tokens":null,"output_price":null,"#,
217 r#""thinking":{"on":true}}],"#,
218 r#""models":["m-old","m-new","m-switch"],"name":"listed","#,
219 r#""reach":{"answered":{"detail":"no socket","state":"unreachable"},"#,
220 r#""connected":{"state":"refused"},"elapsed_ms":3,"host":"127.0.0.1","#,
221 r#""named":{"detail":1,"state":"resolved"},"through":{"state":"direct"}}}"#,
222 );
223 let read: EndpointProbed = serde_json::from_str::<Payload>(line)
224 .unwrap()
225 .read()
226 .unwrap();
227 assert_eq!(
228 serde_json::to_string(&Payload::of(&read).unwrap()).unwrap(),
229 line
230 );
231 }
232}