Skip to main content

kernel/event/record/
probe.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 probe of a base URL found before anything was attached: how
7//! far the call got, and what the model list said.
8
9use serde::ser::SerializeStruct;
10use serde::{Deserialize, Serialize, Serializer};
11
12use crate::model::{Ceiling, Effort};
13use crate::reach::Reach;
14
15/// What a model list said about one model.
16///
17/// Everything but the id is optional, because for most providers
18/// everything but the id is missing. The endpoint book carries these
19/// rows into the postcard snapshots of the views and the standing, so
20/// `Serialize` is written by hand: an absent `thinking` or `canonical`
21/// is omitted only in a human-readable format (kernel D58).
22#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24pub struct ModelFacts {
25    pub id: String,
26    /// How many tokens the model reads in one call.
27    pub context_tokens: Option<u64>,
28    /// How many tokens it may write back.
29    pub max_output_tokens: Option<Ceiling>,
30    /// What it accepts, in the provider's own words (`text`, `image`,
31    /// `audio`). Empty means the row said nothing, never "text only".
32    pub input_modalities: Vec<String>,
33    /// The provider's own input price, verbatim, unit included when the
34    /// provider stated one.
35    pub input_price: Option<String>,
36    /// The provider's own output price, verbatim.
37    pub output_price: Option<String>,
38    /// What the row said about thinking levels, read by the gateway's
39    /// thinking ladder (`crates/gateway/spec/Provider/Thinking.lean`).
40    /// Absent on a row that said nothing and on a line written before
41    /// the key existed.
42    #[serde(default)]
43    pub thinking: Option<ThinkingStatement>,
44    /// The upstream's own identity for this model across providers,
45    /// where it states one (`crates/gateway/spec/Provider/Identity.lean`).
46    #[serde(default)]
47    pub canonical: Option<String>,
48    /// Whether the endpoint's own list carried this row, or the person
49    /// named an id the list did not carry (kernel D63). A row the
50    /// person named states nothing else, because nobody asked the
51    /// provider about it.
52    #[serde(default)]
53    pub listing: Listing,
54}
55
56/// Where one registered model row came from (kernel D63).
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60pub enum Listing {
61    /// The endpoint's `/models` listed it.
62    #[default]
63    Listed,
64    /// The person named the id at attach and the endpoint did not list
65    /// it this time: its list left it out, or could not be read.
66    Named,
67}
68
69/// What one model list said about a model's thinking, in the city's
70/// spelling of each level. Carried into the snapshots inside
71/// [`ModelFacts`], and serialised by hand for the same reason.
72#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74pub struct ThinkingStatement {
75    /// The levels the row named, ascending and without repeats; never
76    /// `none`, because turning thinking off is not a level. Empty when
77    /// the row stated only a switch.
78    #[serde(default)]
79    pub levels: Vec<Effort>,
80    /// The level the row says it uses when sent none.
81    #[serde(default)]
82    pub default: Option<Effort>,
83    /// Whether the row says the model may be asked to think.
84    #[serde(default)]
85    pub on: Option<bool>,
86    /// Whether the row says the model thinks when sent nothing.
87    #[serde(default)]
88    pub default_on: Option<bool>,
89}
90
91impl Serialize for ModelFacts {
92    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
93        let absence = Absence::of(&serializer);
94        let mut row = serializer.serialize_struct("ModelFacts", 9)?;
95        row.serialize_field("id", &self.id)?;
96        row.serialize_field("context_tokens", &self.context_tokens)?;
97        row.serialize_field("max_output_tokens", &self.max_output_tokens)?;
98        row.serialize_field("input_modalities", &self.input_modalities)?;
99        row.serialize_field("input_price", &self.input_price)?;
100        row.serialize_field("output_price", &self.output_price)?;
101        absence.option(&mut row, "thinking", &self.thinking)?;
102        absence.option(&mut row, "canonical", &self.canonical)?;
103        absence.listing(&mut row, self.listing)?;
104        row.end()
105    }
106}
107
108impl Serialize for ThinkingStatement {
109    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
110        let absence = Absence::of(&serializer);
111        let mut statement = serializer.serialize_struct("ThinkingStatement", 4)?;
112        absence.list(&mut statement, "levels", &self.levels)?;
113        absence.option(&mut statement, "default", &self.default)?;
114        absence.option(&mut statement, "on", &self.on)?;
115        absence.option(&mut statement, "default_on", &self.default_on)?;
116        statement.end()
117    }
118}
119
120/// How a format carries a key whose value is absent (kernel D58): a
121/// human-readable format leaves the key out, which is what every line
122/// and CAS blob already holds; postcard is not self-describing and
123/// reads fields by position, so a binary format writes every field.
124#[derive(Clone, Copy)]
125enum Absence {
126    Omitted,
127    Written,
128}
129
130impl Absence {
131    fn of<S: Serializer>(serializer: &S) -> Absence {
132        if serializer.is_human_readable() {
133            Absence::Omitted
134        } else {
135            Absence::Written
136        }
137    }
138
139    fn option<S: SerializeStruct, T: Serialize>(
140        self,
141        fields: &mut S,
142        key: &'static str,
143        value: &Option<T>,
144    ) -> Result<(), S::Error> {
145        match (self, value) {
146            (Absence::Omitted, None) => fields.skip_field(key),
147            (Absence::Omitted, Some(_)) | (Absence::Written, None | Some(_)) => {
148                fields.serialize_field(key, value)
149            }
150        }
151    }
152
153    /// `listed` is what a row without the key reads as, so a
154    /// human-readable format leaves it out and the bytes written before
155    /// the key existed stay as they were.
156    fn listing<S: SerializeStruct>(self, fields: &mut S, value: Listing) -> Result<(), S::Error> {
157        match (self, value) {
158            (Absence::Omitted, Listing::Listed) => fields.skip_field("listing"),
159            (Absence::Omitted, Listing::Named)
160            | (Absence::Written, Listing::Listed | Listing::Named) => {
161                fields.serialize_field("listing", &value)
162            }
163        }
164    }
165
166    fn list<S: SerializeStruct, T: Serialize>(
167        self,
168        fields: &mut S,
169        key: &'static str,
170        value: &[T],
171    ) -> Result<(), S::Error> {
172        match self {
173            Absence::Omitted if value.is_empty() => fields.skip_field(key),
174            Absence::Omitted | Absence::Written => fields.serialize_field(key, value),
175        }
176    }
177}
178
179/// `endpoint_probed`: a base URL was asked what it serves, and nothing
180/// was attached.
181///
182/// A probe that could not read a model list still writes this line:
183/// `models` and `facts` are then empty rather than absent, and `failed`
184/// tells the two apart.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
187pub struct EndpointProbed {
188    pub name: String,
189    pub base_url: String,
190    /// How far the call got, stage by stage.
191    pub reach: Reach,
192    pub models: Vec<String>,
193    pub facts: Vec<ModelFacts>,
194    /// Why the model list could not be read, when it could not.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub failed: Option<ProbeFailure>,
197}
198
199/// The refusal a probe met, as its stable code and subject.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
202pub struct ProbeFailure {
203    pub code: String,
204    pub subject: String,
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used, reason = "test code")]
209mod tests {
210    use super::*;
211    use crate::event::Payload;
212
213    /// The golden bytes the hand-written writer spelled: a probe that
214    /// could not read a model list keeps both lists, empty, beside
215    /// `failed`.
216    #[test]
217    fn a_probed_line_keeps_the_bytes_its_ledger_already_holds() {
218        let line = concat!(
219            r#"{"base_url":"http://127.0.0.1:1","facts":[],"#,
220            r#""failed":{"code":"E_PROVIDER","subject":"m"},"models":[],"name":"local","#,
221            r#""reach":{"answered":{"detail":"no socket","state":"unreachable"},"#,
222            r#""connected":{"state":"refused"},"elapsed_ms":3,"host":"127.0.0.1","#,
223            r#""named":{"detail":1,"state":"resolved"},"through":{"state":"direct"}}}"#,
224        );
225        let read: EndpointProbed = serde_json::from_str::<Payload>(line)
226            .unwrap()
227            .read()
228            .unwrap();
229        assert_eq!(
230            serde_json::to_string(&Payload::of(&read).unwrap()).unwrap(),
231            line
232        );
233    }
234
235    /// The hand-written `Serialize` leaves a human-readable line exactly
236    /// as the derived one with `skip_serializing_if` wrote it (kernel
237    /// D58): a row 0.0.10 wrote, a row that stated part of its thinking,
238    /// and a row that stated a switch only, with no `null` added.
239    #[test]
240    fn a_probed_line_with_thinking_facts_keeps_its_bytes() {
241        let line = concat!(
242            r#"{"base_url":"https://api.example.test/v1","facts":["#,
243            r#"{"context_tokens":null,"id":"m-old","input_modalities":[],"#,
244            r#""input_price":null,"max_output_tokens":null,"output_price":null},"#,
245            r#"{"canonical":"vendor/m-new","context_tokens":200000,"id":"m-new","#,
246            r#""input_modalities":["text"],"input_price":null,"max_output_tokens":null,"#,
247            r#""output_price":null,"thinking":{"default":"medium","levels":["low","medium","high"]}},"#,
248            r#"{"context_tokens":null,"id":"m-switch","input_modalities":[],"#,
249            r#""input_price":null,"max_output_tokens":null,"output_price":null,"#,
250            r#""thinking":{"on":true}}],"#,
251            r#""models":["m-old","m-new","m-switch"],"name":"listed","#,
252            r#""reach":{"answered":{"detail":"no socket","state":"unreachable"},"#,
253            r#""connected":{"state":"refused"},"elapsed_ms":3,"host":"127.0.0.1","#,
254            r#""named":{"detail":1,"state":"resolved"},"through":{"state":"direct"}}}"#,
255        );
256        let read: EndpointProbed = serde_json::from_str::<Payload>(line)
257            .unwrap()
258            .read()
259            .unwrap();
260        assert_eq!(
261            serde_json::to_string(&Payload::of(&read).unwrap()).unwrap(),
262            line
263        );
264    }
265}