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}