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, least to most, in the upstreams' words. Which levels one
178/// (Endpoint, model) offers is the upstream's statement, answered by the gateway's thinking
179/// ladder and never by a table here (`crates/kernel/spec/Model.lean` D56); a level outside the
180/// offer is refused, never moved to a neighbour. Check the vendors before changing the ladder:
181/// <https://platform.claude.com/docs/en/build-with-claude/effort>,
182/// <https://developers.openai.com/api/docs/guides/reasoning>.
183///
184/// Absence (`Option::None`) is not `Effort::None`: absence means nobody stated a level and the
185/// default rule resolves one ([`crate::consts_policy::DEFAULT_EFFORT`]). `Effort::None` survives
186/// only so ledgers that asked a provider not to think read back; no picker offers it.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190pub enum Effort {
191    None,
192    Minimal,
193    Low,
194    Medium,
195    High,
196    #[serde(rename = "xhigh")]
197    XHigh,
198    Max,
199}
200
201impl Effort {
202    /// Every setting, in the order a person is offered them.
203    ///
204    /// The one list. A refusal that spells the allowed values, a form
205    /// that offers them and a document that names them all read this,
206    /// so an eighth setting cannot appear in one of the three and be
207    /// missing from the other two.
208    pub const ALL: [Effort; 7] = [
209        Effort::None,
210        Effort::Minimal,
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::Minimal => "minimal",
224            Effort::Low => "low",
225            Effort::Medium => "medium",
226            Effort::High => "high",
227            Effort::XHigh => "xhigh",
228            Effort::Max => "max",
229        }
230    }
231}
232
233/// One piece of what a model is producing, before the call it belongs to
234/// has settled.
235///
236/// **Reasoning and prose are two streams, not one.** A model that spends
237/// most of a call reasoning sends almost nothing on the prose stream,
238/// and a page that appended both to one buffer would either show a
239/// person their own model's scratch work as its answer or show them an
240/// empty thread for three minutes. Which stream a piece came from is
241/// therefore part of the piece, and no reader has to guess.
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case")]
244#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
245pub enum Increment {
246    /// Prose, which is the answer as it arrives.
247    Said(String),
248    /// Reasoning, which is how the answer was arrived at.
249    Thought(String),
250}
251
252/// Canonical content block. Tool inputs are [`Payload`] โ€” the float ban
253/// holds here because these bytes become ledger payloads verbatim.
254///
255/// `Thinking` and `RedactedThinking` are carried unchanged end to end:
256/// the provider verifies `signature` against the reasoning it issued, and
257/// altering either block earns a 400 that names them as unmodifiable.
258/// The block shapes are the city dialect's, so
259/// <https://platform.claude.com/docs/en/build-with-claude/thinking> is
260/// what a change to them has to agree with.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262#[serde(tag = "kind", rename_all = "snake_case")]
263pub enum ContentBlock {
264    Text {
265        text: String,
266    },
267    Thinking {
268        thinking: String,
269        signature: String,
270    },
271    RedactedThinking {
272        data: String,
273    },
274    ToolUse {
275        id: String,
276        name: ToolName,
277        input: Payload,
278    },
279    /// A picture the model can see. Carries the reference and the
280    /// dimensions; the bytes are fetched at the wire by whoever is about
281    /// to send them (`gateway::endpoint`), so this block stays a thing a
282    /// ledger can hold.
283    Image(ImageRef),
284    ToolResult {
285        tool_use_id: String,
286        content: String,
287        is_error: bool,
288        /// Pictures the tool produced. Absent from every tool result
289        /// written before this field existed, which is why it reads as
290        /// an empty list rather than as a broken record: an old history
291        /// has to replay.
292        #[serde(default)]
293        attachments: Vec<ImageRef>,
294    },
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298pub struct ChatMessage {
299    pub role: Role,
300    pub content: Vec<ContentBlock>,
301}
302
303/// Which message of a request carries an explicit prompt-cache
304/// breakpoint.
305///
306/// A request-side annotation rather than a mark on a message, so a
307/// request can borrow the conversation it carries: marking a message
308/// would need a writable copy of it. `runtime::prefix::BreakpointPlan`
309/// is the only producer, and it anchors the tail - so everything a
310/// request carries, trailing tool results included, sits inside the
311/// region a later request can hit.
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
313pub enum MessageBreakpoint {
314    #[default]
315    Unmarked,
316    Tail,
317}
318
319/// A tool as the provider sees it; sourced from catalog `tool_defs` only.
320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321pub struct ToolDef {
322    pub name: ToolName,
323    pub description: String,
324    pub input_schema: Payload,
325}
326
327/// The canonical request conversation (city dialect = Anthropic Messages). Both production dialects and the scripted model
328/// consume this one shape โ€” the seam carries it so replay, citysim and
329/// the real gateway argue about the same object.
330///
331/// `messages` and `tools` are borrowed on the turn's path, because a
332/// request lives for one call; a holder that keeps a request past the
333/// call (the keep-warm renewal) owns a `ChatRequest<'static>`.
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335pub struct ChatRequest<'a> {
336    pub model: String,
337    /// Absent when no catalogue row states this model's ceiling. The
338    /// OpenAI wire then omits the field and takes the provider's own
339    /// default; the Anthropic wire, which requires it, refuses.
340    pub max_tokens: Option<Ceiling>,
341    pub system: Vec<SystemBlock>,
342    pub messages: Cow<'a, [ChatMessage]>,
343    pub tools: Cow<'a, [ToolDef]>,
344    /// Outside serde, like every request-side annotation: the bytes a
345    /// ledger or a transcript records are the same with and without it.
346    #[serde(skip)]
347    pub breakpoint: MessageBreakpoint,
348    /// Frozen at run start: changing it mid-run would start a new cached
349    /// prompt prefix, so the value rides in from [`crate::FrozenConfig`].
350    pub effort: Option<Effort>,
351}
352
353/// The canonical response: dialect output, endpoint input.
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct ChatResponse {
356    pub content: Vec<ContentBlock>,
357    pub stop: StopReason,
358    pub usage: ModelUsage,
359}
360
361impl ChatRequest<'_> {
362    /// An empty conversation shell for adapters and tests that argue
363    /// about hashes, not content.
364    pub fn empty(model: &str, max_tokens: Ceiling) -> ChatRequest<'static> {
365        ChatRequest {
366            model: model.to_owned(),
367            max_tokens: Some(max_tokens),
368            system: Vec::new(),
369            messages: Cow::Borrowed(&[]),
370            tools: Cow::Borrowed(&[]),
371            breakpoint: MessageBreakpoint::Unmarked,
372            effort: None,
373        }
374    }
375
376    /// Whether the message at `index` carries this request's breakpoint.
377    /// The one reading of [`MessageBreakpoint`]; a dialect only spells
378    /// the answer on its wire.
379    pub fn carries_breakpoint(&self, index: usize) -> bool {
380        match self.breakpoint {
381            MessageBreakpoint::Unmarked => false,
382            MessageBreakpoint::Tail => index.checked_add(1) == Some(self.messages.len()),
383        }
384    }
385
386    /// The same request, owning what it borrowed: for a holder that
387    /// keeps it past the call it was assembled for.
388    pub fn into_owned(self) -> ChatRequest<'static> {
389        ChatRequest {
390            model: self.model,
391            max_tokens: self.max_tokens,
392            system: self.system,
393            messages: Cow::Owned(self.messages.into_owned()),
394            tools: Cow::Owned(self.tools.into_owned()),
395            breakpoint: self.breakpoint,
396            effort: self.effort,
397        }
398    }
399}