1use serde::de::DeserializeOwned;
11use serde::{Deserialize, Deserializer, Serialize};
12use serde_json::Value;
13
14use crate::budget::UsdMicros;
15use crate::model::{Ceiling, DialectKind, ModelTag};
16use crate::reach::Proxying;
17use crate::secret::SecretRef;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub struct EndpointAttached {
27 pub name: String,
28 pub base_url: String,
29 pub dialect: DialectKind,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
33 pub auth: Option<SecretRef>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub auth_header: Option<String>,
37 pub models: Vec<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub connection_kind: Option<String>,
42 #[serde(default = "probed_by_default")]
45 pub probed: bool,
46 #[serde(
49 default,
50 skip_serializing_if = "Option::is_none",
51 deserialize_with = "readable"
52 )]
53 pub tuning: Option<AttachedTuning>,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65pub struct AttachedTuning {
66 #[serde(
67 default,
68 skip_serializing_if = "Option::is_none",
69 deserialize_with = "readable"
70 )]
71 pub label: Option<String>,
72 #[serde(
73 default,
74 skip_serializing_if = "Option::is_none",
75 deserialize_with = "readable"
76 )]
77 pub timeout_ms: Option<u64>,
78 #[serde(
79 default,
80 skip_serializing_if = "Option::is_none",
81 deserialize_with = "readable"
82 )]
83 pub stream_idle_timeout_ms: Option<u64>,
84 #[serde(
85 default,
86 skip_serializing_if = "Option::is_none",
87 deserialize_with = "readable"
88 )]
89 pub request_max_retries: Option<u32>,
90 #[serde(
93 default,
94 skip_serializing_if = "Option::is_none",
95 deserialize_with = "readable"
96 )]
97 pub proxying: Option<Proxying>,
98 #[serde(
101 default,
102 skip_serializing_if = "Option::is_none",
103 deserialize_with = "readable"
104 )]
105 pub max_in_flight: Option<u32>,
106 #[serde(
109 default,
110 skip_serializing_if = "Vec::is_empty",
111 deserialize_with = "readable_pairs"
112 )]
113 pub extra_headers: Vec<(String, String)>,
114 #[serde(
115 default,
116 skip_serializing_if = "Vec::is_empty",
117 deserialize_with = "readable_pairs"
118 )]
119 pub overrides: Vec<(String, String)>,
120}
121
122const fn probed_by_default() -> bool {
123 true
124}
125
126fn readable<'de, D: Deserializer<'de>, T: DeserializeOwned>(
129 held: D,
130) -> Result<Option<T>, D::Error> {
131 Ok(serde_json::from_value(Value::deserialize(held)?).ok())
132}
133
134fn readable_pairs<'de, D: Deserializer<'de>>(held: D) -> Result<Vec<(String, String)>, D::Error> {
137 let Value::Array(rows) = Value::deserialize(held)? else {
138 return Ok(Vec::new());
139 };
140 Ok(rows
141 .into_iter()
142 .filter_map(|row| match row {
143 Value::Array(cells) => match (cells.first(), cells.get(1)) {
144 (Some(Value::String(name)), Some(Value::String(value))) => {
145 Some((name.clone(), value.clone()))
146 }
147 _ => None,
148 },
149 Value::Null
150 | Value::Bool(_)
151 | Value::Number(_)
152 | Value::String(_)
153 | Value::Object(_) => None,
154 })
155 .collect())
156}
157
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167pub enum InputKinds {
168 #[default]
169 Text,
170 TextImage,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
177pub struct ModelSelected {
178 pub tag: ModelTag,
179 pub endpoint: String,
181 pub model: String,
183 pub context_tokens: u64,
185 #[serde(default)]
189 pub max_output_tokens: Option<u64>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub ceiling_from: Option<String>,
195 #[serde(default)]
198 pub input: InputKinds,
199 pub input_price: UsdMicros,
200 pub output_price: UsdMicros,
201 pub cache_read_price: UsdMicros,
202 pub cache_write_price: UsdMicros,
203}
204
205impl ModelSelected {
206 #[must_use]
212 pub fn ceiling(&self) -> Option<Ceiling> {
213 self.max_output_tokens.and_then(Ceiling::new)
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
221pub struct EndpointLost {
222 pub name: String,
223}
224
225#[cfg(test)]
226#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
227mod tests {
228 use super::*;
229 use crate::event::Payload;
230
231 fn selected(max_output_tokens: Option<u64>, ceiling_from: Option<&str>) -> ModelSelected {
232 ModelSelected {
233 tag: ModelTag::Main,
234 endpoint: "house".to_owned(),
235 model: "opus-nine".to_owned(),
236 context_tokens: 200_000,
237 max_output_tokens,
238 ceiling_from: ceiling_from.map(str::to_owned),
239 input: InputKinds::TextImage,
240 input_price: UsdMicros::new(3_000_000),
241 output_price: UsdMicros::new(15_000_000),
242 cache_read_price: UsdMicros::new(300_000),
243 cache_write_price: UsdMicros::new(3_750_000),
244 }
245 }
246
247 #[test]
251 fn each_endpoint_line_keeps_the_bytes_its_ledger_already_holds() {
252 let cases: [(Payload, &str); 5] = [
253 (
254 Payload::of(&EndpointAttached {
255 name: "house".to_owned(),
256 base_url: "https://api.example.test/v1".to_owned(),
257 dialect: DialectKind::OpenAi,
258 auth: Some(SecretRef::parse("secret:house/key").unwrap()),
259 auth_header: Some("x-api-key".to_owned()),
260 models: vec!["opus-nine".to_owned()],
261 connection_kind: Some("openai_compat".to_owned()),
262 probed: false,
263 tuning: Some(AttachedTuning {
264 label: Some("House".to_owned()),
265 timeout_ms: Some(30_000),
266 request_max_retries: Some(2),
267 proxying: Some(Proxying::Never),
268 extra_headers: vec![("x-team".to_owned(), "secret:house/team".to_owned())],
269 ..AttachedTuning::default()
270 }),
271 })
272 .unwrap(),
273 "{\"auth\":\"secret:house/key\",\"auth_header\":\"x-api-key\",\"base_url\":\"https://api.example.test/v1\",\"connection_kind\":\"openai_compat\",\"dialect\":\"open_ai\",\"models\":[\"opus-nine\"],\"name\":\"house\",\"probed\":false,\"tuning\":{\"extra_headers\":[[\"x-team\",\"secret:house/team\"]],\"label\":\"House\",\"proxying\":\"never\",\"request_max_retries\":2,\"timeout_ms\":30000}}",
274 ),
275 (
276 Payload::of(&EndpointAttached {
277 name: "local".to_owned(),
278 base_url: "http://127.0.0.1:11434/v1".to_owned(),
279 dialect: DialectKind::OpenAi,
280 auth: None,
281 auth_header: None,
282 models: Vec::new(),
283 connection_kind: Some("openai_compat".to_owned()),
284 probed: true,
285 tuning: None,
286 })
287 .unwrap(),
288 "{\"base_url\":\"http://127.0.0.1:11434/v1\",\"connection_kind\":\"openai_compat\",\"dialect\":\"open_ai\",\"models\":[],\"name\":\"local\",\"probed\":true}",
289 ),
290 (
291 Payload::of(&selected(Some(32_000), Some("preset"))).unwrap(),
292 "{\"cache_read_price\":300000,\"cache_write_price\":3750000,\"ceiling_from\":\"preset\",\"context_tokens\":200000,\"endpoint\":\"house\",\"input\":\"text_image\",\"input_price\":3000000,\"max_output_tokens\":32000,\"model\":\"opus-nine\",\"output_price\":15000000,\"tag\":\"main\"}",
293 ),
294 (
295 Payload::of(&selected(None, None)).unwrap(),
296 "{\"cache_read_price\":300000,\"cache_write_price\":3750000,\"context_tokens\":200000,\"endpoint\":\"house\",\"input\":\"text_image\",\"input_price\":3000000,\"max_output_tokens\":null,\"model\":\"opus-nine\",\"output_price\":15000000,\"tag\":\"main\"}",
297 ),
298 (
299 Payload::of(&EndpointLost {
300 name: "house".to_owned(),
301 })
302 .unwrap(),
303 "{\"name\":\"house\"}",
304 ),
305 ];
306 for (payload, wire) in cases {
307 assert_eq!(serde_json::to_string(&payload).unwrap(), wire);
308 let back: Payload = serde_json::from_str(wire).unwrap();
309 assert_eq!(back, payload);
310 }
311 }
312
313 #[test]
316 fn an_unreadable_tuning_key_is_left_out_rather_than_refusing_the_line() {
317 let payload: Payload = serde_json::from_str(
318 "{\"base_url\":\"u\",\"dialect\":\"anthropic\",\"models\":[],\"name\":\"n\",\"tuning\":{\"timeout_ms\":-1,\"label\":7,\"overrides\":[[\"a\",\"b\"],[\"c\"],3]}}",
319 )
320 .unwrap();
321 let read = payload.read::<EndpointAttached>().unwrap();
322 assert!(read.probed);
323 assert_eq!(
324 read.tuning,
325 Some(AttachedTuning {
326 overrides: vec![("a".to_owned(), "b".to_owned())],
327 ..AttachedTuning::default()
328 })
329 );
330 }
331
332 #[test]
335 fn an_older_selection_reads_as_text_only_with_no_ceiling() {
336 let payload: Payload = serde_json::from_str(
337 "{\"cache_read_price\":0,\"cache_write_price\":0,\"context_tokens\":0,\"endpoint\":\"house\",\"input_price\":0,\"max_output_tokens\":0,\"model\":\"m\",\"output_price\":0,\"tag\":\"digest\"}",
338 )
339 .unwrap();
340 let read = payload.read::<ModelSelected>().unwrap();
341 assert_eq!(read.input, InputKinds::Text);
342 assert_eq!(read.ceiling(), None);
343 }
344
345 #[test]
347 fn a_stated_ceiling_reads_back_as_that_ceiling() {
348 assert_eq!(
349 selected(Some(8192), Some("catalogue")).ceiling(),
350 Ceiling::new(8192)
351 );
352 assert_eq!(selected(None, None).ceiling(), None);
353 }
354}