Skip to main content

elph_ai/types/
mod.rs

1//! Core types for elph-ai provider streaming.
2//!
3//! Full type definitions will be expanded separately; this module provides the
4//! contract assumed by the API implementation layer.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Arc;
13
14use crate::utils::event_stream::AssistantMessageEventStream;
15
16pub type Api = String;
17pub type ProviderId = String;
18pub type ImagesApi = String;
19pub type ImagesProviderId = String;
20pub type ProviderEnv = HashMap<String, String>;
21pub type ProviderHeaders = HashMap<String, Option<String>>;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum ThinkingLevel {
26    Minimal,
27    Low,
28    Medium,
29    High,
30    Xhigh,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ModelThinkingLevel {
35    Off,
36    Level(ThinkingLevel),
37}
38
39pub type ThinkingLevelMap = HashMap<String, Option<String>>;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum CacheRetention {
44    None,
45    Short,
46    Long,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum Transport {
52    Sse,
53    Websocket,
54    #[serde(rename = "websocket-cached")]
55    WebsocketCached,
56    Auto,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ThinkingBudgets {
61    pub minimal: Option<u32>,
62    pub low: Option<u32>,
63    pub medium: Option<u32>,
64    pub high: Option<u32>,
65}
66
67#[derive(Debug, Clone)]
68pub struct ProviderResponse {
69    pub status: u16,
70    pub headers: HashMap<String, String>,
71}
72
73#[derive(Clone, Default)]
74pub struct StreamOptions {
75    pub temperature: Option<f64>,
76    pub max_tokens: Option<u32>,
77    pub api_key: Option<String>,
78    pub transport: Option<Transport>,
79    pub cache_retention: Option<CacheRetention>,
80    pub session_id: Option<String>,
81    pub headers: Option<ProviderHeaders>,
82    pub timeout_ms: Option<u64>,
83    pub websocket_connect_timeout_ms: Option<u64>,
84    pub max_retries: Option<u32>,
85    pub max_retry_delay_ms: Option<u64>,
86    pub metadata: Option<HashMap<String, Value>>,
87    pub env: Option<ProviderEnv>,
88    pub on_payload: Option<OnPayloadCallback>,
89    pub on_response: Option<OnResponseCallback>,
90    pub signal: Option<tokio_util::sync::CancellationToken>,
91}
92
93pub type OnPayloadCallback =
94    Arc<dyn Fn(Value, Model) -> Pin<Box<dyn Future<Output = Option<Value>> + Send>> + Send + Sync>;
95pub type OnResponseCallback =
96    Arc<dyn Fn(ProviderResponse, Model) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
97
98#[derive(Clone)]
99pub struct SimpleStreamOptions {
100    pub base: StreamOptions,
101    pub reasoning: Option<ThinkingLevel>,
102    pub thinking_budgets: Option<ThinkingBudgets>,
103}
104
105impl SimpleStreamOptions {
106    pub fn from_stream(options: StreamOptions) -> Self {
107        Self {
108            base: options,
109            reasoning: None,
110            thinking_budgets: None,
111        }
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
116pub struct TextContent {
117    #[serde(rename = "type")]
118    pub kind: String,
119    pub text: String,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub text_signature: Option<String>,
122}
123
124impl TextContent {
125    pub fn new(text: impl Into<String>) -> Self {
126        Self {
127            kind: "text".to_string(),
128            text: text.into(),
129            text_signature: None,
130        }
131    }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
135pub struct ThinkingContent {
136    #[serde(rename = "type")]
137    pub kind: String,
138    pub thinking: String,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub thinking_signature: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub redacted: Option<bool>,
143}
144
145impl ThinkingContent {
146    pub fn new(thinking: impl Into<String>) -> Self {
147        Self {
148            kind: "thinking".to_string(),
149            thinking: thinking.into(),
150            thinking_signature: None,
151            redacted: None,
152        }
153    }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
157pub struct ImageContent {
158    #[serde(rename = "type")]
159    pub kind: String,
160    pub data: String,
161    pub mime_type: String,
162}
163
164impl ImageContent {
165    pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
166        Self {
167            kind: "image".to_string(),
168            data: data.into(),
169            mime_type: mime_type.into(),
170        }
171    }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
175pub struct ToolCall {
176    #[serde(rename = "type")]
177    pub kind: String,
178    pub id: String,
179    pub name: String,
180    pub arguments: Value,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub thought_signature: Option<String>,
183}
184
185impl ToolCall {
186    pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
187        Self {
188            kind: "toolCall".to_string(),
189            id: id.into(),
190            name: name.into(),
191            arguments,
192            thought_signature: None,
193        }
194    }
195}
196
197#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198pub struct UsageCost {
199    pub input: f64,
200    pub output: f64,
201    pub cache_read: f64,
202    pub cache_write: f64,
203    pub total: f64,
204}
205
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct Usage {
208    pub input: u64,
209    pub output: u64,
210    pub cache_read: u64,
211    pub cache_write: u64,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub cache_write_1h: Option<u64>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub reasoning: Option<u64>,
216    pub total_tokens: u64,
217    pub cost: UsageCost,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase")]
222pub enum StopReason {
223    Stop,
224    Length,
225    #[serde(rename = "toolUse")]
226    ToolUse,
227    Error,
228    Aborted,
229}
230
231#[allow(clippy::large_enum_variant)]
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(tag = "role", rename_all = "camelCase")]
234pub enum Message {
235    User {
236        content: UserContent,
237        timestamp: i64,
238    },
239    Assistant(AssistantMessage),
240    ToolResult {
241        tool_call_id: String,
242        tool_name: String,
243        content: Vec<ContentBlock>,
244        #[serde(skip_serializing_if = "Option::is_none")]
245        details: Option<Value>,
246        is_error: bool,
247        timestamp: i64,
248    },
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252#[serde(untagged)]
253pub enum UserContent {
254    Text(String),
255    Blocks(Vec<ContentBlock>),
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(tag = "type", rename_all = "camelCase")]
260pub enum ContentBlock {
261    #[serde(rename = "text")]
262    Text { text: String },
263    #[serde(rename = "image")]
264    Image { data: String, mime_type: String },
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct AssistantMessage {
269    pub role: String,
270    pub content: Vec<AssistantContentBlock>,
271    pub api: Api,
272    pub provider: ProviderId,
273    pub model: String,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub response_model: Option<String>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub response_id: Option<String>,
278    pub usage: Usage,
279    pub stop_reason: StopReason,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub error_message: Option<String>,
282    pub timestamp: i64,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(untagged)]
287pub enum AssistantContentBlock {
288    Text(TextContent),
289    Thinking(ThinkingContent),
290    ToolCall(ToolCall),
291}
292
293impl AssistantMessage {
294    pub fn empty(model: &Model) -> Self {
295        Self {
296            role: "assistant".to_string(),
297            content: vec![],
298            api: model.api.clone(),
299            provider: model.provider.clone(),
300            model: model.id.clone(),
301            response_model: None,
302            response_id: None,
303            usage: Usage::default(),
304            stop_reason: StopReason::Stop,
305            error_message: None,
306            timestamp: chrono::Utc::now().timestamp_millis(),
307        }
308    }
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
312pub struct Tool {
313    pub name: String,
314    pub description: String,
315    pub parameters: Value,
316}
317
318#[derive(Debug, Clone)]
319pub struct Context {
320    pub system_prompt: Option<String>,
321    pub messages: Vec<Message>,
322    pub tools: Option<Vec<Tool>>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct TextSignatureV1 {
327    pub v: u8,
328    pub id: String,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub phase: Option<String>,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(tag = "type", rename_all = "snake_case")]
335pub enum AssistantMessageEvent {
336    Start {
337        partial: AssistantMessage,
338    },
339    TextStart {
340        content_index: usize,
341        partial: AssistantMessage,
342    },
343    TextDelta {
344        content_index: usize,
345        delta: String,
346        partial: AssistantMessage,
347    },
348    TextEnd {
349        content_index: usize,
350        content: String,
351        partial: AssistantMessage,
352    },
353    ThinkingStart {
354        content_index: usize,
355        partial: AssistantMessage,
356    },
357    ThinkingDelta {
358        content_index: usize,
359        delta: String,
360        partial: AssistantMessage,
361    },
362    ThinkingEnd {
363        content_index: usize,
364        content: String,
365        partial: AssistantMessage,
366    },
367    ToolcallStart {
368        content_index: usize,
369        partial: AssistantMessage,
370    },
371    ToolcallDelta {
372        content_index: usize,
373        delta: String,
374        partial: AssistantMessage,
375    },
376    ToolcallEnd {
377        content_index: usize,
378        tool_call: ToolCall,
379        partial: AssistantMessage,
380    },
381    Done {
382        reason: StopReason,
383        message: AssistantMessage,
384    },
385    Error {
386        reason: StopReason,
387        error: AssistantMessage,
388    },
389}
390
391#[derive(Debug, Clone, Default, Serialize, Deserialize)]
392#[serde(rename_all = "camelCase")]
393pub struct OpenAICompletionsCompat {
394    pub supports_store: Option<bool>,
395    pub supports_developer_role: Option<bool>,
396    pub supports_reasoning_effort: Option<bool>,
397    pub supports_usage_in_streaming: Option<bool>,
398    pub max_tokens_field: Option<String>,
399    pub requires_tool_result_name: Option<bool>,
400    pub requires_assistant_after_tool_result: Option<bool>,
401    pub requires_thinking_as_text: Option<bool>,
402    pub requires_reasoning_content_on_assistant_messages: Option<bool>,
403    pub thinking_format: Option<String>,
404    pub zai_tool_stream: Option<bool>,
405    pub supports_strict_mode: Option<bool>,
406    pub cache_control_format: Option<String>,
407    pub send_session_affinity_headers: Option<bool>,
408    pub supports_long_cache_retention: Option<bool>,
409}
410
411#[derive(Debug, Clone, Default, Serialize, Deserialize)]
412#[serde(rename_all = "camelCase")]
413pub struct OpenAIResponsesCompat {
414    pub supports_developer_role: Option<bool>,
415    pub send_session_id_header: Option<bool>,
416    pub supports_long_cache_retention: Option<bool>,
417}
418
419#[derive(Debug, Clone, Default, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct AnthropicMessagesCompat {
422    pub supports_eager_tool_input_streaming: Option<bool>,
423    pub supports_long_cache_retention: Option<bool>,
424    pub send_session_affinity_headers: Option<bool>,
425    pub supports_cache_control_on_tools: Option<bool>,
426    pub supports_temperature: Option<bool>,
427    pub force_adaptive_thinking: Option<bool>,
428    pub allow_empty_signature: Option<bool>,
429}
430
431#[derive(Debug, Clone)]
432pub struct Model {
433    pub id: String,
434    pub name: String,
435    pub api: Api,
436    pub provider: ProviderId,
437    pub base_url: String,
438    pub reasoning: bool,
439    pub thinking_level_map: Option<ThinkingLevelMap>,
440    pub input: Vec<String>,
441    pub cost: ModelCost,
442    pub context_window: u32,
443    pub max_tokens: u32,
444    pub headers: Option<HashMap<String, String>>,
445    pub openai_completions_compat: Option<OpenAICompletionsCompat>,
446    pub openai_responses_compat: Option<OpenAIResponsesCompat>,
447    pub anthropic_compat: Option<AnthropicMessagesCompat>,
448}
449
450#[derive(Debug, Clone, Copy)]
451pub struct ModelCost {
452    pub input: f64,
453    pub output: f64,
454    pub cache_read: f64,
455    pub cache_write: f64,
456}
457
458#[derive(Debug, Clone)]
459pub struct ImagesModel {
460    pub id: String,
461    pub name: String,
462    pub api: ImagesApi,
463    pub provider: ImagesProviderId,
464    pub base_url: String,
465    pub input: Vec<String>,
466    pub output: Vec<String>,
467    pub cost: ModelCost,
468    pub headers: Option<HashMap<String, String>>,
469}
470
471#[derive(Debug, Clone)]
472pub struct ImagesContext {
473    pub input: Vec<ContentBlock>,
474}
475
476#[derive(Clone)]
477pub struct ImagesOptions {
478    pub api_key: Option<String>,
479    pub signal: Option<tokio_util::sync::CancellationToken>,
480    pub env: Option<ProviderEnv>,
481    pub headers: Option<ProviderHeaders>,
482    pub timeout_ms: Option<u64>,
483    pub max_retries: Option<u32>,
484    pub on_payload: Option<OnPayloadCallback>,
485    pub on_response: Option<OnResponseCallback>,
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct AssistantImages {
490    pub api: ImagesApi,
491    pub provider: ImagesProviderId,
492    pub model: String,
493    pub output: Vec<ContentBlock>,
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub response_id: Option<String>,
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub usage: Option<Usage>,
498    pub stop_reason: StopReason,
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub error_message: Option<String>,
501    pub timestamp: i64,
502}
503
504/// Uniform stream contract for API implementation modules.
505pub trait ProviderStreams: Send + Sync {
506    fn stream(&self, model: &Model, context: &Context, options: Option<StreamOptions>) -> AssistantMessageEventStream;
507
508    fn stream_simple(
509        &self,
510        model: &Model,
511        context: &Context,
512        options: Option<SimpleStreamOptions>,
513    ) -> AssistantMessageEventStream;
514}
515
516pub trait ProviderImages: Send + Sync {
517    fn generate_images(
518        &self,
519        model: &ImagesModel,
520        context: &ImagesContext,
521        options: Option<ImagesOptions>,
522    ) -> Pin<Box<dyn Future<Output = AssistantImages> + Send>>;
523}
524
525// Message helpers
526impl Message {
527    pub fn role(&self) -> &'static str {
528        match self {
529            Message::User { .. } => "user",
530            Message::Assistant(_) => "assistant",
531            Message::ToolResult { .. } => "toolResult",
532        }
533    }
534
535    pub fn as_assistant(&self) -> Option<&AssistantMessage> {
536        match self {
537            Message::Assistant(m) => Some(m),
538            _ => None,
539        }
540    }
541}
542
543impl AssistantContentBlock {
544    pub fn is_text(&self) -> bool {
545        matches!(self, AssistantContentBlock::Text(_))
546    }
547
548    pub fn is_thinking(&self) -> bool {
549        matches!(self, AssistantContentBlock::Thinking(_))
550    }
551
552    pub fn is_tool_call(&self) -> bool {
553        matches!(self, AssistantContentBlock::ToolCall(_))
554    }
555}