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}
49
50/// What one model list said about a model's thinking, in the city's
51/// spelling of each level. Carried into the snapshots inside
52/// [`ModelFacts`], and serialised by hand for the same reason.
53#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55pub struct ThinkingStatement {
56    /// The levels the row named, ascending and without repeats; never
57    /// `none`, because turning thinking off is not a level. Empty when
58    /// the row stated only a switch.
59    #[serde(default)]
60    pub levels: Vec<Effort>,
61    /// The level the row says it uses when sent none.
62    #[serde(default)]
63    pub default: Option<Effort>,
64    /// Whether the row says the model may be asked to think.
65    #[serde(default)]
66    pub on: Option<bool>,
67    /// Whether the row says the model thinks when sent nothing.
68    #[serde(default)]
69    pub default_on: Option<bool>,
70}
71
72impl Serialize for ModelFacts {
73    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
74        let absence = Absence::of(&serializer);
75        let mut row = serializer.serialize_struct("ModelFacts", 8)?;
76        row.serialize_field("id", &self.id)?;
77        row.serialize_field("context_tokens", &self.context_tokens)?;
78        row.serialize_field("max_output_tokens", &self.max_output_tokens)?;
79        row.serialize_field("input_modalities", &self.input_modalities)?;
80        row.serialize_field("input_price", &self.input_price)?;
81        row.serialize_field("output_price", &self.output_price)?;
82        absence.option(&mut row, "thinking", &self.thinking)?;
83        absence.option(&mut row, "canonical", &self.canonical)?;
84        row.end()
85    }
86}
87
88impl Serialize for ThinkingStatement {
89    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
90        let absence = Absence::of(&serializer);
91        let mut statement = serializer.serialize_struct("ThinkingStatement", 4)?;
92        absence.list(&mut statement, "levels", &self.levels)?;
93        absence.option(&mut statement, "default", &self.default)?;
94        absence.option(&mut statement, "on", &self.on)?;
95        absence.option(&mut statement, "default_on", &self.default_on)?;
96        statement.end()
97    }
98}
99
100/// How a format carries a key whose value is absent (kernel D58): a
101/// human-readable format leaves the key out, which is what every line
102/// and CAS blob already holds; postcard is not self-describing and
103/// reads fields by position, so a binary format writes every field.
104#[derive(Clone, Copy)]
105enum Absence {
106    Omitted,
107    Written,
108}
109
110impl Absence {
111    fn of<S: Serializer>(serializer: &S) -> Absence {
112        if serializer.is_human_readable() {
113            Absence::Omitted
114        } else {
115            Absence::Written
116        }
117    }
118
119    fn option<S: SerializeStruct, T: Serialize>(
120        self,
121        fields: &mut S,
122        key: &'static str,
123        value: &Option<T>,
124    ) -> Result<(), S::Error> {
125        match (self, value) {
126            (Absence::Omitted, None) => fields.skip_field(key),
127            (Absence::Omitted, Some(_)) | (Absence::Written, None | Some(_)) => {
128                fields.serialize_field(key, value)
129            }
130        }
131    }
132
133    fn list<S: SerializeStruct, T: Serialize>(
134        self,
135        fields: &mut S,
136        key: &'static str,
137        value: &[T],
138    ) -> Result<(), S::Error> {
139        match self {
140            Absence::Omitted if value.is_empty() => fields.skip_field(key),
141            Absence::Omitted | Absence::Written => fields.serialize_field(key, value),
142        }
143    }
144}
145
146/// `endpoint_probed`: a base URL was asked what it serves, and nothing
147/// was attached.
148///
149/// A probe that could not read a model list still writes this line:
150/// `models` and `facts` are then empty rather than absent, and `failed`
151/// tells the two apart.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154pub struct EndpointProbed {
155    pub name: String,
156    pub base_url: String,
157    /// How far the call got, stage by stage.
158    pub reach: Reach,
159    pub models: Vec<String>,
160    pub facts: Vec<ModelFacts>,
161    /// Why the model list could not be read, when it could not.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub failed: Option<ProbeFailure>,
164}
165
166/// The refusal a probe met, as its stable code and subject.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169pub struct ProbeFailure {
170    pub code: String,
171    pub subject: String,
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used, reason = "test code")]
176mod tests {
177    use super::*;
178    use crate::event::Payload;
179
180    /// The golden bytes the hand-written writer spelled: a probe that
181    /// could not read a model list keeps both lists, empty, beside
182    /// `failed`.
183    #[test]
184    fn a_probed_line_keeps_the_bytes_its_ledger_already_holds() {
185        let line = concat!(
186            r#"{"base_url":"http://127.0.0.1:1","facts":[],"#,
187            r#""failed":{"code":"E_PROVIDER","subject":"m"},"models":[],"name":"local","#,
188            r#""reach":{"answered":{"detail":"no socket","state":"unreachable"},"#,
189            r#""connected":{"state":"refused"},"elapsed_ms":3,"host":"127.0.0.1","#,
190            r#""named":{"detail":1,"state":"resolved"},"through":{"state":"direct"}}}"#,
191        );
192        let read: EndpointProbed = serde_json::from_str::<Payload>(line)
193            .unwrap()
194            .read()
195            .unwrap();
196        assert_eq!(
197            serde_json::to_string(&Payload::of(&read).unwrap()).unwrap(),
198            line
199        );
200    }
201
202    /// The hand-written `Serialize` leaves a human-readable line exactly
203    /// as the derived one with `skip_serializing_if` wrote it (kernel
204    /// D58): a row 0.0.10 wrote, a row that stated part of its thinking,
205    /// and a row that stated a switch only, with no `null` added.
206    #[test]
207    fn a_probed_line_with_thinking_facts_keeps_its_bytes() {
208        let line = concat!(
209            r#"{"base_url":"https://api.example.test/v1","facts":["#,
210            r#"{"context_tokens":null,"id":"m-old","input_modalities":[],"#,
211            r#""input_price":null,"max_output_tokens":null,"output_price":null},"#,
212            r#"{"canonical":"vendor/m-new","context_tokens":200000,"id":"m-new","#,
213            r#""input_modalities":["text"],"input_price":null,"max_output_tokens":null,"#,
214            r#""output_price":null,"thinking":{"default":"medium","levels":["low","medium","high"]}},"#,
215            r#"{"context_tokens":null,"id":"m-switch","input_modalities":[],"#,
216            r#""input_price":null,"max_output_tokens":null,"output_price":null,"#,
217            r#""thinking":{"on":true}}],"#,
218            r#""models":["m-old","m-new","m-switch"],"name":"listed","#,
219            r#""reach":{"answered":{"detail":"no socket","state":"unreachable"},"#,
220            r#""connected":{"state":"refused"},"elapsed_ms":3,"host":"127.0.0.1","#,
221            r#""named":{"detail":1,"state":"resolved"},"through":{"state":"direct"}}}"#,
222        );
223        let read: EndpointProbed = serde_json::from_str::<Payload>(line)
224            .unwrap()
225            .read()
226            .unwrap();
227        assert_eq!(
228            serde_json::to_string(&Payload::of(&read).unwrap()).unwrap(),
229            line
230        );
231    }
232}