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    Max,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ModelThinkingLevel {
36    Off,
37    Level(ThinkingLevel),
38}
39
40pub type ThinkingLevelMap = HashMap<String, Option<String>>;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum CacheRetention {
45    None,
46    Short,
47    Long,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum Transport {
53    Sse,
54    Websocket,
55    #[serde(rename = "websocket-cached")]
56    WebsocketCached,
57    Auto,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ThinkingBudgets {
62    pub minimal: Option<u32>,
63    pub low: Option<u32>,
64    pub medium: Option<u32>,
65    pub high: Option<u32>,
66}
67
68#[derive(Debug, Clone)]
69pub struct ProviderResponse {
70    pub status: u16,
71    pub headers: HashMap<String, String>,
72}
73
74#[derive(Clone, Default)]
75pub struct StreamOptions {
76    pub temperature: Option<f64>,
77    pub max_tokens: Option<u32>,
78    pub api_key: Option<String>,
79    pub transport: Option<Transport>,
80    pub cache_retention: Option<CacheRetention>,
81    pub session_id: Option<String>,
82    pub headers: Option<ProviderHeaders>,
83    pub timeout_ms: Option<u64>,
84    pub websocket_connect_timeout_ms: Option<u64>,
85    pub max_retries: Option<u32>,
86    pub max_retry_delay_ms: Option<u64>,
87    pub metadata: Option<HashMap<String, Value>>,
88    pub env: Option<ProviderEnv>,
89    pub on_payload: Option<OnPayloadCallback>,
90    pub on_response: Option<OnResponseCallback>,
91    pub signal: Option<tokio_util::sync::CancellationToken>,
92}
93
94pub type OnPayloadCallback =
95    Arc<dyn Fn(Value, Model) -> Pin<Box<dyn Future<Output = Option<Value>> + Send>> + Send + Sync>;
96pub type OnResponseCallback =
97    Arc<dyn Fn(ProviderResponse, Model) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
98
99#[derive(Clone)]
100pub struct SimpleStreamOptions {
101    pub base: StreamOptions,
102    pub reasoning: Option<ThinkingLevel>,
103    pub thinking_budgets: Option<ThinkingBudgets>,
104}
105
106impl SimpleStreamOptions {
107    pub fn from_stream(options: StreamOptions) -> Self {
108        Self {
109            base: options,
110            reasoning: None,
111            thinking_budgets: None,
112        }
113    }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117pub struct TextContent {
118    #[serde(rename = "type")]
119    pub kind: String,
120    pub text: String,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub text_signature: Option<String>,
123}
124
125impl TextContent {
126    pub fn new(text: impl Into<String>) -> Self {
127        Self {
128            kind: "text".to_string(),
129            text: text.into(),
130            text_signature: None,
131        }
132    }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
136pub struct ThinkingContent {
137    #[serde(rename = "type")]
138    pub kind: String,
139    pub thinking: String,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub thinking_signature: Option<String>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub redacted: Option<bool>,
144}
145
146impl ThinkingContent {
147    pub fn new(thinking: impl Into<String>) -> Self {
148        Self {
149            kind: "thinking".to_string(),
150            thinking: thinking.into(),
151            thinking_signature: None,
152            redacted: None,
153        }
154    }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
158pub struct ImageContent {
159    #[serde(rename = "type")]
160    pub kind: String,
161    pub data: String,
162    pub mime_type: String,
163}
164
165impl ImageContent {
166    pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
167        Self {
168            kind: "image".to_string(),
169            data: data.into(),
170            mime_type: mime_type.into(),
171        }
172    }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
176pub struct ToolCall {
177    #[serde(rename = "type")]
178    pub kind: String,
179    pub id: String,
180    pub name: String,
181    pub arguments: Value,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub thought_signature: Option<String>,
184}
185
186impl ToolCall {
187    pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
188        Self {
189            kind: "toolCall".to_string(),
190            id: id.into(),
191            name: name.into(),
192            arguments,
193            thought_signature: None,
194        }
195    }
196}
197
198#[derive(Debug, Clone, Default, Serialize, Deserialize)]
199pub struct UsageCost {
200    pub input: f64,
201    pub output: f64,
202    pub cache_read: f64,
203    pub cache_write: f64,
204    pub total: f64,
205}
206
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct Usage {
209    pub input: u64,
210    pub output: u64,
211    pub cache_read: u64,
212    pub cache_write: u64,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub cache_write_1h: Option<u64>,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub reasoning: Option<u64>,
217    pub total_tokens: u64,
218    pub cost: UsageCost,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub enum StopReason {
224    Stop,
225    Length,
226    #[serde(rename = "toolUse")]
227    ToolUse,
228    Error,
229    Aborted,
230}
231
232#[allow(clippy::large_enum_variant)]
233#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(tag = "role", rename_all = "camelCase")]
235pub enum Message {
236    User {
237        content: UserContent,
238        timestamp: i64,
239    },
240    Assistant(AssistantMessage),
241    ToolResult {
242        tool_call_id: String,
243        tool_name: String,
244        content: Vec<ContentBlock>,
245        #[serde(skip_serializing_if = "Option::is_none")]
246        details: Option<Value>,
247        /// Names from `Context.tools` that became available after this result.
248        /// Providers with native deferred tool loading use this as the load point.
249        #[serde(default, skip_serializing_if = "Option::is_none")]
250        added_tool_names: Option<Vec<String>>,
251        is_error: bool,
252        timestamp: i64,
253    },
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[serde(untagged)]
258pub enum UserContent {
259    Text(String),
260    Blocks(Vec<ContentBlock>),
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[serde(tag = "type", rename_all = "camelCase")]
265pub enum ContentBlock {
266    #[serde(rename = "text")]
267    Text { text: String },
268    #[serde(rename = "image")]
269    Image { data: String, mime_type: String },
270}
271
272fn assistant_role_default() -> String {
273    "assistant".to_string()
274}
275
276/// Redacted provider/runtime diagnostic attached to an assistant message.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct AssistantMessageDiagnostic {
280    pub kind: String,
281    pub message: String,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub details: Option<Value>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct AssistantMessage {
288    #[serde(skip_serializing, default = "assistant_role_default")]
289    pub role: String,
290    pub content: Vec<AssistantContentBlock>,
291    pub api: Api,
292    pub provider: ProviderId,
293    pub model: String,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub response_model: Option<String>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub response_id: Option<String>,
298    /// Redacted provider/runtime diagnostics for failures and recoveries.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub diagnostics: Option<Vec<AssistantMessageDiagnostic>>,
301    pub usage: Usage,
302    pub stop_reason: StopReason,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub error_message: Option<String>,
305    pub timestamp: i64,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(untagged)]
310pub enum AssistantContentBlock {
311    Text(TextContent),
312    Thinking(ThinkingContent),
313    ToolCall(ToolCall),
314}
315
316impl AssistantMessage {
317    pub fn empty(model: &Model) -> Self {
318        Self {
319            role: "assistant".to_string(),
320            content: vec![],
321            api: model.api.clone(),
322            provider: model.provider.clone(),
323            model: model.id.clone(),
324            response_model: None,
325            response_id: None,
326            diagnostics: None,
327            usage: Usage::default(),
328            stop_reason: StopReason::Stop,
329            error_message: None,
330            timestamp: chrono::Utc::now().timestamp_millis(),
331        }
332    }
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
336pub struct Tool {
337    pub name: String,
338    pub description: String,
339    pub parameters: Value,
340}
341
342#[derive(Debug, Clone)]
343pub struct Context {
344    pub system_prompt: Option<String>,
345    pub messages: Vec<Message>,
346    pub tools: Option<Vec<Tool>>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct TextSignatureV1 {
351    pub v: u8,
352    pub id: String,
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub phase: Option<String>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358#[serde(tag = "type", rename_all = "snake_case")]
359pub enum AssistantMessageEvent {
360    Start {
361        partial: AssistantMessage,
362    },
363    TextStart {
364        content_index: usize,
365        partial: AssistantMessage,
366    },
367    TextDelta {
368        content_index: usize,
369        delta: String,
370        partial: AssistantMessage,
371    },
372    TextEnd {
373        content_index: usize,
374        content: String,
375        partial: AssistantMessage,
376    },
377    ThinkingStart {
378        content_index: usize,
379        partial: AssistantMessage,
380    },
381    ThinkingDelta {
382        content_index: usize,
383        delta: String,
384        partial: AssistantMessage,
385    },
386    ThinkingEnd {
387        content_index: usize,
388        content: String,
389        partial: AssistantMessage,
390    },
391    ToolcallStart {
392        content_index: usize,
393        partial: AssistantMessage,
394    },
395    ToolcallDelta {
396        content_index: usize,
397        delta: String,
398        partial: AssistantMessage,
399    },
400    ToolcallEnd {
401        content_index: usize,
402        tool_call: ToolCall,
403        partial: AssistantMessage,
404    },
405    Done {
406        reason: StopReason,
407        message: AssistantMessage,
408    },
409    Error {
410        reason: StopReason,
411        error: AssistantMessage,
412    },
413}
414
415#[derive(Debug, Clone, Default, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct OpenAICompletionsCompat {
418    pub supports_store: Option<bool>,
419    pub supports_developer_role: Option<bool>,
420    pub supports_reasoning_effort: Option<bool>,
421    pub supports_usage_in_streaming: Option<bool>,
422    pub max_tokens_field: Option<String>,
423    pub requires_tool_result_name: Option<bool>,
424    pub requires_assistant_after_tool_result: Option<bool>,
425    pub requires_thinking_as_text: Option<bool>,
426    pub requires_reasoning_content_on_assistant_messages: Option<bool>,
427    pub thinking_format: Option<String>,
428    pub zai_tool_stream: Option<bool>,
429    pub supports_strict_mode: Option<bool>,
430    pub cache_control_format: Option<String>,
431    pub send_session_affinity_headers: Option<bool>,
432    pub supports_long_cache_retention: Option<bool>,
433}
434
435#[derive(Debug, Clone, Default, Serialize, Deserialize)]
436#[serde(rename_all = "camelCase")]
437pub struct OpenAIResponsesCompat {
438    pub supports_developer_role: Option<bool>,
439    pub send_session_id_header: Option<bool>,
440    pub supports_long_cache_retention: Option<bool>,
441    /// Whether the model supports client-executed tool search for deferred tools.
442    pub supports_tool_search: Option<bool>,
443}
444
445#[derive(Debug, Clone, Default, Serialize, Deserialize)]
446#[serde(rename_all = "camelCase")]
447pub struct AnthropicMessagesCompat {
448    pub supports_eager_tool_input_streaming: Option<bool>,
449    pub supports_long_cache_retention: Option<bool>,
450    pub send_session_affinity_headers: Option<bool>,
451    pub supports_cache_control_on_tools: Option<bool>,
452    pub supports_temperature: Option<bool>,
453    pub force_adaptive_thinking: Option<bool>,
454    pub allow_empty_signature: Option<bool>,
455    /// Whether the provider supports deferred tools loaded by `tool_reference`.
456    pub supports_tool_references: Option<bool>,
457}
458
459#[derive(Debug, Clone)]
460pub struct Model {
461    pub id: String,
462    pub name: String,
463    pub api: Api,
464    pub provider: ProviderId,
465    pub base_url: String,
466    pub reasoning: bool,
467    pub thinking_level_map: Option<ThinkingLevelMap>,
468    pub input: Vec<String>,
469    pub cost: ModelCost,
470    pub context_window: u32,
471    pub max_tokens: u32,
472    pub headers: Option<HashMap<String, String>>,
473    pub openai_completions_compat: Option<OpenAICompletionsCompat>,
474    pub openai_responses_compat: Option<OpenAIResponsesCompat>,
475    pub anthropic_compat: Option<AnthropicMessagesCompat>,
476}
477
478/// Base token rates in USD per million tokens.
479#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
480#[serde(rename_all = "camelCase")]
481pub struct ModelCostRates {
482    pub input: f64,
483    pub output: f64,
484    pub cache_read: f64,
485    pub cache_write: f64,
486}
487
488/// Request-wide pricing tier. Applies when total input usage exceeds the threshold.
489#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
490#[serde(rename_all = "camelCase")]
491pub struct ModelCostTier {
492    /// Use this tier for requests whose total input usage exceeds this token count.
493    pub input_tokens_above: u64,
494    pub input: f64,
495    pub output: f64,
496    pub cache_read: f64,
497    pub cache_write: f64,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
501#[serde(rename_all = "camelCase")]
502pub struct ModelCost {
503    pub input: f64,
504    pub output: f64,
505    pub cache_read: f64,
506    pub cache_write: f64,
507    /// Request-wide pricing tiers. The highest matching input threshold applies to the full request.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub tiers: Option<Vec<ModelCostTier>>,
510}
511
512impl ModelCost {
513    pub fn flat(input: f64, output: f64, cache_read: f64, cache_write: f64) -> Self {
514        Self {
515            input,
516            output,
517            cache_read,
518            cache_write,
519            tiers: None,
520        }
521    }
522
523    pub fn rates(&self) -> ModelCostRates {
524        ModelCostRates {
525            input: self.input,
526            output: self.output,
527            cache_read: self.cache_read,
528            cache_write: self.cache_write,
529        }
530    }
531}
532
533impl Default for ModelCost {
534    fn default() -> Self {
535        Self::flat(0.0, 0.0, 0.0, 0.0)
536    }
537}
538
539#[derive(Debug, Clone)]
540pub struct ImagesModel {
541    pub id: String,
542    pub name: String,
543    pub api: ImagesApi,
544    pub provider: ImagesProviderId,
545    pub base_url: String,
546    pub input: Vec<String>,
547    pub output: Vec<String>,
548    pub cost: ModelCost,
549    pub headers: Option<HashMap<String, String>>,
550}
551
552#[derive(Debug, Clone)]
553pub struct ImagesContext {
554    pub input: Vec<ContentBlock>,
555}
556
557#[derive(Clone)]
558pub struct ImagesOptions {
559    pub api_key: Option<String>,
560    pub signal: Option<tokio_util::sync::CancellationToken>,
561    pub env: Option<ProviderEnv>,
562    pub headers: Option<ProviderHeaders>,
563    pub timeout_ms: Option<u64>,
564    pub max_retries: Option<u32>,
565    pub on_payload: Option<OnPayloadCallback>,
566    pub on_response: Option<OnResponseCallback>,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct AssistantImages {
571    pub api: ImagesApi,
572    pub provider: ImagesProviderId,
573    pub model: String,
574    pub output: Vec<ContentBlock>,
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub response_id: Option<String>,
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub usage: Option<Usage>,
579    pub stop_reason: StopReason,
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub error_message: Option<String>,
582    pub timestamp: i64,
583}
584
585/// Uniform stream contract for API implementation modules.
586pub trait ProviderStreams: Send + Sync {
587    fn stream(&self, model: &Model, context: &Context, options: Option<StreamOptions>) -> AssistantMessageEventStream;
588
589    fn stream_simple(
590        &self,
591        model: &Model,
592        context: &Context,
593        options: Option<SimpleStreamOptions>,
594    ) -> AssistantMessageEventStream;
595}
596
597pub trait ProviderImages: Send + Sync {
598    fn generate_images(
599        &self,
600        model: &ImagesModel,
601        context: &ImagesContext,
602        options: Option<ImagesOptions>,
603    ) -> Pin<Box<dyn Future<Output = AssistantImages> + Send>>;
604}
605
606// Message helpers
607impl Message {
608    pub fn role(&self) -> &'static str {
609        match self {
610            Message::User { .. } => "user",
611            Message::Assistant(_) => "assistant",
612            Message::ToolResult { .. } => "toolResult",
613        }
614    }
615
616    pub fn as_assistant(&self) -> Option<&AssistantMessage> {
617        match self {
618            Message::Assistant(m) => Some(m),
619            _ => None,
620        }
621    }
622}
623
624impl AssistantContentBlock {
625    pub fn is_text(&self) -> bool {
626        matches!(self, AssistantContentBlock::Text(_))
627    }
628
629    pub fn is_thinking(&self) -> bool {
630        matches!(self, AssistantContentBlock::Thinking(_))
631    }
632
633    pub fn is_tool_call(&self) -> bool {
634        matches!(self, AssistantContentBlock::ToolCall(_))
635    }
636}