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 = "Vec::is_empty",
103 deserialize_with = "readable_pairs"
104 )]
105 pub extra_headers: Vec<(String, String)>,
106 #[serde(
107 default,
108 skip_serializing_if = "Vec::is_empty",
109 deserialize_with = "readable_pairs"
110 )]
111 pub overrides: Vec<(String, String)>,
112}
113
114const fn probed_by_default() -> bool {
115 true
116}
117
118fn readable<'de, D: Deserializer<'de>, T: DeserializeOwned>(
121 held: D,
122) -> Result<Option<T>, D::Error> {
123 Ok(serde_json::from_value(Value::deserialize(held)?).ok())
124}
125
126fn readable_pairs<'de, D: Deserializer<'de>>(held: D) -> Result<Vec<(String, String)>, D::Error> {
129 let Value::Array(rows) = Value::deserialize(held)? else {
130 return Ok(Vec::new());
131 };
132 Ok(rows
133 .into_iter()
134 .filter_map(|row| match row {
135 Value::Array(cells) => match (cells.first(), cells.get(1)) {
136 (Some(Value::String(name)), Some(Value::String(value))) => {
137 Some((name.clone(), value.clone()))
138 }
139 _ => None,
140 },
141 Value::Null
142 | Value::Bool(_)
143 | Value::Number(_)
144 | Value::String(_)
145 | Value::Object(_) => None,
146 })
147 .collect())
148}
149
150#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159pub enum InputKinds {
160 #[default]
161 Text,
162 TextImage,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169pub struct ModelSelected {
170 pub tag: ModelTag,
171 pub endpoint: String,
173 pub model: String,
175 pub context_tokens: u64,
177 #[serde(default)]
181 pub max_output_tokens: Option<u64>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub ceiling_from: Option<String>,
187 #[serde(default)]
190 pub input: InputKinds,
191 pub input_price: UsdMicros,
192 pub output_price: UsdMicros,
193 pub cache_read_price: UsdMicros,
194 pub cache_write_price: UsdMicros,
195}
196
197impl ModelSelected {
198 #[must_use]
204 pub fn ceiling(&self) -> Option<Ceiling> {
205 self.max_output_tokens.and_then(Ceiling::new)
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213pub struct EndpointLost {
214 pub name: String,
215}
216
217#[cfg(test)]
218#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
219mod tests {
220 use super::*;
221 use crate::event::Payload;
222
223 fn selected(max_output_tokens: Option<u64>, ceiling_from: Option<&str>) -> ModelSelected {
224 ModelSelected {
225 tag: ModelTag::Main,
226 endpoint: "house".to_owned(),
227 model: "opus-nine".to_owned(),
228 context_tokens: 200_000,
229 max_output_tokens,
230 ceiling_from: ceiling_from.map(str::to_owned),
231 input: InputKinds::TextImage,
232 input_price: UsdMicros::new(3_000_000),
233 output_price: UsdMicros::new(15_000_000),
234 cache_read_price: UsdMicros::new(300_000),
235 cache_write_price: UsdMicros::new(3_750_000),
236 }
237 }
238
239 #[test]
243 fn each_endpoint_line_keeps_the_bytes_its_ledger_already_holds() {
244 let cases: [(Payload, &str); 5] = [
245 (
246 Payload::of(&EndpointAttached {
247 name: "house".to_owned(),
248 base_url: "https://api.example.test/v1".to_owned(),
249 dialect: DialectKind::OpenAi,
250 auth: Some(SecretRef::parse("secret:house/key").unwrap()),
251 auth_header: Some("x-api-key".to_owned()),
252 models: vec!["opus-nine".to_owned()],
253 connection_kind: Some("openai_compat".to_owned()),
254 probed: false,
255 tuning: Some(AttachedTuning {
256 label: Some("House".to_owned()),
257 timeout_ms: Some(30_000),
258 request_max_retries: Some(2),
259 proxying: Some(Proxying::Never),
260 extra_headers: vec![("x-team".to_owned(), "secret:house/team".to_owned())],
261 ..AttachedTuning::default()
262 }),
263 })
264 .unwrap(),
265 "{\"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}}",
266 ),
267 (
268 Payload::of(&EndpointAttached {
269 name: "local".to_owned(),
270 base_url: "http://127.0.0.1:11434/v1".to_owned(),
271 dialect: DialectKind::OpenAi,
272 auth: None,
273 auth_header: None,
274 models: Vec::new(),
275 connection_kind: Some("openai_compat".to_owned()),
276 probed: true,
277 tuning: None,
278 })
279 .unwrap(),
280 "{\"base_url\":\"http://127.0.0.1:11434/v1\",\"connection_kind\":\"openai_compat\",\"dialect\":\"open_ai\",\"models\":[],\"name\":\"local\",\"probed\":true}",
281 ),
282 (
283 Payload::of(&selected(Some(32_000), Some("preset"))).unwrap(),
284 "{\"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\"}",
285 ),
286 (
287 Payload::of(&selected(None, None)).unwrap(),
288 "{\"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\"}",
289 ),
290 (
291 Payload::of(&EndpointLost {
292 name: "house".to_owned(),
293 })
294 .unwrap(),
295 "{\"name\":\"house\"}",
296 ),
297 ];
298 for (payload, wire) in cases {
299 assert_eq!(serde_json::to_string(&payload).unwrap(), wire);
300 let back: Payload = serde_json::from_str(wire).unwrap();
301 assert_eq!(back, payload);
302 }
303 }
304
305 #[test]
308 fn an_unreadable_tuning_key_is_left_out_rather_than_refusing_the_line() {
309 let payload: Payload = serde_json::from_str(
310 "{\"base_url\":\"u\",\"dialect\":\"anthropic\",\"models\":[],\"name\":\"n\",\"tuning\":{\"timeout_ms\":-1,\"label\":7,\"overrides\":[[\"a\",\"b\"],[\"c\"],3]}}",
311 )
312 .unwrap();
313 let read = payload.read::<EndpointAttached>().unwrap();
314 assert!(read.probed);
315 assert_eq!(
316 read.tuning,
317 Some(AttachedTuning {
318 overrides: vec![("a".to_owned(), "b".to_owned())],
319 ..AttachedTuning::default()
320 })
321 );
322 }
323
324 #[test]
327 fn an_older_selection_reads_as_text_only_with_no_ceiling() {
328 let payload: Payload = serde_json::from_str(
329 "{\"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\"}",
330 )
331 .unwrap();
332 let read = payload.read::<ModelSelected>().unwrap();
333 assert_eq!(read.input, InputKinds::Text);
334 assert_eq!(read.ceiling(), None);
335 }
336}