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_tuning"
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(default, skip_serializing_if = "Option::is_none")]
68 pub accounts: Option<Vec<super::ProviderAccount>>,
69 #[serde(
70 default,
71 skip_serializing_if = "Option::is_none",
72 deserialize_with = "readable"
73 )]
74 pub label: Option<String>,
75 #[serde(
76 default,
77 skip_serializing_if = "Option::is_none",
78 deserialize_with = "readable"
79 )]
80 pub timeout_ms: Option<u64>,
81 #[serde(
82 default,
83 skip_serializing_if = "Option::is_none",
84 deserialize_with = "readable"
85 )]
86 pub stream_idle_timeout_ms: Option<u64>,
87 #[serde(
88 default,
89 skip_serializing_if = "Option::is_none",
90 deserialize_with = "readable"
91 )]
92 pub request_max_retries: Option<u32>,
93 #[serde(
96 default,
97 skip_serializing_if = "Option::is_none",
98 deserialize_with = "readable"
99 )]
100 pub proxying: Option<Proxying>,
101 #[serde(
104 default,
105 skip_serializing_if = "Option::is_none",
106 deserialize_with = "readable"
107 )]
108 pub max_in_flight: Option<u32>,
109 #[serde(
112 default,
113 skip_serializing_if = "Option::is_none",
114 deserialize_with = "readable"
115 )]
116 pub account_retries: Option<crate::account_recovery::AccountRetries>,
117 #[serde(
120 default,
121 skip_serializing_if = "Vec::is_empty",
122 deserialize_with = "readable_pairs"
123 )]
124 pub extra_headers: Vec<(String, String)>,
125 #[serde(
126 default,
127 skip_serializing_if = "Vec::is_empty",
128 deserialize_with = "readable_pairs"
129 )]
130 pub overrides: Vec<(String, String)>,
131}
132
133fn readable_tuning<'de, D: Deserializer<'de>>(
134 deserializer: D,
135) -> Result<Option<AttachedTuning>, D::Error> {
136 let value = Value::deserialize(deserializer)?;
137 if !value.is_object() {
138 return Ok(None);
139 }
140 AttachedTuning::deserialize(value)
141 .map(Some)
142 .map_err(serde::de::Error::custom)
143}
144
145const fn probed_by_default() -> bool {
146 true
147}
148
149fn readable<'de, D: Deserializer<'de>, T: DeserializeOwned>(
152 held: D,
153) -> Result<Option<T>, D::Error> {
154 Ok(serde_json::from_value(Value::deserialize(held)?).ok())
155}
156
157fn readable_pairs<'de, D: Deserializer<'de>>(held: D) -> Result<Vec<(String, String)>, D::Error> {
160 let Value::Array(rows) = Value::deserialize(held)? else {
161 return Ok(Vec::new());
162 };
163 Ok(rows
164 .into_iter()
165 .filter_map(|row| match row {
166 Value::Array(cells) => match (cells.first(), cells.get(1)) {
167 (Some(Value::String(name)), Some(Value::String(value))) => {
168 Some((name.clone(), value.clone()))
169 }
170 _ => None,
171 },
172 Value::Null
173 | Value::Bool(_)
174 | Value::Number(_)
175 | Value::String(_)
176 | Value::Object(_) => None,
177 })
178 .collect())
179}
180
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190pub enum InputKinds {
191 #[default]
192 Text,
193 TextImage,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
200pub struct ModelSelected {
201 pub tag: ModelTag,
202 pub endpoint: String,
204 pub model: String,
206 pub context_tokens: u64,
208 #[serde(default)]
212 pub max_output_tokens: Option<u64>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub ceiling_from: Option<String>,
218 #[serde(default)]
221 pub input: InputKinds,
222 pub input_price: UsdMicros,
223 pub output_price: UsdMicros,
224 pub cache_read_price: UsdMicros,
225 pub cache_write_price: UsdMicros,
226}
227
228impl ModelSelected {
229 #[must_use]
235 pub fn ceiling(&self) -> Option<Ceiling> {
236 self.max_output_tokens.and_then(Ceiling::new)
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244pub struct EndpointLost {
245 pub name: String,
246}
247
248#[cfg(test)]
249#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
250mod tests {
251 use super::*;
252 use crate::event::Payload;
253
254 fn selected(max_output_tokens: Option<u64>, ceiling_from: Option<&str>) -> ModelSelected {
255 ModelSelected {
256 tag: ModelTag::Main,
257 endpoint: "house".to_owned(),
258 model: "opus-nine".to_owned(),
259 context_tokens: 200_000,
260 max_output_tokens,
261 ceiling_from: ceiling_from.map(str::to_owned),
262 input: InputKinds::TextImage,
263 input_price: UsdMicros::new(3_000_000),
264 output_price: UsdMicros::new(15_000_000),
265 cache_read_price: UsdMicros::new(300_000),
266 cache_write_price: UsdMicros::new(3_750_000),
267 }
268 }
269
270 #[test]
274 fn each_endpoint_line_keeps_the_bytes_its_ledger_already_holds() {
275 let cases: [(Payload, &str); 5] = [
276 (
277 Payload::of(&EndpointAttached {
278 name: "house".to_owned(),
279 base_url: "https://api.example.test/v1".to_owned(),
280 dialect: DialectKind::OpenAi,
281 auth: Some(SecretRef::parse("secret:house/key").unwrap()),
282 auth_header: Some("x-api-key".to_owned()),
283 models: vec!["opus-nine".to_owned()],
284 connection_kind: Some("openai_compat".to_owned()),
285 probed: false,
286 tuning: Some(AttachedTuning {
287 label: Some("House".to_owned()),
288 timeout_ms: Some(30_000),
289 request_max_retries: Some(2),
290 proxying: Some(Proxying::Never),
291 extra_headers: vec![("x-team".to_owned(), "secret:house/team".to_owned())],
292 ..AttachedTuning::default()
293 }),
294 })
295 .unwrap(),
296 "{\"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}}",
297 ),
298 (
299 Payload::of(&EndpointAttached {
300 name: "local".to_owned(),
301 base_url: "http://127.0.0.1:11434/v1".to_owned(),
302 dialect: DialectKind::OpenAi,
303 auth: None,
304 auth_header: None,
305 models: Vec::new(),
306 connection_kind: Some("openai_compat".to_owned()),
307 probed: true,
308 tuning: None,
309 })
310 .unwrap(),
311 "{\"base_url\":\"http://127.0.0.1:11434/v1\",\"connection_kind\":\"openai_compat\",\"dialect\":\"open_ai\",\"models\":[],\"name\":\"local\",\"probed\":true}",
312 ),
313 (
314 Payload::of(&selected(Some(32_000), Some("preset"))).unwrap(),
315 "{\"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\"}",
316 ),
317 (
318 Payload::of(&selected(None, None)).unwrap(),
319 "{\"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\"}",
320 ),
321 (
322 Payload::of(&EndpointLost {
323 name: "house".to_owned(),
324 })
325 .unwrap(),
326 "{\"name\":\"house\"}",
327 ),
328 ];
329 for (payload, wire) in cases {
330 assert_eq!(serde_json::to_string(&payload).unwrap(), wire);
331 let back: Payload = serde_json::from_str(wire).unwrap();
332 assert_eq!(back, payload);
333 }
334 }
335
336 #[test]
339 fn an_unreadable_tuning_key_is_left_out_rather_than_refusing_the_line() {
340 let payload: Payload = serde_json::from_str(
341 "{\"base_url\":\"u\",\"dialect\":\"anthropic\",\"models\":[],\"name\":\"n\",\"tuning\":{\"timeout_ms\":-1,\"label\":7,\"overrides\":[[\"a\",\"b\"],[\"c\"],3]}}",
342 )
343 .unwrap();
344 let read = payload.read::<EndpointAttached>().unwrap();
345 assert!(read.probed);
346 assert_eq!(
347 read.tuning,
348 Some(AttachedTuning {
349 overrides: vec![("a".to_owned(), "b".to_owned())],
350 ..AttachedTuning::default()
351 })
352 );
353 }
354
355 #[test]
358 fn an_older_selection_reads_as_text_only_with_no_ceiling() {
359 let payload: Payload = serde_json::from_str(
360 "{\"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\"}",
361 )
362 .unwrap();
363 let read = payload.read::<ModelSelected>().unwrap();
364 assert_eq!(read.input, InputKinds::Text);
365 assert_eq!(read.ceiling(), None);
366 }
367
368 #[test]
370 fn a_stated_ceiling_reads_back_as_that_ceiling() {
371 assert_eq!(
372 selected(Some(8192), Some("catalogue")).ceiling(),
373 Ceiling::new(8192)
374 );
375 assert_eq!(selected(None, None).ceiling(), None);
376 }
377}