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    /// How many calls may be in flight at once; absent when nobody
99    /// settled it, and a line written before the key existed reads so.
100    #[serde(
101        default,
102        skip_serializing_if = "Option::is_none",
103        deserialize_with = "readable"
104    )]
105    pub max_in_flight: Option<u32>,
106    /// Header name and the person's own spelling of its value, which for
107    /// a credential is its reference.
108    #[serde(
109        default,
110        skip_serializing_if = "Vec::is_empty",
111        deserialize_with = "readable_pairs"
112    )]
113    pub extra_headers: Vec<(String, String)>,
114    #[serde(
115        default,
116        skip_serializing_if = "Vec::is_empty",
117        deserialize_with = "readable_pairs"
118    )]
119    pub overrides: Vec<(String, String)>,
120}
121
122const fn probed_by_default() -> bool {
123    true
124}
125
126/// A value this build can read, or `None`: the tolerance
127/// [`AttachedTuning`] documents, decided in one place.
128fn readable<'de, D: Deserializer<'de>, T: DeserializeOwned>(
129    held: D,
130) -> Result<Option<T>, D::Error> {
131    Ok(serde_json::from_value(Value::deserialize(held)?).ok())
132}
133
134/// The rows that are two strings; a row that is not is left out rather
135/// than half read.
136fn readable_pairs<'de, D: Deserializer<'de>>(held: D) -> Result<Vec<(String, String)>, D::Error> {
137    let Value::Array(rows) = Value::deserialize(held)? else {
138        return Ok(Vec::new());
139    };
140    Ok(rows
141        .into_iter()
142        .filter_map(|row| match row {
143            Value::Array(cells) => match (cells.first(), cells.get(1)) {
144                (Some(Value::String(name)), Some(Value::String(value))) => {
145                    Some((name.clone(), value.clone()))
146                }
147                _ => None,
148            },
149            Value::Null
150            | Value::Bool(_)
151            | Value::Number(_)
152            | Value::String(_)
153            | Value::Object(_) => None,
154        })
155        .collect())
156}
157
158/// What a model accepts as input.
159///
160/// A closed judgement rather than a set of flags: every row answers it,
161/// and the answer decides whether a picture may be sent at all. The
162/// default is the narrow one, because guessing narrow costs a refusal a
163/// person can act on and guessing wide costs a 400 from the provider.
164#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167pub enum InputKinds {
168    #[default]
169    Text,
170    TextImage,
171}
172
173/// `model_selected`: this model answers for this tag, with the facts no
174/// probe returns.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
177pub struct ModelSelected {
178    pub tag: ModelTag,
179    /// The attached endpoint the model is called through.
180    pub endpoint: String,
181    /// The model id as the endpoint spells it.
182    pub model: String,
183    /// The context window; zero where nobody stated one.
184    pub context_tokens: u64,
185    /// The output ceiling, written as `null` when none was resolved.
186    /// Read it through [`ModelSelected::ceiling`], which owns what a
187    /// zero means.
188    #[serde(default)]
189    pub max_output_tokens: Option<u64>,
190    /// Which rung of the ceiling ladder supplied the figure, in the
191    /// spelling `gateway::CeilingSource::as_str` owns. Absent when no
192    /// rung did.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub ceiling_from: Option<String>,
195    /// Absent on a line written before the key existed, which replays
196    /// as text-only rather than as a broken record.
197    #[serde(default)]
198    pub input: InputKinds,
199    pub input_price: UsdMicros,
200    pub output_price: UsdMicros,
201    pub cache_read_price: UsdMicros,
202    pub cache_write_price: UsdMicros,
203}
204
205impl ModelSelected {
206    /// The registered ceiling, or `None` when the line states none.
207    ///
208    /// A zero reads as none: a build writing `u64` wrote it when no
209    /// catalogue row knew the model, and zero sent as a ceiling is a
210    /// request the provider answers with nothing.
211    #[must_use]
212    pub fn ceiling(&self) -> Option<Ceiling> {
213        self.max_output_tokens.and_then(Ceiling::new)
214    }
215}
216
217/// `endpoint_lost`: the endpoint under this name is gone, and every
218/// choice made through it goes with it.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
221pub struct EndpointLost {
222    pub name: String,
223}
224
225#[cfg(test)]
226#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
227mod tests {
228    use super::*;
229    use crate::event::Payload;
230
231    fn selected(max_output_tokens: Option<u64>, ceiling_from: Option<&str>) -> ModelSelected {
232        ModelSelected {
233            tag: ModelTag::Main,
234            endpoint: "house".to_owned(),
235            model: "opus-nine".to_owned(),
236            context_tokens: 200_000,
237            max_output_tokens,
238            ceiling_from: ceiling_from.map(str::to_owned),
239            input: InputKinds::TextImage,
240            input_price: UsdMicros::new(3_000_000),
241            output_price: UsdMicros::new(15_000_000),
242            cache_read_price: UsdMicros::new(300_000),
243            cache_write_price: UsdMicros::new(3_750_000),
244        }
245    }
246
247    /// The golden bytes: each line as the hand-written writer in
248    /// `gateway::router::payload` spelled it, so a ledger already on
249    /// disk reads back and a new line is byte-identical to an old one.
250    #[test]
251    fn each_endpoint_line_keeps_the_bytes_its_ledger_already_holds() {
252        let cases: [(Payload, &str); 5] = [
253            (
254                Payload::of(&EndpointAttached {
255                    name: "house".to_owned(),
256                    base_url: "https://api.example.test/v1".to_owned(),
257                    dialect: DialectKind::OpenAi,
258                    auth: Some(SecretRef::parse("secret:house/key").unwrap()),
259                    auth_header: Some("x-api-key".to_owned()),
260                    models: vec!["opus-nine".to_owned()],
261                    connection_kind: Some("openai_compat".to_owned()),
262                    probed: false,
263                    tuning: Some(AttachedTuning {
264                        label: Some("House".to_owned()),
265                        timeout_ms: Some(30_000),
266                        request_max_retries: Some(2),
267                        proxying: Some(Proxying::Never),
268                        extra_headers: vec![("x-team".to_owned(), "secret:house/team".to_owned())],
269                        ..AttachedTuning::default()
270                    }),
271                })
272                .unwrap(),
273                "{\"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}}",
274            ),
275            (
276                Payload::of(&EndpointAttached {
277                    name: "local".to_owned(),
278                    base_url: "http://127.0.0.1:11434/v1".to_owned(),
279                    dialect: DialectKind::OpenAi,
280                    auth: None,
281                    auth_header: None,
282                    models: Vec::new(),
283                    connection_kind: Some("openai_compat".to_owned()),
284                    probed: true,
285                    tuning: None,
286                })
287                .unwrap(),
288                "{\"base_url\":\"http://127.0.0.1:11434/v1\",\"connection_kind\":\"openai_compat\",\"dialect\":\"open_ai\",\"models\":[],\"name\":\"local\",\"probed\":true}",
289            ),
290            (
291                Payload::of(&selected(Some(32_000), Some("preset"))).unwrap(),
292                "{\"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\"}",
293            ),
294            (
295                Payload::of(&selected(None, None)).unwrap(),
296                "{\"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\"}",
297            ),
298            (
299                Payload::of(&EndpointLost {
300                    name: "house".to_owned(),
301                })
302                .unwrap(),
303                "{\"name\":\"house\"}",
304            ),
305        ];
306        for (payload, wire) in cases {
307            assert_eq!(serde_json::to_string(&payload).unwrap(), wire);
308            let back: Payload = serde_json::from_str(wire).unwrap();
309            assert_eq!(back, payload);
310        }
311    }
312
313    /// A tuning key this build cannot read is left out, and the rest of
314    /// the line still reads.
315    #[test]
316    fn an_unreadable_tuning_key_is_left_out_rather_than_refusing_the_line() {
317        let payload: Payload = serde_json::from_str(
318            "{\"base_url\":\"u\",\"dialect\":\"anthropic\",\"models\":[],\"name\":\"n\",\"tuning\":{\"timeout_ms\":-1,\"label\":7,\"overrides\":[[\"a\",\"b\"],[\"c\"],3]}}",
319        )
320        .unwrap();
321        let read = payload.read::<EndpointAttached>().unwrap();
322        assert!(read.probed);
323        assert_eq!(
324            read.tuning,
325            Some(AttachedTuning {
326                overrides: vec![("a".to_owned(), "b".to_owned())],
327                ..AttachedTuning::default()
328            })
329        );
330    }
331
332    /// A line from before `input` existed reads as text-only, and its
333    /// zero ceiling reads as no ceiling.
334    #[test]
335    fn an_older_selection_reads_as_text_only_with_no_ceiling() {
336        let payload: Payload = serde_json::from_str(
337            "{\"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\"}",
338        )
339        .unwrap();
340        let read = payload.read::<ModelSelected>().unwrap();
341        assert_eq!(read.input, InputKinds::Text);
342        assert_eq!(read.ceiling(), None);
343    }
344
345    /// A registered ceiling is the one the line states.
346    #[test]
347    fn a_stated_ceiling_reads_back_as_that_ceiling() {
348        assert_eq!(
349            selected(Some(8192), Some("catalogue")).ceiling(),
350            Ceiling::new(8192)
351        );
352        assert_eq!(selected(None, None).ceiling(), None);
353    }
354}