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)]
220 pub max_output_tokens: Option<u64>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub ceiling_from: Option<String>,
226 #[serde(default)]
229 pub input: InputKinds,
230 pub input_price: UsdMicros,
231 pub output_price: UsdMicros,
232 pub cache_read_price: UsdMicros,
233 pub cache_write_price: UsdMicros,
234}
235
236impl ModelSelected {
237 #[must_use]
243 pub fn ceiling(&self) -> Option<Ceiling> {
244 self.max_output_tokens.and_then(Ceiling::new)
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
252pub struct EndpointLost {
253 pub name: String,
254}
255
256#[cfg(test)]
257#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
258mod tests {
259 use super::*;
260 use crate::event::Payload;
261
262 fn selected(max_output_tokens: Option<u64>, ceiling_from: Option<&str>) -> ModelSelected {
263 ModelSelected {
264 tag: ModelTag::Main,
265 endpoint: "house".to_owned(),
266 model: "opus-nine".to_owned(),
267 context_tokens: 200_000,
268 max_output_tokens,
269 ceiling_from: ceiling_from.map(str::to_owned),
270 input: InputKinds::TextImage,
271 input_price: UsdMicros::new(3_000_000),
272 output_price: UsdMicros::new(15_000_000),
273 cache_read_price: UsdMicros::new(300_000),
274 cache_write_price: UsdMicros::new(3_750_000),
275 }
276 }
277
278 #[test]
282 fn each_endpoint_line_keeps_the_bytes_its_ledger_already_holds() {
283 let cases: [(Payload, &str); 5] = [
284 (
285 Payload::of(&EndpointAttached {
286 name: "house".to_owned(),
287 base_url: "https://api.example.test/v1".to_owned(),
288 dialect: DialectKind::OpenAi,
289 auth: Some(SecretRef::parse("secret:house/key").unwrap()),
290 auth_header: Some("x-api-key".to_owned()),
291 models: vec!["opus-nine".to_owned()],
292 connection_kind: Some("openai_compat".to_owned()),
293 probed: false,
294 tuning: Some(AttachedTuning {
295 label: Some("House".to_owned()),
296 timeout_ms: Some(30_000),
297 request_max_retries: Some(2),
298 proxying: Some(Proxying::Never),
299 extra_headers: vec![("x-team".to_owned(), "secret:house/team".to_owned())],
300 ..AttachedTuning::default()
301 }),
302 facts_blob: None,
303 })
304 .unwrap(),
305 "{\"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}}",
306 ),
307 (
308 Payload::of(&EndpointAttached {
309 name: "local".to_owned(),
310 base_url: "http://127.0.0.1:11434/v1".to_owned(),
311 dialect: DialectKind::OpenAi,
312 auth: None,
313 auth_header: None,
314 models: Vec::new(),
315 connection_kind: Some("openai_compat".to_owned()),
316 probed: true,
317 tuning: None,
318 facts_blob: None,
319 })
320 .unwrap(),
321 "{\"base_url\":\"http://127.0.0.1:11434/v1\",\"connection_kind\":\"openai_compat\",\"dialect\":\"open_ai\",\"models\":[],\"name\":\"local\",\"probed\":true}",
322 ),
323 (
324 Payload::of(&selected(Some(32_000), Some("preset"))).unwrap(),
325 "{\"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\"}",
326 ),
327 (
328 Payload::of(&selected(None, None)).unwrap(),
329 "{\"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\"}",
330 ),
331 (
332 Payload::of(&EndpointLost {
333 name: "house".to_owned(),
334 })
335 .unwrap(),
336 "{\"name\":\"house\"}",
337 ),
338 ];
339 for (payload, wire) in cases {
340 assert_eq!(serde_json::to_string(&payload).unwrap(), wire);
341 let back: Payload = serde_json::from_str(wire).unwrap();
342 assert_eq!(back, payload);
343 }
344 }
345
346 #[test]
349 fn an_unreadable_tuning_key_is_left_out_rather_than_refusing_the_line() {
350 let payload: Payload = serde_json::from_str(
351 "{\"base_url\":\"u\",\"dialect\":\"anthropic\",\"models\":[],\"name\":\"n\",\"tuning\":{\"timeout_ms\":-1,\"label\":7,\"overrides\":[[\"a\",\"b\"],[\"c\"],3]}}",
352 )
353 .unwrap();
354 let read = payload.read::<EndpointAttached>().unwrap();
355 assert!(read.probed);
356 assert_eq!(
357 read.tuning,
358 Some(AttachedTuning {
359 overrides: vec![("a".to_owned(), "b".to_owned())],
360 ..AttachedTuning::default()
361 })
362 );
363 }
364
365 #[test]
368 fn an_older_selection_reads_as_text_only_with_no_ceiling() {
369 let payload: Payload = serde_json::from_str(
370 "{\"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\"}",
371 )
372 .unwrap();
373 let read = payload.read::<ModelSelected>().unwrap();
374 assert_eq!(read.input, InputKinds::Text);
375 assert_eq!(read.ceiling(), None);
376 }
377
378 #[test]
380 fn a_stated_ceiling_reads_back_as_that_ceiling() {
381 assert_eq!(
382 selected(Some(8192), Some("catalogue")).ceiling(),
383 Ceiling::new(8192)
384 );
385 assert_eq!(selected(None, None).ceiling(), None);
386 }
387}