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::{BTreeMap, 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)]
165    pub variant: RemoteReasoningSelection,
166    /// Host-supplied capability metadata for the model (mirrors the core
167    /// `ModelCapability` contract).
168    #[serde(default, skip_serializing_if = "RemoteModelCapability::is_empty")]
169    pub capability: RemoteModelCapability,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub provider: Option<String>,
172    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
173    pub metadata: HashMap<String, String>,
174}
175
176/// Mirror of the core `ModelCapability`: host-supplied model capability
177/// metadata carried with the model intent so remote workers validate and
178/// encode effort exactly like a local runtime.
179#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
180pub struct RemoteModelCapability {
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub reasoning: Option<RemoteReasoningCapability>,
183}
184
185impl RemoteModelCapability {
186    pub fn is_empty(&self) -> bool {
187        self.reasoning.is_none()
188    }
189}
190
191/// Mirror of the core `ReasoningCapability`.
192#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
193pub struct RemoteReasoningCapability {
194    #[serde(default)]
195    pub efforts: Vec<String>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub default_effort: Option<String>,
198    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
199    pub aliases: BTreeMap<String, String>,
200    #[serde(default)]
201    pub encoding: RemoteReasoningEncoding,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub disable: Option<RemoteReasoningDisableEncoding>,
204    #[serde(default)]
205    pub mandatory: bool,
206}
207
208#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
209#[serde(rename_all = "snake_case")]
210pub enum RemoteReasoningSelection {
211    #[default]
212    ProviderDefault,
213    Disabled,
214    Effort(String),
215}
216
217#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218#[serde(rename_all = "snake_case")]
219pub enum RemoteReasoningDisableEncoding {
220    Native,
221    Omit,
222    Effort(String),
223    Budget(u32),
224    ToggleFalse,
225}
226
227/// Mirror of the core `ReasoningEncoding`.
228#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
229#[serde(rename_all = "snake_case")]
230pub enum RemoteReasoningEncoding {
231    #[default]
232    Effort,
233    Budget(BTreeMap<String, u32>),
234}
235
236impl RemoteModelIntent {
237    pub fn new(model: impl Into<String>) -> Self {
238        Self {
239            model: model.into(),
240            variant: RemoteReasoningSelection::ProviderDefault,
241            capability: RemoteModelCapability::default(),
242            provider: None,
243            metadata: HashMap::new(),
244        }
245    }
246
247    pub(crate) fn validate(&self) -> Result<(), RemoteProtocolError> {
248        require_non_empty("RemoteModelIntent", "model", &self.model)
249    }
250}
251
252#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
253pub struct RemoteGenerationOptions {
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub output_token_cap: Option<u64>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub temperature: Option<String>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub top_p: Option<String>,
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub stop: Vec<String>,
262    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
263    pub provider_options: HashMap<String, String>,
264}
265
266impl RemoteGenerationOptions {
267    pub fn is_empty(&self) -> bool {
268        self.output_token_cap.is_none()
269            && self.temperature.is_none()
270            && self.top_p.is_none()
271            && self.stop.is_empty()
272            && self.provider_options.is_empty()
273    }
274
275    pub(crate) fn validate(&self, type_name: &'static str) -> Result<(), RemoteProtocolError> {
276        if self.output_token_cap == Some(0) {
277            return Err(RemoteProtocolError::InvalidEnvelope {
278                type_name,
279                message: "generation.output_token_cap must be greater than zero".to_string(),
280            });
281        }
282        Ok(())
283    }
284}
285
286#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
287pub struct RemoteLlmRequestScope {
288    pub session_id: String,
289    pub agent_frame_id: String,
290    pub request_id: String,
291}
292
293impl RemoteLlmRequestScope {
294    pub fn new(
295        session_id: impl Into<String>,
296        agent_frame_id: impl Into<String>,
297        request_id: impl Into<String>,
298    ) -> Self {
299        Self {
300            session_id: session_id.into(),
301            agent_frame_id: agent_frame_id.into(),
302            request_id: request_id.into(),
303        }
304    }
305
306    fn validate(&self) -> Result<(), RemoteProtocolError> {
307        require_non_empty("RemoteLlmRequestScope", "session_id", &self.session_id)?;
308        require_non_empty(
309            "RemoteLlmRequestScope",
310            "agent_frame_id",
311            &self.agent_frame_id,
312        )?;
313        require_non_empty("RemoteLlmRequestScope", "request_id", &self.request_id)?;
314        Ok(())
315    }
316}
317
318#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
319#[serde(rename_all = "snake_case")]
320pub enum RemoteLlmRole {
321    #[default]
322    User,
323    Assistant,
324    System,
325}
326
327#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
328pub struct RemoteLlmMessage {
329    pub role: RemoteLlmRole,
330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
331    pub content: Vec<RemoteLlmContentBlock>,
332}
333
334impl RemoteLlmMessage {
335    fn validate(&self, index: usize) -> Result<(), RemoteProtocolError> {
336        if self.content.is_empty() {
337            return Err(RemoteProtocolError::InvalidEnvelope {
338                type_name: "RemoteLlmMessage",
339                message: format!("message at index {index} must contain at least one block"),
340            });
341        }
342        for block in &self.content {
343            block.validate()?;
344        }
345        Ok(())
346    }
347}
348
349#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
350#[serde(tag = "type", rename_all = "snake_case")]
351pub enum RemoteLlmContentBlock {
352    Text {
353        text: String,
354        #[serde(default, skip_serializing_if = "Option::is_none")]
355        response_meta: Option<RemoteResponseTextMeta>,
356        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
357        cache_breakpoint: bool,
358    },
359    ImageAttachment {
360        attachment_index: usize,
361    },
362    ToolCall {
363        call_id: String,
364        tool_name: String,
365        input_json: String,
366        #[serde(default, skip_serializing_if = "Option::is_none")]
367        replay: Option<RemoteProviderReplayMeta>,
368    },
369    ToolResult {
370        call_id: String,
371        content: String,
372        #[serde(default, skip_serializing_if = "Option::is_none")]
373        tool_name: Option<String>,
374    },
375    Reasoning {
376        text: String,
377        #[serde(default, skip_serializing_if = "Option::is_none")]
378        replay: Option<RemoteProviderReasoningReplay>,
379    },
380}
381
382impl RemoteLlmContentBlock {
383    fn validate(&self) -> Result<(), RemoteProtocolError> {
384        match self {
385            Self::ToolCall {
386                call_id, tool_name, ..
387            } => {
388                require_non_empty("RemoteLlmContentBlock::ToolCall", "call_id", call_id)?;
389                require_non_empty("RemoteLlmContentBlock::ToolCall", "tool_name", tool_name)
390            }
391            Self::ToolResult { call_id, .. } => {
392                require_non_empty("RemoteLlmContentBlock::ToolResult", "call_id", call_id)
393            }
394            Self::Text { .. } | Self::ImageAttachment { .. } | Self::Reasoning { .. } => Ok(()),
395        }
396    }
397}
398
399#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
400pub struct RemoteResponseTextMeta {
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub id: Option<String>,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub status: Option<String>,
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub phase: Option<String>,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub provider_payload: Option<String>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub origin_provider: Option<String>,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub origin_model: Option<String>,
413}
414
415#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
416pub struct RemoteProviderReplayMeta {
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub item_id: Option<String>,
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub opaque: Option<String>,
421}
422
423#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
424pub struct RemoteProviderReasoningReplay {
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub item_id: Option<String>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub encrypted_content: Option<String>,
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub signature: Option<String>,
431    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
432    pub redacted: bool,
433    #[serde(default, skip_serializing_if = "Vec::is_empty")]
434    pub summary: Vec<String>,
435}
436
437#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
438pub struct RemoteLlmAttachment {
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub id: Option<String>,
441    pub mime: String,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub data_base64: Option<String>,
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub reference: Option<RemoteAttachmentRef>,
446    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
447    pub metadata: HashMap<String, String>,
448}
449
450impl RemoteLlmAttachment {
451    fn validate(&self, index: usize) -> Result<(), RemoteProtocolError> {
452        if self.mime.trim().is_empty() {
453            return Err(RemoteProtocolError::InvalidEnvelope {
454                type_name: "RemoteLlmAttachment",
455                message: format!("attachment at index {index} requires a non-empty mime"),
456            });
457        }
458        if let Some(reference) = &self.reference {
459            reference.validate()?;
460        }
461        Ok(())
462    }
463}
464
465#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
466pub struct RemoteAttachmentRef {
467    pub id: String,
468    pub mime: String,
469    pub byte_len: u64,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub width: Option<u32>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub height: Option<u32>,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub label: Option<String>,
476    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
477    pub metadata: HashMap<String, String>,
478}
479
480impl RemoteAttachmentRef {
481    pub(crate) fn validate(&self) -> Result<(), RemoteProtocolError> {
482        require_non_empty("RemoteAttachmentRef", "id", &self.id)?;
483        require_non_empty("RemoteAttachmentRef", "mime", &self.mime)
484    }
485}
486
487#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
488pub struct RemoteLlmToolSpec {
489    pub name: String,
490    #[serde(default)]
491    pub description: String,
492    #[serde(default = "default_remote_input_schema")]
493    pub input_schema: RemoteSchemaContract,
494    #[serde(default)]
495    pub output_schema: RemoteSchemaContract,
496}
497
498impl RemoteLlmToolSpec {
499    pub(crate) fn validate(&self) -> Result<(), RemoteProtocolError> {
500        require_non_empty("RemoteLlmToolSpec", "name", &self.name)
501    }
502}
503
504#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
505#[serde(rename_all = "snake_case")]
506pub enum RemoteLlmToolChoice {
507    #[default]
508    Auto,
509    None,
510    Required,
511}
512
513#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
514#[serde(tag = "type", rename_all = "snake_case")]
515pub enum RemoteLlmOutputSpec {
516    JsonObject,
517    JsonSchema {
518        name: String,
519        schema: RemoteSchemaContract,
520        strict: bool,
521    },
522}
523
524impl RemoteLlmOutputSpec {
525    fn validate(&self) -> Result<(), RemoteProtocolError> {
526        match self {
527            Self::JsonObject => Ok(()),
528            Self::JsonSchema { name, .. } => {
529                require_non_empty("RemoteLlmOutputSpec::JsonSchema", "name", name)
530            }
531        }
532    }
533}
534
535#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
536#[serde(tag = "type", rename_all = "snake_case")]
537pub enum RemoteLlmOutputPart {
538    Text {
539        text: String,
540        #[serde(default, skip_serializing_if = "Option::is_none")]
541        response_meta: Option<RemoteResponseTextMeta>,
542    },
543    Reasoning {
544        text: String,
545        #[serde(default, skip_serializing_if = "Option::is_none")]
546        replay: Option<RemoteProviderReasoningReplay>,
547    },
548    ToolCall {
549        call_id: String,
550        tool_name: String,
551        input_json: String,
552        #[serde(default, skip_serializing_if = "Option::is_none")]
553        replay: Option<RemoteProviderReplayMeta>,
554    },
555}
556
557#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
558#[serde(rename_all = "snake_case")]
559pub enum RemoteLlmTerminalReason {
560    Stop,
561    ToolUse,
562    OutputLimit,
563    ContextOverflow,
564    ContentFilter,
565    ProviderError,
566    Cancelled,
567    #[default]
568    Unknown,
569}
570
571/// Wire mirror of the core `ProviderFailureKind` classification.
572#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
573#[serde(rename_all = "snake_case")]
574pub enum RemoteProviderFailureKind {
575    Transport,
576    Timeout,
577    Http,
578    Stream,
579    Auth,
580    Validation,
581    Quota,
582    Unsupported,
583    #[default]
584    #[serde(other)]
585    Unknown,
586}
587
588#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
589pub struct RemoteProviderMetadata {
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub usage: Option<serde_json::Value>,
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub request_body: Option<String>,
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub http_summary: Option<String>,
596    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
597    pub data: HashMap<String, serde_json::Value>,
598}
599
600impl RemoteProviderMetadata {
601    pub fn is_empty(&self) -> bool {
602        self.usage.is_none()
603            && self.request_body.is_none()
604            && self.http_summary.is_none()
605            && self.data.is_empty()
606    }
607}
608
609#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
610pub struct RemoteDiagnostic {
611    pub kind: String,
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub code: Option<String>,
614    pub message: String,
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub data: Option<serde_json::Value>,
617}