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}