1use serde::de::DeserializeOwned;
11use serde::{Deserialize, Deserializer, Serialize};
12use serde_json::Value;
13
14use crate::budget::UsdMicros;
15use crate::locator::B3Hash;
16use crate::model::{Ceiling, DialectKind, ModelTag};
17use crate::reach::Proxying;
18use crate::secret::SecretRef;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub struct EndpointAttached {
28 pub name: String,
29 pub base_url: String,
30 pub dialect: DialectKind,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
34 pub auth: Option<SecretRef>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub auth_header: Option<String>,
38 pub models: Vec<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub connection_kind: Option<String>,
43 #[serde(default = "probed_by_default")]
46 pub probed: bool,
47 #[serde(
50 default,
51 skip_serializing_if = "Option::is_none",
52 deserialize_with = "readable_tuning"
53 )]
54 pub tuning: Option<AttachedTuning>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
60 #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
61 pub facts_blob: Option<B3Hash>,
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73pub struct AttachedTuning {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub accounts: Option<Vec<super::ProviderAccount>>,
77 #[serde(
78 default,
79 skip_serializing_if = "Option::is_none",
80 deserialize_with = "readable"
81 )]
82 pub label: Option<String>,
83 #[serde(
84 default,
85 skip_serializing_if = "Option::is_none",
86 deserialize_with = "readable"
87 )]
88 pub timeout_ms: Option<u64>,
89 #[serde(
90 default,
91 skip_serializing_if = "Option::is_none",
92 deserialize_with = "readable"
93 )]
94 pub stream_idle_timeout_ms: Option<u64>,
95 #[serde(
96 default,
97 skip_serializing_if = "Option::is_none",
98 deserialize_with = "readable"
99 )]
100 pub request_max_retries: Option<u32>,
101 #[serde(
104 default,
105 skip_serializing_if = "Option::is_none",
106 deserialize_with = "readable"
107 )]
108 pub proxying: Option<Proxying>,
109 #[serde(
112 default,
113 skip_serializing_if = "Option::is_none",
114 deserialize_with = "readable"
115 )]
116 pub max_in_flight: Option<u32>,
117 #[serde(
120 default,
121 skip_serializing_if = "Option::is_none",
122 deserialize_with = "readable"
123 )]
124 pub account_retries: Option<crate::account_recovery::AccountRetries>,
125 #[serde(
128 default,
129 skip_serializing_if = "Vec::is_empty",
130 deserialize_with = "readable_pairs"
131 )]
132 pub extra_headers: Vec<(String, String)>,
133 #[serde(
134 default,
135 skip_serializing_if = "Vec::is_empty",
136 deserialize_with = "readable_pairs"
137 )]
138 pub overrides: Vec<(String, String)>,
139}
140
141fn readable_tuning<'de, D: Deserializer<'de>>(
142 deserializer: D,
143) -> Result<Option<AttachedTuning>, D::Error> {
144 let value = Value::deserialize(deserializer)?;
145 if !value.is_object() {
146 return Ok(None);
147 }
148 AttachedTuning::deserialize(value)
149 .map(Some)
150 .map_err(serde::de::Error::custom)
151}
152
153const fn probed_by_default() -> bool {
154 true
155}
156
157fn readable<'de, D: Deserializer<'de>, T: DeserializeOwned>(
160 held: D,
161) -> Result<Option<T>, D::Error> {
162 Ok(serde_json::from_value(Value::deserialize(held)?).ok())
163}
164
165fn readable_pairs<'de, D: Deserializer<'de>>(held: D) -> Result<Vec<(String, String)>, D::Error> {
168 let Value::Array(rows) = Value::deserialize(held)? else {
169 return Ok(Vec::new());
170 };
171 Ok(rows
172 .into_iter()
173 .filter_map(|row| match row {
174 Value::Array(cells) => match (cells.first(), cells.get(1)) {
175 (Some(Value::String(name)), Some(Value::String(value))) => {
176 Some((name.clone(), value.clone()))
177 }
178 _ => None,
179 },
180 Value::Null
181 | Value::Bool(_)
182 | Value::Number(_)
183 | Value::String(_)
184 | Value::Object(_) => None,
185 })
186 .collect())
187}
188
189#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
198pub enum InputKinds {
199 #[default]
200 Text,
201 TextImage,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208pub struct ModelSelected {
209 pub tag: ModelTag,
210 pub endpoint: String,
212 pub model: String,
214 pub context_tokens: u64,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub window_from: Option<String>,
221 #[serde(default)]
225 pub max_output_tokens: Option<u64>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub ceiling_from: Option<String>,
231 #[serde(default)]
234 pub input: InputKinds,
235 pub input_price: UsdMicros,
236 pub output_price: UsdMicros,
237 pub cache_read_price: UsdMicros,
238 pub cache_write_price: UsdMicros,
239}
240
241impl ModelSelected {
242 #[must_use]
248 pub fn ceiling(&self) -> Option<Ceiling> {
249 self.max_output_tokens.and_then(Ceiling::new)
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257pub struct EndpointLost {
258 pub name: String,
259}
260
261#[cfg(test)]
262#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
263mod tests {
264 use super::*;
265 use crate::event::Payload;
266
267 fn selected(max_output_tokens: Option<u64>, ceiling_from: Option<&str>) -> ModelSelected {
268 ModelSelected {
269 tag: ModelTag::Main,
270 endpoint: "house".to_owned(),
271 model: "opus-nine".to_owned(),
272 context_tokens: 200_000,
273 window_from: None,
274 max_output_tokens,
275 ceiling_from: ceiling_from.map(str::to_owned),
276 input: InputKinds::TextImage,
277 input_price: UsdMicros::new(3_000_000),
278 output_price: UsdMicros::new(15_000_000),
279 cache_read_price: UsdMicros::new(300_000),
280 cache_write_price: UsdMicros::new(3_750_000),
281 }
282 }
283
284 #[test]
288 fn each_endpoint_line_keeps_the_bytes_its_ledger_already_holds() {
289 let cases: [(Payload, &str); 5] = [
290 (
291 Payload::of(&EndpointAttached {
292 name: "house".to_owned(),
293 base_url: "https://api.example.test/v1".to_owned(),
294 dialect: DialectKind::OpenAi,
295 auth: Some(SecretRef::parse("secret:house/key").unwrap()),
296 auth_header: Some("x-api-key".to_owned()),
297 models: vec!["opus-nine".to_owned()],
298 connection_kind: Some("openai_compat".to_owned()),
299 probed: false,
300 tuning: Some(AttachedTuning {
301 label: Some("House".to_owned()),
302 timeout_ms: Some(30_000),
303 request_max_retries: Some(2),
304 proxying: Some(Proxying::Never),
305 extra_headers: vec![("x-team".to_owned(), "secret:house/team".to_owned())],
306 ..AttachedTuning::default()
307 }),
308 facts_blob: None,
309 })
310 .unwrap(),
311 "{\"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}}",
312 ),
313 (
314 Payload::of(&EndpointAttached {
315 name: "local".to_owned(),
316 base_url: "http://127.0.0.1:11434/v1".to_owned(),
317 dialect: DialectKind::OpenAi,
318 auth: None,
319 auth_header: None,
320 models: Vec::new(),
321 connection_kind: Some("openai_compat".to_owned()),
322 probed: true,
323 tuning: None,
324 facts_blob: None,
325 })
326 .unwrap(),
327 "{\"base_url\":\"http://127.0.0.1:11434/v1\",\"connection_kind\":\"openai_compat\",\"dialect\":\"open_ai\",\"models\":[],\"name\":\"local\",\"probed\":true}",
328 ),
329 (
330 Payload::of(&selected(Some(32_000), Some("preset"))).unwrap(),
331 "{\"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\"}",
332 ),
333 (
334 Payload::of(&selected(None, None)).unwrap(),
335 "{\"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\"}",
336 ),
337 (
338 Payload::of(&EndpointLost {
339 name: "house".to_owned(),
340 })
341 .unwrap(),
342 "{\"name\":\"house\"}",
343 ),
344 ];
345 for (payload, wire) in cases {
346 assert_eq!(serde_json::to_string(&payload).unwrap(), wire);
347 let back: Payload = serde_json::from_str(wire).unwrap();
348 assert_eq!(back, payload);
349 }
350 }
351
352 #[test]
355 fn an_unreadable_tuning_key_is_left_out_rather_than_refusing_the_line() {
356 let payload: Payload = serde_json::from_str(
357 "{\"base_url\":\"u\",\"dialect\":\"anthropic\",\"models\":[],\"name\":\"n\",\"tuning\":{\"timeout_ms\":-1,\"label\":7,\"overrides\":[[\"a\",\"b\"],[\"c\"],3]}}",
358 )
359 .unwrap();
360 let read = payload.read::<EndpointAttached>().unwrap();
361 assert!(read.probed);
362 assert_eq!(
363 read.tuning,
364 Some(AttachedTuning {
365 overrides: vec![("a".to_owned(), "b".to_owned())],
366 ..AttachedTuning::default()
367 })
368 );
369 }
370
371 #[test]
374 fn an_older_selection_reads_as_text_only_with_no_ceiling() {
375 let payload: Payload = serde_json::from_str(
376 "{\"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\"}",
377 )
378 .unwrap();
379 let read = payload.read::<ModelSelected>().unwrap();
380 assert_eq!(read.input, InputKinds::Text);
381 assert_eq!(read.ceiling(), None);
382 }
383
384 #[test]
386 fn a_stated_ceiling_reads_back_as_that_ceiling() {
387 assert_eq!(
388 selected(Some(8192), Some("catalogue")).ceiling(),
389 Ceiling::new(8192)
390 );
391 assert_eq!(selected(None, None).ceiling(), None);
392 }
393}