Skip to main content

lash_sansio/llm/
types.rs

1use std::num::NonZeroUsize;
2use std::sync::Arc;
3
4use crate::{AttachmentRef, SchemaContract};
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, Default, 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    /// Provider-owned payload needed to replay this text part on a future
47    /// request. The kernel stores it opaquely and providers decide whether it
48    /// is valid for their next wire request.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub provider_payload: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub origin_provider: Option<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub origin_model: Option<String>,
55}
56
57impl ResponseTextMeta {
58    pub fn phase_is(&self, expected: &str) -> bool {
59        self.phase
60            .as_deref()
61            .is_some_and(|phase| phase.eq_ignore_ascii_case(expected))
62    }
63
64    pub fn is_final_answer_phase(&self) -> bool {
65        self.phase_is("final_answer")
66    }
67
68    pub fn is_commentary_phase(&self) -> bool {
69        self.phase_is("commentary")
70    }
71}
72
73#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
74pub struct LlmToolSpec {
75    pub name: String,
76    pub description: String,
77    pub input_schema: SchemaContract,
78    pub output_schema: SchemaContract,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
82pub enum LlmToolChoice {
83    #[default]
84    Auto,
85    None,
86    Required,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
90pub struct ProviderReplayMeta {
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub item_id: Option<String>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub opaque: Option<String>,
95}
96
97impl ProviderReplayMeta {
98    pub fn is_empty(&self) -> bool {
99        self.item_id.is_none() && self.opaque.is_none()
100    }
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
104pub struct ProviderReasoningReplay {
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub item_id: Option<String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub encrypted_content: Option<String>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub signature: Option<String>,
111    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112    pub redacted: bool,
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub summary: Vec<String>,
115}
116
117impl ProviderReasoningReplay {
118    pub fn is_empty(&self) -> bool {
119        self.item_id.is_none()
120            && self.encrypted_content.is_none()
121            && self.signature.is_none()
122            && !self.redacted
123            && self.summary.is_empty()
124    }
125}
126
127#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
128pub enum LlmOutputPart {
129    Text {
130        text: String,
131        response_meta: Option<ResponseTextMeta>,
132    },
133    /// Model "thinking" / reasoning output from providers that expose a
134    /// chain-of-thought channel.
135    ///
136    /// * `text` — human-readable summary for display.
137    /// * `replay` — opaque provider replay state. Provider crates decide
138    ///   how to map it back to their wire format on the next turn.
139    Reasoning {
140        text: String,
141        replay: Option<ProviderReasoningReplay>,
142    },
143    ToolCall {
144        call_id: String,
145        tool_name: String,
146        input_json: String,
147        /// Opaque provider replay state. Core may use `item_id` for stable
148        /// correlation, but provider crates own the wire semantics.
149        replay: Option<ProviderReplayMeta>,
150    },
151}
152
153#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
154pub enum LlmRole {
155    User,
156    Assistant,
157    System,
158}
159
160/// A structured content block inside an `LlmMessage`. Mirrors pi-mono's
161/// per-provider block types and maps cleanly onto each wire format so the
162/// adapters can emit the right shape without re-coalescing flat messages.
163#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
164pub enum LlmContentBlock {
165    Text {
166        text: Arc<str>,
167        response_meta: Option<ResponseTextMeta>,
168        cache_breakpoint: bool,
169    },
170    /// Index into the enclosing `LlmRequest.attachments` vector. User-role
171    /// messages may embed images; adapters drop them for providers that
172    /// don't accept vision input.
173    Image { attachment_idx: usize },
174    /// Assistant tool call with optional opaque provider replay state.
175    ToolCall {
176        call_id: String,
177        tool_name: String,
178        input_json: String,
179        replay: Option<ProviderReplayMeta>,
180    },
181    /// User tool-result block. Some providers allow multiple per user turn;
182    /// adapters that want one-per-message split as needed.
183    ToolResult {
184        call_id: String,
185        content: String,
186        /// Name of the tool that produced this result. Some provider replay
187        /// formats require this; others ignore it.
188        tool_name: Option<String>,
189    },
190    /// Chain-of-thought / reasoning block. See [`LlmOutputPart::Reasoning`]
191    /// for field semantics. Adapters that don't support reasoning replay
192    /// drop these blocks silently.
193    Reasoning {
194        text: String,
195        replay: Option<ProviderReasoningReplay>,
196    },
197}
198
199/// A single role turn in the LLM conversation. `blocks` holds structured
200/// content that maps 1:1 onto provider wire types. The old flat
201/// `content: String` + `kind` discriminator has been retired in favor of
202/// this block model.
203#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
204pub struct LlmMessage {
205    pub role: LlmRole,
206    pub blocks: Arc<Vec<LlmContentBlock>>,
207}
208
209impl LlmMessage {
210    pub fn new(role: LlmRole, blocks: Vec<LlmContentBlock>) -> Self {
211        Self {
212            role,
213            blocks: Arc::new(blocks),
214        }
215    }
216
217    /// Convenience constructor for a single-text-block message.
218    pub fn text(role: LlmRole, text: impl Into<Arc<str>>) -> Self {
219        Self {
220            role,
221            blocks: Arc::new(vec![LlmContentBlock::Text {
222                text: text.into(),
223                response_meta: None,
224                cache_breakpoint: false,
225            }]),
226        }
227    }
228
229    /// True if every block is a `Text` whose content is whitespace-only.
230    pub fn is_blank(&self) -> bool {
231        self.blocks.iter().all(|b| match b {
232            LlmContentBlock::Text { text, .. } => text.trim().is_empty(),
233            _ => false,
234        })
235    }
236}
237
238#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239pub struct LlmRequestScope {
240    /// Logical Lash session.
241    pub session_id: String,
242    /// Durable agent frame/branch inside the session. Providers must use this
243    /// when caching continuation state so frame switches do not inherit each
244    /// other's provider-local response ids.
245    pub agent_frame_id: String,
246    /// One provider call, suitable for request correlation/idempotency.
247    pub request_id: String,
248}
249
250impl LlmRequestScope {
251    pub fn new(
252        session_id: impl Into<String>,
253        agent_frame_id: impl Into<String>,
254        request_id: impl Into<String>,
255    ) -> Self {
256        Self {
257            session_id: session_id.into(),
258            agent_frame_id: agent_frame_id.into(),
259            request_id: request_id.into(),
260        }
261    }
262
263    pub fn continuation_key(&self) -> String {
264        format!("{}::{}", self.session_id, self.agent_frame_id)
265    }
266}
267
268#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
269pub struct LlmAttachment {
270    pub mime: String,
271    pub data: Vec<u8>,
272    pub reference: Option<AttachmentRef>,
273}
274
275impl LlmAttachment {
276    pub fn bytes(mime: impl Into<String>, data: Vec<u8>) -> Self {
277        Self {
278            mime: mime.into(),
279            data,
280            reference: None,
281        }
282    }
283
284    pub fn reference(reference: AttachmentRef) -> Self {
285        Self {
286            mime: reference.canonical_mime().to_string(),
287            data: Vec::new(),
288            reference: Some(reference),
289        }
290    }
291
292    pub fn is_resolved(&self) -> bool {
293        !self.data.is_empty() || self.reference.is_none()
294    }
295}
296
297#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
298pub struct LlmJsonSchema {
299    pub name: String,
300    pub schema: SchemaContract,
301    pub strict: bool,
302}
303
304#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
305pub enum LlmOutputSpec {
306    JsonObject,
307    JsonSchema(LlmJsonSchema),
308}
309
310#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct GenerationOptions {
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub output_token_cap: Option<NonZeroUsize>,
315}
316
317impl GenerationOptions {
318    pub fn output_token_cap_u64(&self) -> Option<u64> {
319        self.output_token_cap
320            .map(NonZeroUsize::get)
321            .map(|value| value as u64)
322    }
323}
324
325#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
326pub struct LlmRequest {
327    pub model: String,
328    pub messages: Vec<LlmMessage>,
329    pub attachments: Vec<LlmAttachment>,
330    pub tools: Arc<Vec<LlmToolSpec>>,
331    pub tool_choice: LlmToolChoice,
332    pub model_variant: Option<String>,
333    #[serde(default)]
334    pub generation: GenerationOptions,
335    pub scope: LlmRequestScope,
336    pub output_spec: Option<LlmOutputSpec>,
337    #[serde(default, skip)]
338    pub stream_events: Option<LlmEventSender>,
339    #[serde(default, skip)]
340    pub provider_trace: Option<LlmProviderTraceSender>,
341}
342
343impl LlmRequest {
344    pub fn session_id(&self) -> &str {
345        self.scope.session_id.as_str()
346    }
347
348    pub fn agent_frame_id(&self) -> &str {
349        self.scope.agent_frame_id.as_str()
350    }
351
352    pub fn request_id(&self) -> &str {
353        self.scope.request_id.as_str()
354    }
355
356    pub fn continuation_key(&self) -> String {
357        self.scope.continuation_key()
358    }
359}
360
361#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
362pub struct LlmUsage {
363    pub input_tokens: i64,
364    pub output_tokens: i64,
365    pub cached_input_tokens: i64,
366    pub reasoning_tokens: i64,
367}
368
369#[derive(Clone, Debug)]
370pub enum LlmStreamEvent {
371    /// Append-only visible assistant text. Providers must send only the new
372    /// suffix here; completed/cumulative message text belongs in `Part(Text)`.
373    Delta(String),
374    /// Incremental reasoning-summary text. Kept separate from `Delta` so
375    /// the UI can render it in a distinct muted/italic style rather than
376    /// mixing it into the assistant's final text.
377    ReasoningDelta(String),
378    /// Structured provider output state. Text parts reconcile final response
379    /// state and replay metadata; they are not live-visible text deltas.
380    Part(LlmOutputPart),
381    Usage(LlmUsage),
382    RetryStatus {
383        wait_seconds: u64,
384        attempt: usize,
385        max_attempts: usize,
386        reason: String,
387    },
388}
389
390#[derive(Clone)]
391pub struct LlmEventSender(Arc<dyn Fn(LlmStreamEvent) + Send + Sync>);
392
393impl LlmEventSender {
394    pub fn new<F>(send: F) -> Self
395    where
396        F: Fn(LlmStreamEvent) + Send + Sync + 'static,
397    {
398        Self(Arc::new(send))
399    }
400
401    pub fn send(&self, event: LlmStreamEvent) {
402        (self.0)(event);
403    }
404}
405
406#[derive(Clone, Debug)]
407pub struct LlmProviderTraceEvent {
408    pub provider: &'static str,
409    pub event_name: String,
410    pub raw: String,
411}
412
413#[derive(Clone)]
414pub struct LlmProviderTraceSender(Arc<dyn Fn(LlmProviderTraceEvent) + Send + Sync>);
415
416impl LlmProviderTraceSender {
417    pub fn new<F>(send: F) -> Self
418    where
419        F: Fn(LlmProviderTraceEvent) + Send + Sync + 'static,
420    {
421        Self(Arc::new(send))
422    }
423
424    pub fn send(&self, event: LlmProviderTraceEvent) {
425        (self.0)(event);
426    }
427}
428
429impl std::fmt::Debug for LlmProviderTraceSender {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        f.debug_struct("LlmProviderTraceSender")
432            .finish_non_exhaustive()
433    }
434}
435
436impl std::fmt::Debug for LlmEventSender {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        f.debug_struct("LlmEventSender").finish_non_exhaustive()
439    }
440}
441
442#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
443pub struct LlmResponse {
444    pub full_text: String,
445    pub parts: Vec<LlmOutputPart>,
446    pub usage: LlmUsage,
447    pub terminal_reason: LlmTerminalReason,
448    pub terminal_diagnostic: Option<String>,
449    pub provider_usage: Option<serde_json::Value>,
450    pub request_body: Option<String>,
451    pub http_summary: Option<String>,
452}
453
454#[derive(Clone, Debug)]
455pub struct ModelSelection {
456    pub model: &'static str,
457    pub variant: Option<&'static str>,
458}