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::locator::B3Hash;
16use crate::model::{Ceiling, DialectKind, ModelTag};
17use crate::reach::Proxying;
18use crate::secret::SecretRef;
19
20/// `endpoint_attached`: a base URL, its dialect, the credential's vault
21/// place and the model ids the endpoint reported.
22///
23/// **The credential is a reference, never the key**: that is what keeps
24/// this line exportable.
25#[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    /// Absent for an endpoint that takes no credential.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
34    pub auth: Option<SecretRef>,
35    /// The header the credential travels in, when it is not a bearer.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub auth_header: Option<String>,
38    pub models: Vec<String>,
39    /// `gateway::ConnectionKind` in its own spelling. Absent on a line
40    /// written by a build with one registration per dialect.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub connection_kind: Option<String>,
43    /// Absent means true: every line written before the key existed
44    /// came from a probe that succeeded.
45    #[serde(default = "probed_by_default")]
46    pub probed: bool,
47    /// What the person settled; absent when they settled nothing, and
48    /// read as nothing settled when it is not an object.
49    #[serde(
50        default,
51        skip_serializing_if = "Option::is_none",
52        deserialize_with = "readable_tuning"
53    )]
54    pub tuning: Option<AttachedTuning>,
55    /// The model facts read at attach or re-probe time, as one CAS blob
56    /// (`crates/kernel/spec/Event/Record.lean` ยง8-88, kernel D57). Absent on a line
57    /// written before the key existed and on an attach that read no list;
58    /// a replay then rebuilds from `models` alone.
59    #[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/// How the person set an endpoint up, as the line keeps it.
65///
66/// Tolerant in one direction only: a key that is missing reads as
67/// nothing settled, which is what every line written before the key
68/// existed means, and a key that is present and unreadable is left out
69/// rather than guessed at, because a deadline this city invented would
70/// be a deadline nobody can explain.
71#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73pub struct AttachedTuning {
74    /// Explicit ordered accounts; absent keeps the legacy registration.
75    #[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    /// Absent for the default, which is how the line says nothing was
102    /// settled here too.
103    #[serde(
104        default,
105        skip_serializing_if = "Option::is_none",
106        deserialize_with = "readable"
107    )]
108    pub proxying: Option<Proxying>,
109    /// How many calls may be in flight at once; absent when nobody
110    /// settled it, and a line written before the key existed reads so.
111    #[serde(
112        default,
113        skip_serializing_if = "Option::is_none",
114        deserialize_with = "readable"
115    )]
116    pub max_in_flight: Option<u32>,
117    /// How many more times one account is asked before the next account
118    /// takes the request; absent when nobody settled it.
119    #[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    /// Header name and the person's own spelling of its value, which for
126    /// a credential is its reference.
127    #[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
157/// A value this build can read, or `None`: the tolerance
158/// [`AttachedTuning`] documents, decided in one place.
159fn 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
165/// The rows that are two strings; a row that is not is left out rather
166/// than half read.
167fn 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/// What a model accepts as input.
190///
191/// A closed judgement rather than a set of flags: every row answers it,
192/// and the answer decides whether a picture may be sent at all. The
193/// default is the narrow one, because guessing narrow costs a refusal a
194/// person can act on and guessing wide costs a 400 from the provider.
195#[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/// `model_selected`: this model answers for this tag, with the facts no
205/// probe returns.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208pub struct ModelSelected {
209    pub tag: ModelTag,
210    /// The attached endpoint the model is called through.
211    pub endpoint: String,
212    /// The model id as the endpoint spells it.
213    pub model: String,
214    /// The context window; zero where nobody stated one.
215    pub context_tokens: u64,
216    /// Which rung of the ladder stated the window, in the spelling
217    /// `gateway::FigureSource::as_str` owns. Absent when no rung did,
218    /// and on a line written before the key existed.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub window_from: Option<String>,
221    /// The output ceiling, written as `null` when none was resolved.
222    /// Read it through [`ModelSelected::ceiling`], which owns what a
223    /// zero means.
224    #[serde(default)]
225    pub max_output_tokens: Option<u64>,
226    /// Which rung of the ceiling ladder supplied the figure, in the
227    /// spelling `gateway::OutputCeiling::word` owns. Absent when no
228    /// rung did, and on a line written before the key existed.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub ceiling_from: Option<String>,
231    /// Absent on a line written before the key existed, which replays
232    /// as text-only rather than as a broken record.
233    #[serde(default)]
234    pub input: InputKinds,
235    pub input_price: UsdMicros,
236    pub output_price: UsdMicros,
237    pub cache_read_price: UsdMicros,
238    pub cache_write_price: UsdMicros,
239}
240
241impl ModelSelected {
242    /// The registered ceiling, or `None` when the line states none.
243    ///
244    /// A zero reads as none: a build writing `u64` wrote it when no
245    /// catalogue row knew the model, and zero sent as a ceiling is a
246    /// request the provider answers with nothing.
247    #[must_use]
248    pub fn ceiling(&self) -> Option<Ceiling> {
249        self.max_output_tokens.and_then(Ceiling::new)
250    }
251}
252
253/// `endpoint_lost`: the endpoint under this name is gone, and every
254/// choice made through it goes with it.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257pub struct EndpointLost {
258    pub name: String,
259}
260
261#[cfg(test)]
262#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
263mod tests {
264    use super::*;
265    use crate::event::Payload;
266
267    fn selected(max_output_tokens: Option<u64>, ceiling_from: Option<&str>) -> ModelSelected {
268        ModelSelected {
269            tag: ModelTag::Main,
270            endpoint: "house".to_owned(),
271            model: "opus-nine".to_owned(),
272            context_tokens: 200_000,
273            window_from: None,
274            max_output_tokens,
275            ceiling_from: ceiling_from.map(str::to_owned),
276            input: InputKinds::TextImage,
277            input_price: UsdMicros::new(3_000_000),
278            output_price: UsdMicros::new(15_000_000),
279            cache_read_price: UsdMicros::new(300_000),
280            cache_write_price: UsdMicros::new(3_750_000),
281        }
282    }
283
284    /// The golden bytes: each line as the hand-written writer in
285    /// `gateway::router::payload` spelled it, so a ledger already on
286    /// disk reads back and a new line is byte-identical to an old one.
287    #[test]
288    fn each_endpoint_line_keeps_the_bytes_its_ledger_already_holds() {
289        let cases: [(Payload, &str); 5] = [
290            (
291                Payload::of(&EndpointAttached {
292                    name: "house".to_owned(),
293                    base_url: "https://api.example.test/v1".to_owned(),
294                    dialect: DialectKind::OpenAi,
295                    auth: Some(SecretRef::parse("secret:house/key").unwrap()),
296                    auth_header: Some("x-api-key".to_owned()),
297                    models: vec!["opus-nine".to_owned()],
298                    connection_kind: Some("openai_compat".to_owned()),
299                    probed: false,
300                    tuning: Some(AttachedTuning {
301                        label: Some("House".to_owned()),
302                        timeout_ms: Some(30_000),
303                        request_max_retries: Some(2),
304                        proxying: Some(Proxying::Never),
305                        extra_headers: vec![("x-team".to_owned(), "secret:house/team".to_owned())],
306                        ..AttachedTuning::default()
307                    }),
308                    facts_blob: None,
309                })
310                .unwrap(),
311                "{\"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}}",
312            ),
313            (
314                Payload::of(&EndpointAttached {
315                    name: "local".to_owned(),
316                    base_url: "http://127.0.0.1:11434/v1".to_owned(),
317                    dialect: DialectKind::OpenAi,
318                    auth: None,
319                    auth_header: None,
320                    models: Vec::new(),
321                    connection_kind: Some("openai_compat".to_owned()),
322                    probed: true,
323                    tuning: None,
324                    facts_blob: None,
325                })
326                .unwrap(),
327                "{\"base_url\":\"http://127.0.0.1:11434/v1\",\"connection_kind\":\"openai_compat\",\"dialect\":\"open_ai\",\"models\":[],\"name\":\"local\",\"probed\":true}",
328            ),
329            (
330                Payload::of(&selected(Some(32_000), Some("preset"))).unwrap(),
331                "{\"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\"}",
332            ),
333            (
334                Payload::of(&selected(None, None)).unwrap(),
335                "{\"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\"}",
336            ),
337            (
338                Payload::of(&EndpointLost {
339                    name: "house".to_owned(),
340                })
341                .unwrap(),
342                "{\"name\":\"house\"}",
343            ),
344        ];
345        for (payload, wire) in cases {
346            assert_eq!(serde_json::to_string(&payload).unwrap(), wire);
347            let back: Payload = serde_json::from_str(wire).unwrap();
348            assert_eq!(back, payload);
349        }
350    }
351
352    /// A tuning key this build cannot read is left out, and the rest of
353    /// the line still reads.
354    #[test]
355    fn an_unreadable_tuning_key_is_left_out_rather_than_refusing_the_line() {
356        let payload: Payload = serde_json::from_str(
357            "{\"base_url\":\"u\",\"dialect\":\"anthropic\",\"models\":[],\"name\":\"n\",\"tuning\":{\"timeout_ms\":-1,\"label\":7,\"overrides\":[[\"a\",\"b\"],[\"c\"],3]}}",
358        )
359        .unwrap();
360        let read = payload.read::<EndpointAttached>().unwrap();
361        assert!(read.probed);
362        assert_eq!(
363            read.tuning,
364            Some(AttachedTuning {
365                overrides: vec![("a".to_owned(), "b".to_owned())],
366                ..AttachedTuning::default()
367            })
368        );
369    }
370
371    /// A line from before `input` existed reads as text-only, and its
372    /// zero ceiling reads as no ceiling.
373    #[test]
374    fn an_older_selection_reads_as_text_only_with_no_ceiling() {
375        let payload: Payload = serde_json::from_str(
376            "{\"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\"}",
377        )
378        .unwrap();
379        let read = payload.read::<ModelSelected>().unwrap();
380        assert_eq!(read.input, InputKinds::Text);
381        assert_eq!(read.ceiling(), None);
382    }
383
384    /// A registered ceiling is the one the line states.
385    #[test]
386    fn a_stated_ceiling_reads_back_as_that_ceiling() {
387        assert_eq!(
388            selected(Some(8192), Some("catalogue")).ceiling(),
389            Ceiling::new(8192)
390        );
391        assert_eq!(selected(None, None).ceiling(), None);
392    }
393}