Skip to main content

kernel/event/record/
endpoint.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What a person attached, which model answers for a tag, and which
7//! endpoint stopped answering: the three lines the gateway's endpoint
8//! book is rebuilt from.
9
10use 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/// `endpoint_attached`: a base URL, its dialect, the credential's vault
20/// place and the model ids the endpoint reported.
21///
22/// **The credential is a reference, never the key**: that is what keeps
23/// this line exportable.
24#[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    /// Absent for an endpoint that takes no credential.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
33    pub auth: Option<SecretRef>,
34    /// The header the credential travels in, when it is not a bearer.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub auth_header: Option<String>,
37    pub models: Vec<String>,
38    /// `gateway::ConnectionKind` in its own spelling. Absent on a line
39    /// written by a build with one registration per dialect.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub connection_kind: Option<String>,
42    /// Absent means true: every line written before the key existed
43    /// came from a probe that succeeded.
44    #[serde(default = "probed_by_default")]
45    pub probed: bool,
46    /// What the person settled; absent when they settled nothing, and
47    /// read as nothing settled when it is not an object.
48    #[serde(
49        default,
50        skip_serializing_if = "Option::is_none",
51        deserialize_with = "readable"
52    )]
53    pub tuning: Option<AttachedTuning>,
54}
55
56/// How the person set an endpoint up, as the line keeps it.
57///
58/// Tolerant in one direction only: a key that is missing reads as
59/// nothing settled, which is what every line written before the key
60/// existed means, and a key that is present and unreadable is left out
61/// rather than guessed at, because a deadline this city invented would
62/// be a deadline nobody can explain.
63#[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    /// Absent for the default, which is how the line says nothing was
91    /// settled here too.
92    #[serde(
93        default,
94        skip_serializing_if = "Option::is_none",
95        deserialize_with = "readable"
96    )]
97    pub proxying: Option<Proxying>,
98    /// Header name and the person's own spelling of its value, which for
99    /// a credential is its reference.
100    #[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
118/// A value this build can read, or `None`: the tolerance
119/// [`AttachedTuning`] documents, decided in one place.
120fn 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
126/// The rows that are two strings; a row that is not is left out rather
127/// than half read.
128fn 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/// What a model accepts as input.
151///
152/// A closed judgement rather than a set of flags: every row answers it,
153/// and the answer decides whether a picture may be sent at all. The
154/// default is the narrow one, because guessing narrow costs a refusal a
155/// person can act on and guessing wide costs a 400 from the provider.
156#[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/// `model_selected`: this model answers for this tag, with the facts no
166/// probe returns.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169pub struct ModelSelected {
170    pub tag: ModelTag,
171    /// The attached endpoint the model is called through.
172    pub endpoint: String,
173    /// The model id as the endpoint spells it.
174    pub model: String,
175    /// The context window; zero where nobody stated one.
176    pub context_tokens: u64,
177    /// The output ceiling, written as `null` when none was resolved.
178    /// Read it through [`ModelSelected::ceiling`], which owns what a
179    /// zero means.
180    #[serde(default)]
181    pub max_output_tokens: Option<u64>,
182    /// Which rung of the ceiling ladder supplied the figure, in the
183    /// spelling `gateway::CeilingSource::as_str` owns. Absent when no
184    /// rung did.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub ceiling_from: Option<String>,
187    /// Absent on a line written before the key existed, which replays
188    /// as text-only rather than as a broken record.
189    #[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    /// The registered ceiling, or `None` when the line states none.
199    ///
200    /// A zero reads as none: a build writing `u64` wrote it when no
201    /// catalogue row knew the model, and zero sent as a ceiling is a
202    /// request the provider answers with nothing.
203    #[must_use]
204    pub fn ceiling(&self) -> Option<Ceiling> {
205        self.max_output_tokens.and_then(Ceiling::new)
206    }
207}
208
209/// `endpoint_lost`: the endpoint under this name is gone, and every
210/// choice made through it goes with it.
211#[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    /// The golden bytes: each line as the hand-written writer in
240    /// `gateway::router::payload` spelled it, so a ledger already on
241    /// disk reads back and a new line is byte-identical to an old one.
242    #[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    /// A tuning key this build cannot read is left out, and the rest of
306    /// the line still reads.
307    #[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    /// A line from before `input` existed reads as text-only, and its
325    /// zero ceiling reads as no ceiling.
326    #[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}