Skip to main content

lash_sansio/llm/
types.rs

1use std::num::NonZeroUsize;
2use std::sync::Arc;
3
4use crate::{AttachmentRef, SchemaProjectionOverride};
5
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum LlmTerminalReason {
9    Stop,
10    ToolUse,
11    OutputLimit,
12    ContextOverflow,
13    ContentFilter,
14    ProviderError,
15    Cancelled,
16    #[default]
17    Unknown,
18}
19
20impl LlmTerminalReason {
21    pub fn code(self) -> &'static str {
22        match self {
23            Self::Stop => "stop",
24            Self::ToolUse => "tool_use",
25            Self::OutputLimit => "output_limit",
26            Self::ContextOverflow => "context_overflow",
27            Self::ContentFilter => "content_filter",
28            Self::ProviderError => "provider_error",
29            Self::Cancelled => "cancelled",
30            Self::Unknown => "unknown",
31        }
32    }
33}
34
35#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub struct ResponseTextMeta {
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub id: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub status: Option<String>,
41    /// Opaque provider replay phase tag. Provider crates own the wire
42    /// vocabulary (e.g. OpenAI Responses `"commentary"`/`"final_answer"`);
43    /// the kernel treats it as an opaque string and round-trips it verbatim.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub phase: Option<String>,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49pub struct LlmToolSpec {
50    pub name: String,
51    pub description: String,
52    pub input_schema: serde_json::Value,
53    pub output_schema: serde_json::Value,
54    pub input_schema_projections: Vec<SchemaProjectionOverride>,
55    pub output_schema_projections: Vec<SchemaProjectionOverride>,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
59pub enum LlmToolChoice {
60    #[default]
61    Auto,
62    None,
63    Required,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
67pub struct ProviderReplayMeta {
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub item_id: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub opaque: Option<String>,
72}
73
74impl ProviderReplayMeta {
75    pub fn is_empty(&self) -> bool {
76        self.item_id.is_none() && self.opaque.is_none()
77    }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
81pub struct ProviderReasoningReplay {
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub item_id: Option<String>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub encrypted_content: Option<String>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub signature: Option<String>,
88    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
89    pub redacted: bool,
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub summary: Vec<String>,
92}
93
94impl ProviderReasoningReplay {
95    pub fn is_empty(&self) -> bool {
96        self.item_id.is_none()
97            && self.encrypted_content.is_none()
98            && self.signature.is_none()
99            && !self.redacted
100            && self.summary.is_empty()
101    }
102}
103
104#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
105pub enum LlmOutputPart {
106    Text {
107        text: String,
108        response_meta: Option<ResponseTextMeta>,
109    },
110    /// Model "thinking" / reasoning output from providers that expose a
111    /// chain-of-thought channel.
112    ///
113    /// * `text` — human-readable summary for display.
114    /// * `replay` — opaque provider replay state. Provider crates decide
115    ///   how to map it back to their wire format on the next turn.
116    Reasoning {
117        text: String,
118        replay: Option<ProviderReasoningReplay>,
119    },
120    ToolCall {
121        call_id: String,
122        tool_name: String,
123        input_json: String,
124        /// Opaque provider replay state. Core may use `item_id` for stable
125        /// correlation, but provider crates own the wire semantics.
126        replay: Option<ProviderReplayMeta>,
127    },
128}
129
130#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131pub enum LlmRole {
132    User,
133    Assistant,
134    System,
135}
136
137/// A structured content block inside an `LlmMessage`. Mirrors pi-mono's
138/// per-provider block types and maps cleanly onto each wire format so the
139/// adapters can emit the right shape without re-coalescing flat messages.
140#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
141pub enum LlmContentBlock {
142    Text {
143        text: Arc<str>,
144        response_meta: Option<ResponseTextMeta>,
145        cache_breakpoint: bool,
146    },
147    /// Index into the enclosing `LlmRequest.attachments` vector. User-role
148    /// messages may embed images; adapters drop them for providers that
149    /// don't accept vision input.
150    Image { attachment_idx: usize },
151    /// Assistant tool call with optional opaque provider replay state.
152    ToolCall {
153        call_id: String,
154        tool_name: String,
155        input_json: String,
156        replay: Option<ProviderReplayMeta>,
157    },
158    /// User tool-result block. Some providers allow multiple per user turn;
159    /// adapters that want one-per-message split as needed.
160    ToolResult {
161        call_id: String,
162        content: String,
163        /// Name of the tool that produced this result. Some provider replay
164        /// formats require this; others ignore it.
165        tool_name: Option<String>,
166    },
167    /// Chain-of-thought / reasoning block. See [`LlmOutputPart::Reasoning`]
168    /// for field semantics. Adapters that don't support reasoning replay
169    /// drop these blocks silently.
170    Reasoning {
171        text: String,
172        replay: Option<ProviderReasoningReplay>,
173    },
174}
175
176/// A single role turn in the LLM conversation. `blocks` holds structured
177/// content that maps 1:1 onto provider wire types. The old flat
178/// `content: String` + `kind` discriminator has been retired in favor of
179/// this block model.
180#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
181pub struct LlmMessage {
182    pub role: LlmRole,
183    pub blocks: Arc<Vec<LlmContentBlock>>,
184}
185
186impl LlmMessage {
187    pub fn new(role: LlmRole, blocks: Vec<LlmContentBlock>) -> Self {
188        Self {
189            role,
190            blocks: Arc::new(blocks),
191        }
192    }
193
194    /// Convenience constructor for a single-text-block message.
195    pub fn text(role: LlmRole, text: impl Into<Arc<str>>) -> Self {
196        Self {
197            role,
198            blocks: Arc::new(vec![LlmContentBlock::Text {
199                text: text.into(),
200                response_meta: None,
201                cache_breakpoint: false,
202            }]),
203        }
204    }
205
206    /// True if every block is a `Text` whose content is whitespace-only.
207    pub fn is_blank(&self) -> bool {
208        self.blocks.iter().all(|b| match b {
209            LlmContentBlock::Text { text, .. } => text.trim().is_empty(),
210            _ => false,
211        })
212    }
213}
214
215#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
216pub struct LlmAttachment {
217    pub mime: String,
218    pub data: Vec<u8>,
219    pub reference: Option<AttachmentRef>,
220}
221
222impl LlmAttachment {
223    pub fn bytes(mime: impl Into<String>, data: Vec<u8>) -> Self {
224        Self {
225            mime: mime.into(),
226            data,
227            reference: None,
228        }
229    }
230
231    pub fn reference(reference: AttachmentRef) -> Self {
232        Self {
233            mime: reference.canonical_mime().to_string(),
234            data: Vec::new(),
235            reference: Some(reference),
236        }
237    }
238
239    pub fn is_resolved(&self) -> bool {
240        !self.data.is_empty() || self.reference.is_none()
241    }
242}
243
244#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
245pub struct LlmJsonSchema {
246    pub name: String,
247    pub schema: serde_json::Value,
248    pub strict: bool,
249}
250
251#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
252pub enum LlmOutputSpec {
253    JsonObject,
254    JsonSchema(LlmJsonSchema),
255}
256
257#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct GenerationOptions {
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub output_token_cap: Option<NonZeroUsize>,
262}
263
264impl GenerationOptions {
265    pub fn output_token_cap_u64(&self) -> Option<u64> {
266        self.output_token_cap
267            .map(NonZeroUsize::get)
268            .map(|value| value as u64)
269    }
270}
271
272#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
273pub struct LlmRequest {
274    pub model: String,
275    pub messages: Vec<LlmMessage>,
276    pub attachments: Vec<LlmAttachment>,
277    pub tools: Arc<Vec<LlmToolSpec>>,
278    pub tool_choice: LlmToolChoice,
279    pub model_variant: Option<String>,
280    #[serde(default)]
281    pub generation: GenerationOptions,
282    pub session_id: Option<String>,
283    pub output_spec: Option<LlmOutputSpec>,
284    #[serde(default, skip)]
285    pub stream_events: Option<LlmEventSender>,
286    #[serde(default, skip)]
287    pub provider_trace: Option<LlmProviderTraceSender>,
288}
289
290#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
291pub struct LlmUsage {
292    pub input_tokens: i64,
293    pub output_tokens: i64,
294    pub cached_input_tokens: i64,
295    pub reasoning_tokens: i64,
296}
297
298#[derive(Clone, Debug)]
299pub enum LlmStreamEvent {
300    Delta(String),
301    /// Incremental reasoning-summary text. Kept separate from `Delta` so
302    /// the UI can render it in a distinct muted/italic style rather than
303    /// mixing it into the assistant's final text.
304    ReasoningDelta(String),
305    Part(LlmOutputPart),
306    Usage(LlmUsage),
307    RetryStatus {
308        wait_seconds: u64,
309        attempt: usize,
310        max_attempts: usize,
311        reason: String,
312    },
313}
314
315#[derive(Clone)]
316pub struct LlmEventSender(Arc<dyn Fn(LlmStreamEvent) + Send + Sync>);
317
318impl LlmEventSender {
319    pub fn new<F>(send: F) -> Self
320    where
321        F: Fn(LlmStreamEvent) + Send + Sync + 'static,
322    {
323        Self(Arc::new(send))
324    }
325
326    pub fn send(&self, event: LlmStreamEvent) {
327        (self.0)(event);
328    }
329}
330
331#[derive(Clone, Debug)]
332pub struct LlmProviderTraceEvent {
333    pub provider: &'static str,
334    pub event_name: String,
335    pub raw: String,
336}
337
338#[derive(Clone)]
339pub struct LlmProviderTraceSender(Arc<dyn Fn(LlmProviderTraceEvent) + Send + Sync>);
340
341impl LlmProviderTraceSender {
342    pub fn new<F>(send: F) -> Self
343    where
344        F: Fn(LlmProviderTraceEvent) + Send + Sync + 'static,
345    {
346        Self(Arc::new(send))
347    }
348
349    pub fn send(&self, event: LlmProviderTraceEvent) {
350        (self.0)(event);
351    }
352}
353
354impl std::fmt::Debug for LlmProviderTraceSender {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        f.debug_struct("LlmProviderTraceSender")
357            .finish_non_exhaustive()
358    }
359}
360
361impl std::fmt::Debug for LlmEventSender {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        f.debug_struct("LlmEventSender").finish_non_exhaustive()
364    }
365}
366
367#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
368pub struct LlmResponse {
369    pub full_text: String,
370    pub parts: Vec<LlmOutputPart>,
371    pub usage: LlmUsage,
372    pub terminal_reason: LlmTerminalReason,
373    pub terminal_diagnostic: Option<String>,
374    pub provider_usage: Option<serde_json::Value>,
375    pub request_body: Option<String>,
376    pub http_summary: Option<String>,
377}
378
379#[derive(Clone, Debug)]
380pub struct ModelSelection {
381    pub model: &'static str,
382    pub variant: Option<&'static str>,
383}