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