Skip to main content

kernel/model/
wire.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//! The canonical conversation vocabulary: what a request and a response
7//! are made of, in the city dialect every adapter translates from.
8//! The part `crates/kernel/spec/Model.lean` specifies this module with the
9//! rest of `kernel::model`.
10
11use std::borrow::Cow;
12use std::num::NonZeroU64;
13
14use serde::{Deserialize, Serialize};
15
16use crate::event::Payload;
17use crate::model::image::ImageRef;
18use crate::model::usage::ModelUsage;
19use crate::tool::ToolName;
20/// Building-level constraints riding along the call. It carries the one
21/// load-bearing bit; further fields only grow, and each one is
22/// added at every construction site the compiler names.
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct BuildingPolicy {
25    /// Confidential buildings lock the call to the local model pool;
26    /// content never leaves the machine (11.4).
27    pub confidential: bool,
28}
29
30impl BuildingPolicy {
31    pub fn new(confidential: bool) -> Self {
32        BuildingPolicy { confidential }
33    }
34}
35
36/// Message author on the canonical (Anthropic-shaped) conversation.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum Role {
40    User,
41    Assistant,
42}
43
44/// Why the provider stopped. Wire-borne and closed: a fourth reason has
45/// to be answered by every dialect that maps one (14.3).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum StopReason {
49    EndTurn,
50    ToolUse,
51    MaxTokens,
52}
53
54/// The most a model may emit in one response, thinking included.
55///
56/// **Zero is unrepresentable, and absence is not zero.** A provider
57/// answers a zero ceiling with nothing at all, and a reply with nothing
58/// in it reads downstream as work that finished โ€” so a figure nobody
59/// registered has to be carried as a figure nobody registered, and the
60/// dialect that cannot write the request without one refuses the call
61/// instead of inventing a number.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
63#[serde(transparent)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65pub struct Ceiling(NonZeroU64);
66
67impl Ceiling {
68    /// `None` for zero, which is not a ceiling.
69    pub const fn new(tokens: u64) -> Option<Ceiling> {
70        match NonZeroU64::new(tokens) {
71            Some(tokens) => Some(Ceiling(tokens)),
72            None => None,
73        }
74    }
75
76    pub const fn get(self) -> u64 {
77        self.0.get()
78    }
79}
80
81impl std::fmt::Display for Ceiling {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}", self.0.get())
84    }
85}
86
87/// One frozen-prefix block on the wire. `cache` marks an explicit prompt
88/// cache breakpoint (breakpoints sit on segment edges).
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct SystemBlock {
91    pub text: String,
92    pub cache: bool,
93}
94
95/// Which wire the far side speaks. Closed: a fourth dialect is a
96/// compile error at every entrance of `gateway::dialect`, which is how
97/// it is kept from being approximated with the nearest of the three
98/// already written.
99///
100/// Lives here rather than in `gateway` for the same reason
101/// [`BuildingPolicy`] does: two outer crates must name it (the gateway
102/// translates it, the wire carries it) and neither may name the other.
103/// `gateway::dialect` is its evaluator, not its definition.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107pub enum DialectKind {
108    Anthropic,
109    /// OpenAI's chat completions face, which most relays, most local
110    /// servers and most other laboratories also serve.
111    OpenAi,
112    /// OpenAI's responses face: a different request body, a different
113    /// reply shape and a different set of stream event names.
114    ///
115    /// Registered apart from [`DialectKind::OpenAi`] because a person
116    /// who pasted a responses URL said which face they meant, and a
117    /// registration that folded the two would send chat bodies to a
118    /// path that answers 404.
119    OpenAiResponses,
120}
121
122/// What a chosen model is for. Exhaustive rather than a free label: a
123/// tag exists because some code asks for a model by it, so a tag with no
124/// asker is a setting the person can fill in and never see used. It
125/// grows when a caller appears, not when a name is imagined.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129pub enum ModelTag {
130    /// The model a resident thinks with.
131    Main,
132    /// The small model that reads long documents so the main one does
133    /// not have to: summaries, structure trees, search results.
134    Digest,
135    /// The model that turns a recording into text.
136    ///
137    /// A tag rather than a store of its own, because "which endpoint and
138    /// which model answer this kind of job" already has a mechanism: a
139    /// person attaches an endpoint and chooses a model for a tag. A
140    /// second form and a second file would be a second answer to one
141    /// question, and the credential would need a second way into the
142    /// vault.
143    Transcribe,
144    /// The model that reads text out of a picture: one a person
145    /// registered as able to read images. The city's OCR tool asks for
146    /// it, and with none chosen that tool says so rather than handing a
147    /// picture to a model that only reads text (`crates/kernel/spec/Model.lean` ยง8-80).
148    Ocr,
149}
150
151impl ModelTag {
152    /// Every tag, in the order a settings page should offer them.
153    pub const ALL: [ModelTag; 4] = [
154        ModelTag::Main,
155        ModelTag::Digest,
156        ModelTag::Transcribe,
157        ModelTag::Ocr,
158    ];
159
160    #[must_use]
161    pub fn as_str(&self) -> &'static str {
162        match self {
163            ModelTag::Main => "main",
164            ModelTag::Digest => "digest",
165            ModelTag::Transcribe => "transcribe",
166            ModelTag::Ocr => "ocr",
167        }
168    }
169}
170
171impl std::fmt::Display for ModelTag {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        f.write_str(self.as_str())
174    }
175}
176
177/// How hard the provider should think before answering, ordered from
178/// least to most. Both dialects accept every level; they disagree only
179/// on where `None` is written (an effort value on one wire, a separate
180/// thinking field on the other).
181///
182/// The ladder mirrors the providers' own vocabularies; check theirs
183/// before changing it:
184/// <https://platform.claude.com/docs/en/build-with-claude/effort> and
185/// <https://developers.openai.com/api/docs/guides/reasoning>.
186///
187/// Absence (`Option::None`) is not `Effort::None`: absence leaves the
188/// choice to the provider, `Effort::None` asks it not to think.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
192pub enum Effort {
193    None,
194    Low,
195    Medium,
196    High,
197    #[serde(rename = "xhigh")]
198    XHigh,
199    Max,
200}
201
202impl Effort {
203    /// Every setting, in the order a person is offered them.
204    ///
205    /// The one list. A refusal that spells the allowed values, a form
206    /// that offers them and a document that names them all read this,
207    /// so a seventh setting cannot appear in one of the three and be
208    /// missing from the other two.
209    pub const ALL: [Effort; 6] = [
210        Effort::None,
211        Effort::Low,
212        Effort::Medium,
213        Effort::High,
214        Effort::XHigh,
215        Effort::Max,
216    ];
217
218    /// The word the wire and every configuration file spell it with.
219    #[must_use]
220    pub fn as_str(self) -> &'static str {
221        match self {
222            Effort::None => "none",
223            Effort::Low => "low",
224            Effort::Medium => "medium",
225            Effort::High => "high",
226            Effort::XHigh => "xhigh",
227            Effort::Max => "max",
228        }
229    }
230}
231
232/// One piece of what a model is producing, before the call it belongs to
233/// has settled.
234///
235/// **Reasoning and prose are two streams, not one.** A model that spends
236/// most of a call reasoning sends almost nothing on the prose stream,
237/// and a page that appended both to one buffer would either show a
238/// person their own model's scratch work as its answer or show them an
239/// empty thread for three minutes. Which stream a piece came from is
240/// therefore part of the piece, and no reader has to guess.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244pub enum Increment {
245    /// Prose, which is the answer as it arrives.
246    Said(String),
247    /// Reasoning, which is how the answer was arrived at.
248    Thought(String),
249}
250
251/// Canonical content block. Tool inputs are [`Payload`] โ€” the float ban
252/// holds here because these bytes become ledger payloads verbatim.
253///
254/// `Thinking` and `RedactedThinking` are carried unchanged end to end:
255/// the provider verifies `signature` against the reasoning it issued, and
256/// altering either block earns a 400 that names them as unmodifiable.
257/// The block shapes are the city dialect's, so
258/// <https://platform.claude.com/docs/en/build-with-claude/thinking> is
259/// what a change to them has to agree with.
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261#[serde(tag = "kind", rename_all = "snake_case")]
262pub enum ContentBlock {
263    Text {
264        text: String,
265    },
266    Thinking {
267        thinking: String,
268        signature: String,
269    },
270    RedactedThinking {
271        data: String,
272    },
273    ToolUse {
274        id: String,
275        name: ToolName,
276        input: Payload,
277    },
278    /// A picture the model can see. Carries the reference and the
279    /// dimensions; the bytes are fetched at the wire by whoever is about
280    /// to send them (`gateway::endpoint`), so this block stays a thing a
281    /// ledger can hold.
282    Image(ImageRef),
283    ToolResult {
284        tool_use_id: String,
285        content: String,
286        is_error: bool,
287        /// Pictures the tool produced. Absent from every tool result
288        /// written before this field existed, which is why it reads as
289        /// an empty list rather than as a broken record: an old history
290        /// has to replay.
291        #[serde(default)]
292        attachments: Vec<ImageRef>,
293    },
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct ChatMessage {
298    pub role: Role,
299    pub content: Vec<ContentBlock>,
300}
301
302/// Which message of a request carries an explicit prompt-cache
303/// breakpoint.
304///
305/// A request-side annotation rather than a mark on a message, so a
306/// request can borrow the conversation it carries: marking a message
307/// would need a writable copy of it. `runtime::prefix::BreakpointPlan`
308/// is the only producer, and it anchors the tail - so everything a
309/// request carries, trailing tool results included, sits inside the
310/// region a later request can hit.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
312pub enum MessageBreakpoint {
313    #[default]
314    Unmarked,
315    Tail,
316}
317
318/// A tool as the provider sees it; sourced from catalog `tool_defs` only.
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320pub struct ToolDef {
321    pub name: ToolName,
322    pub description: String,
323    pub input_schema: Payload,
324}
325
326/// The canonical request conversation (city dialect = Anthropic Messages). Both production dialects and the scripted model
327/// consume this one shape โ€” the seam carries it so replay, citysim and
328/// the real gateway argue about the same object.
329///
330/// `messages` and `tools` are borrowed on the turn's path, because a
331/// request lives for one call; a holder that keeps a request past the
332/// call (the keep-warm renewal) owns a `ChatRequest<'static>`.
333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
334pub struct ChatRequest<'a> {
335    pub model: String,
336    /// Absent when no catalogue row states this model's ceiling. The
337    /// OpenAI wire then omits the field and takes the provider's own
338    /// default; the Anthropic wire, which requires it, refuses.
339    pub max_tokens: Option<Ceiling>,
340    pub system: Vec<SystemBlock>,
341    pub messages: Cow<'a, [ChatMessage]>,
342    pub tools: Cow<'a, [ToolDef]>,
343    /// Outside serde, like every request-side annotation: the bytes a
344    /// ledger or a transcript records are the same with and without it.
345    #[serde(skip)]
346    pub breakpoint: MessageBreakpoint,
347    /// Frozen at run start: changing it mid-run would start a new cached
348    /// prompt prefix, so the value rides in from [`crate::FrozenConfig`].
349    pub effort: Option<Effort>,
350}
351
352/// The canonical response: dialect output, endpoint input.
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct ChatResponse {
355    pub content: Vec<ContentBlock>,
356    pub stop: StopReason,
357    pub usage: ModelUsage,
358}
359
360impl ChatRequest<'_> {
361    /// An empty conversation shell for adapters and tests that argue
362    /// about hashes, not content.
363    pub fn empty(model: &str, max_tokens: Ceiling) -> ChatRequest<'static> {
364        ChatRequest {
365            model: model.to_owned(),
366            max_tokens: Some(max_tokens),
367            system: Vec::new(),
368            messages: Cow::Borrowed(&[]),
369            tools: Cow::Borrowed(&[]),
370            breakpoint: MessageBreakpoint::Unmarked,
371            effort: None,
372        }
373    }
374
375    /// Whether the message at `index` carries this request's breakpoint.
376    /// The one reading of [`MessageBreakpoint`]; a dialect only spells
377    /// the answer on its wire.
378    pub fn carries_breakpoint(&self, index: usize) -> bool {
379        match self.breakpoint {
380            MessageBreakpoint::Unmarked => false,
381            MessageBreakpoint::Tail => index.checked_add(1) == Some(self.messages.len()),
382        }
383    }
384
385    /// The same request, owning what it borrowed: for a holder that
386    /// keeps it past the call it was assembled for.
387    pub fn into_owned(self) -> ChatRequest<'static> {
388        ChatRequest {
389            model: self.model,
390            max_tokens: self.max_tokens,
391            system: self.system,
392            messages: Cow::Owned(self.messages.into_owned()),
393            tools: Cow::Owned(self.tools.into_owned()),
394            breakpoint: self.breakpoint,
395            effort: self.effort,
396        }
397    }
398}