Skip to main content

lash_remote_protocol/
llm.rs

1//! LLM request/response envelopes: messages, attachments, tool specs, output
2//! specs, provider metadata, and schema-projection contracts.
3
4use std::collections::HashMap;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::ensure_protocol_version;
10use crate::registry_errors::{RemoteProtocolError, require_non_empty};
11use crate::usage_activity::RemoteUsage;
12
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
14pub struct RemoteSchemaContract {
15    pub canonical: serde_json::Value,
16    #[serde(
17        default,
18        skip_serializing_if = "RemoteSchemaProjectionPolicy::is_default"
19    )]
20    pub projection: RemoteSchemaProjectionPolicy,
21}
22
23impl RemoteSchemaContract {
24    fn new(canonical: serde_json::Value) -> Self {
25        Self {
26            canonical,
27            projection: RemoteSchemaProjectionPolicy::default(),
28        }
29    }
30}
31
32impl Default for RemoteSchemaContract {
33    fn default() -> Self {
34        Self::new(serde_json::Value::Null)
35    }
36}
37
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39pub struct RemoteSchemaProjectionPolicy {
40    #[serde(default, skip_serializing_if = "RemoteProjectionMode::is_auto")]
41    pub mode: RemoteProjectionMode,
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub overrides: Vec<RemoteSchemaProjectionOverride>,
44}
45
46impl RemoteSchemaProjectionPolicy {
47    fn is_default(&self) -> bool {
48        self.mode == RemoteProjectionMode::Auto && self.overrides.is_empty()
49    }
50}
51
52impl Default for RemoteSchemaProjectionPolicy {
53    fn default() -> Self {
54        Self {
55            mode: RemoteProjectionMode::Auto,
56            overrides: Vec::new(),
57        }
58    }
59}
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62#[serde(rename_all = "snake_case")]
63pub enum RemoteProjectionMode {
64    #[default]
65    Auto,
66    ExplicitOnly,
67    Exact,
68}
69
70impl RemoteProjectionMode {
71    fn is_auto(&self) -> bool {
72        *self == Self::Auto
73    }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77pub struct RemoteSchemaProjectionOverride {
78    pub dialect: String,
79    pub schema: serde_json::Value,
80}
81
82pub(crate) fn default_remote_input_schema() -> RemoteSchemaContract {
83    RemoteSchemaContract::new(serde_json::json!({
84        "type": "object",
85        "properties": {},
86        "additionalProperties": true
87    }))
88}
89
90#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
91pub struct RemoteLlmRequest {
92    pub protocol_version: u32,
93    pub request_id: String,
94    pub scope: RemoteLlmRequestScope,
95    pub model_intent: RemoteModelIntent,
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub messages: Vec<RemoteLlmMessage>,
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub attachments: Vec<RemoteLlmAttachment>,
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub tools: Vec<RemoteLlmToolSpec>,
102    #[serde(default)]
103    pub tool_choice: RemoteLlmToolChoice,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub output_spec: Option<RemoteLlmOutputSpec>,
106    #[serde(default, skip_serializing_if = "RemoteGenerationOptions::is_empty")]
107    pub generation: RemoteGenerationOptions,
108    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
109    pub metadata: HashMap<String, serde_json::Value>,
110}
111
112impl RemoteLlmRequest {
113    pub fn validate(&self) -> Result<(), RemoteProtocolError> {
114        ensure_protocol_version(self.protocol_version)?;
115        require_non_empty("RemoteLlmRequest", "request_id", &self.request_id)?;
116        self.scope.validate()?;
117        self.model_intent.validate()?;
118        self.generation.validate("RemoteLlmRequest")?;
119        for (index, message) in self.messages.iter().enumerate() {
120            message.validate(index)?;
121        }
122        for (index, attachment) in self.attachments.iter().enumerate() {
123            attachment.validate(index)?;
124        }
125        for tool in &self.tools {
126            tool.validate()?;
127        }
128        if let Some(output_spec) = &self.output_spec {
129            output_spec.validate()?;
130        }
131        Ok(())
132    }
133}
134
135#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
136pub struct RemoteLlmResponse {
137    pub protocol_version: u32,
138    pub request_id: String,
139    #[serde(default)]
140    pub full_text: String,
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub output_parts: Vec<RemoteLlmOutputPart>,
143    #[serde(default)]
144    pub usage: RemoteUsage,
145    #[serde(default)]
146    pub terminal_reason: RemoteLlmTerminalReason,
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub diagnostics: Vec<RemoteDiagnostic>,
149    #[serde(default, skip_serializing_if = "RemoteProviderMetadata::is_empty")]
150    pub provider_metadata: RemoteProviderMetadata,
151}
152
153impl RemoteLlmResponse {
154    pub fn validate(&self) -> Result<(), RemoteProtocolError> {
155        ensure_protocol_version(self.protocol_version)?;
156        require_non_empty("RemoteLlmResponse", "request_id", &self.request_id)?;
157        Ok(())
158    }
159}
160
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162pub struct RemoteModelIntent {
163    pub model: String,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub variant: Option<String>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub provider: Option<String>,
168    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
169    pub metadata: HashMap<String, String>,
170}
171
172impl RemoteModelIntent {
173    pub fn new(model: impl Into<String>) -> Self {
174        Self {
175            model: model.into(),
176            variant: None,
177            provider: None,
178            metadata: HashMap::new(),
179        }
180    }
181
182    pub(crate) fn validate(&self) -> Result<(), RemoteProtocolError> {
183        require_non_empty("RemoteModelIntent", "model", &self.model)
184    }
185}
186
187#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188pub struct RemoteGenerationOptions {
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub output_token_cap: Option<u64>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub temperature: Option<String>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub top_p: Option<String>,
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub stop: Vec<String>,
197    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
198    pub provider_options: HashMap<String, String>,
199}
200
201impl RemoteGenerationOptions {
202    pub fn is_empty(&self) -> bool {
203        self.output_token_cap.is_none()
204            && self.temperature.is_none()
205            && self.top_p.is_none()
206            && self.stop.is_empty()
207            && self.provider_options.is_empty()
208    }
209
210    pub(crate) fn validate(&self, type_name: &'static str) -> Result<(), RemoteProtocolError> {
211        if self.output_token_cap == Some(0) {
212            return Err(RemoteProtocolError::InvalidEnvelope {
213                type_name,
214                message: "generation.output_token_cap must be greater than zero".to_string(),
215            });
216        }
217        Ok(())
218    }
219}
220
221#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
222pub struct RemoteLlmRequestScope {
223    pub session_id: String,
224    pub agent_frame_id: String,
225    pub request_id: String,
226}
227
228impl RemoteLlmRequestScope {
229    pub fn new(
230        session_id: impl Into<String>,
231        agent_frame_id: impl Into<String>,
232        request_id: impl Into<String>,
233    ) -> Self {
234        Self {
235            session_id: session_id.into(),
236            agent_frame_id: agent_frame_id.into(),
237            request_id: request_id.into(),
238        }
239    }
240
241    fn validate(&self) -> Result<(), RemoteProtocolError> {
242        require_non_empty("RemoteLlmRequestScope", "session_id", &self.session_id)?;
243        require_non_empty(
244            "RemoteLlmRequestScope",
245            "agent_frame_id",
246            &self.agent_frame_id,
247        )?;
248        require_non_empty("RemoteLlmRequestScope", "request_id", &self.request_id)?;
249        Ok(())
250    }
251}
252
253#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
254#[serde(rename_all = "snake_case")]
255pub enum RemoteLlmRole {
256    #[default]
257    User,
258    Assistant,
259    System,
260}
261
262#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
263pub struct RemoteLlmMessage {
264    pub role: RemoteLlmRole,
265    #[serde(default, skip_serializing_if = "Vec::is_empty")]
266    pub content: Vec<RemoteLlmContentBlock>,
267}
268
269impl RemoteLlmMessage {
270    fn validate(&self, index: usize) -> Result<(), RemoteProtocolError> {
271        if self.content.is_empty() {
272            return Err(RemoteProtocolError::InvalidEnvelope {
273                type_name: "RemoteLlmMessage",
274                message: format!("message at index {index} must contain at least one block"),
275            });
276        }
277        for block in &self.content {
278            block.validate()?;
279        }
280        Ok(())
281    }
282}
283
284#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
285#[serde(tag = "type", rename_all = "snake_case")]
286pub enum RemoteLlmContentBlock {
287    Text {
288        text: String,
289        #[serde(default, skip_serializing_if = "Option::is_none")]
290        response_meta: Option<RemoteResponseTextMeta>,
291        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
292        cache_breakpoint: bool,
293    },
294    ImageAttachment {
295        attachment_index: usize,
296    },
297    ToolCall {
298        call_id: String,
299        tool_name: String,
300        input_json: String,
301        #[serde(default, skip_serializing_if = "Option::is_none")]
302        replay: Option<RemoteProviderReplayMeta>,
303    },
304    ToolResult {
305        call_id: String,
306        content: String,
307        #[serde(default, skip_serializing_if = "Option::is_none")]
308        tool_name: Option<String>,
309    },
310    Reasoning {
311        text: String,
312        #[serde(default, skip_serializing_if = "Option::is_none")]
313        replay: Option<RemoteProviderReasoningReplay>,
314    },
315}
316
317impl RemoteLlmContentBlock {
318    fn validate(&self) -> Result<(), RemoteProtocolError> {
319        match self {
320            Self::ToolCall {
321                call_id, tool_name, ..
322            } => {
323                require_non_empty("RemoteLlmContentBlock::ToolCall", "call_id", call_id)?;
324                require_non_empty("RemoteLlmContentBlock::ToolCall", "tool_name", tool_name)
325            }
326            Self::ToolResult { call_id, .. } => {
327                require_non_empty("RemoteLlmContentBlock::ToolResult", "call_id", call_id)
328            }
329            Self::Text { .. } | Self::ImageAttachment { .. } | Self::Reasoning { .. } => Ok(()),
330        }
331    }
332}
333
334#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
335pub struct RemoteResponseTextMeta {
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub id: Option<String>,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub status: Option<String>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub phase: Option<String>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub provider_payload: Option<String>,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub origin_provider: Option<String>,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub origin_model: Option<String>,
348}
349
350#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
351pub struct RemoteProviderReplayMeta {
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub item_id: Option<String>,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub opaque: Option<String>,
356}
357
358#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
359pub struct RemoteProviderReasoningReplay {
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub item_id: Option<String>,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub encrypted_content: Option<String>,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub signature: Option<String>,
366    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
367    pub redacted: bool,
368    #[serde(default, skip_serializing_if = "Vec::is_empty")]
369    pub summary: Vec<String>,
370}
371
372#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
373pub struct RemoteLlmAttachment {
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub id: Option<String>,
376    pub mime: String,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub data_base64: Option<String>,
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub reference: Option<RemoteAttachmentRef>,
381    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
382    pub metadata: HashMap<String, String>,
383}
384
385impl RemoteLlmAttachment {
386    fn validate(&self, index: usize) -> Result<(), RemoteProtocolError> {
387        if self.mime.trim().is_empty() {
388            return Err(RemoteProtocolError::InvalidEnvelope {
389                type_name: "RemoteLlmAttachment",
390                message: format!("attachment at index {index} requires a non-empty mime"),
391            });
392        }
393        if let Some(reference) = &self.reference {
394            reference.validate()?;
395        }
396        Ok(())
397    }
398}
399
400#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
401pub struct RemoteAttachmentRef {
402    pub id: String,
403    pub mime: String,
404    pub byte_len: u64,
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub width: Option<u32>,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub height: Option<u32>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub label: Option<String>,
411    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
412    pub metadata: HashMap<String, String>,
413}
414
415impl RemoteAttachmentRef {
416    pub(crate) fn validate(&self) -> Result<(), RemoteProtocolError> {
417        require_non_empty("RemoteAttachmentRef", "id", &self.id)?;
418        require_non_empty("RemoteAttachmentRef", "mime", &self.mime)
419    }
420}
421
422#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
423pub struct RemoteLlmToolSpec {
424    pub name: String,
425    #[serde(default)]
426    pub description: String,
427    #[serde(default = "default_remote_input_schema")]
428    pub input_schema: RemoteSchemaContract,
429    #[serde(default)]
430    pub output_schema: RemoteSchemaContract,
431}
432
433impl RemoteLlmToolSpec {
434    pub(crate) fn validate(&self) -> Result<(), RemoteProtocolError> {
435        require_non_empty("RemoteLlmToolSpec", "name", &self.name)
436    }
437}
438
439#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
440#[serde(rename_all = "snake_case")]
441pub enum RemoteLlmToolChoice {
442    #[default]
443    Auto,
444    None,
445    Required,
446}
447
448#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
449#[serde(tag = "type", rename_all = "snake_case")]
450pub enum RemoteLlmOutputSpec {
451    JsonObject,
452    JsonSchema {
453        name: String,
454        schema: RemoteSchemaContract,
455        strict: bool,
456    },
457}
458
459impl RemoteLlmOutputSpec {
460    fn validate(&self) -> Result<(), RemoteProtocolError> {
461        match self {
462            Self::JsonObject => Ok(()),
463            Self::JsonSchema { name, .. } => {
464                require_non_empty("RemoteLlmOutputSpec::JsonSchema", "name", name)
465            }
466        }
467    }
468}
469
470#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
471#[serde(tag = "type", rename_all = "snake_case")]
472pub enum RemoteLlmOutputPart {
473    Text {
474        text: String,
475        #[serde(default, skip_serializing_if = "Option::is_none")]
476        response_meta: Option<RemoteResponseTextMeta>,
477    },
478    Reasoning {
479        text: String,
480        #[serde(default, skip_serializing_if = "Option::is_none")]
481        replay: Option<RemoteProviderReasoningReplay>,
482    },
483    ToolCall {
484        call_id: String,
485        tool_name: String,
486        input_json: String,
487        #[serde(default, skip_serializing_if = "Option::is_none")]
488        replay: Option<RemoteProviderReplayMeta>,
489    },
490}
491
492#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
493#[serde(rename_all = "snake_case")]
494pub enum RemoteLlmTerminalReason {
495    Stop,
496    ToolUse,
497    OutputLimit,
498    ContextOverflow,
499    ContentFilter,
500    ProviderError,
501    Cancelled,
502    #[default]
503    Unknown,
504}
505
506/// Wire mirror of the core `ProviderFailureKind` classification.
507#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
508#[serde(rename_all = "snake_case")]
509pub enum RemoteProviderFailureKind {
510    Transport,
511    Timeout,
512    Http,
513    Stream,
514    Auth,
515    Validation,
516    Quota,
517    Unsupported,
518    #[default]
519    #[serde(other)]
520    Unknown,
521}
522
523#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
524pub struct RemoteProviderMetadata {
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub usage: Option<serde_json::Value>,
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub request_body: Option<String>,
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub http_summary: Option<String>,
531    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
532    pub data: HashMap<String, serde_json::Value>,
533}
534
535impl RemoteProviderMetadata {
536    pub fn is_empty(&self) -> bool {
537        self.usage.is_none()
538            && self.request_body.is_none()
539            && self.http_summary.is_none()
540            && self.data.is_empty()
541    }
542}
543
544#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
545pub struct RemoteDiagnostic {
546    pub kind: String,
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub code: Option<String>,
549    pub message: String,
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub data: Option<serde_json::Value>,
552}