Skip to main content

everruns_core/
model.rs

1// Model entity and model-profile types (specs/providers.md)
2//
3// A Model is a specific model via a specific provider (provider FK + wire
4// model id). ModelProfile is the model's identity and metadata; profile data
5// lives in `crate::model_profiles`.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10pub use crate::provider::{DriverId, ProviderStatus};
11use crate::typed_id::{ModelId, ProviderId};
12
13#[cfg(feature = "openapi")]
14use utoipa::ToSchema;
15
16// LLM model "healthy" status is not persisted on the model row. It is
17// derived at read time from the joined provider's state and exposed as a
18// boolean on `ModelWithProvider`. The per-row `enabled` flag is the only
19// persisted user-facing toggle, and it controls visibility in UI model
20// pickers.
21
22/// How the model was added to the system
23#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
24#[cfg_attr(feature = "openapi", derive(ToSchema))]
25#[cfg_attr(feature = "openapi", schema(example = "predefined"))]
26#[serde(rename_all = "snake_case")]
27pub enum ModelSource {
28    /// User-created via API or UI
29    #[default]
30    Manual,
31    /// Automatically discovered from provider's list_models API
32    Discovered,
33    /// From hardcoded seed data
34    Predefined,
35}
36
37impl std::fmt::Display for ModelSource {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            ModelSource::Manual => write!(f, "manual"),
41            ModelSource::Discovered => write!(f, "discovered"),
42            ModelSource::Predefined => write!(f, "predefined"),
43        }
44    }
45}
46
47impl std::str::FromStr for ModelSource {
48    type Err = String;
49
50    fn from_str(s: &str) -> Result<Self, Self::Err> {
51        match s {
52            "manual" => Ok(ModelSource::Manual),
53            "discovered" => Ok(ModelSource::Discovered),
54            "predefined" => Ok(ModelSource::Predefined),
55            _ => Err(format!("Unknown model source: {}", s)),
56        }
57    }
58}
59
60/// LLM Model entity
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[cfg_attr(feature = "openapi", derive(ToSchema))]
63pub struct Model {
64    /// Prefixed public identifier. See [ID Schema](https://docs.everruns.com/advanced/id-schema/).
65    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "model_01933b5a00007000800000000000001"))]
66    pub id: ModelId,
67    /// Owning provider's prefixed public identifier.
68    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "provider_01933b5a00007000800000000000001"))]
69    pub provider_id: ProviderId,
70    /// Provider-side model identifier as sent on the wire (e.g. `gpt-4o`, `claude-sonnet-4`).
71    pub model_id: String,
72    /// Human-readable display name. Safe to render in user-facing messages.
73    pub display_name: String,
74    /// Capability tags supported by this model (e.g. `chat`, `tools`, `vision`).
75    pub capabilities: Vec<String>,
76    /// Whether this model is starred in the UI for quick access.
77    pub is_favorite: bool,
78    /// Whether this model is selectable. Controls UI visibility AND server-side resolution: `ProviderResolverService` requires `enabled = true`, and org default-model validation rejects disabled models. Disabled models stay visible in raw list endpoints (so admins can re-enable them) but cannot be used in active sessions or as a session/agent default.
79    pub enabled: bool,
80    /// How this model entry was added (manually, discovered, or seeded as predefined).
81    pub source: ModelSource,
82    /// Timestamp when this model was created (RFC 3339).
83    pub created_at: DateTime<Utc>,
84    /// Timestamp when this model was last updated (RFC 3339).
85    pub updated_at: DateTime<Utc>,
86}
87
88/// LLM Model with provider info
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[cfg_attr(feature = "openapi", derive(ToSchema))]
91pub struct ModelWithProvider {
92    /// Prefixed public identifier. See [ID Schema](https://docs.everruns.com/advanced/id-schema/).
93    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "model_01933b5a00007000800000000000001"))]
94    pub id: ModelId,
95    /// Owning provider's prefixed public identifier.
96    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "provider_01933b5a00007000800000000000001"))]
97    pub provider_id: ProviderId,
98    /// Provider-side model identifier as sent on the wire (e.g. `gpt-4o`).
99    #[cfg_attr(feature = "openapi", schema(example = "claude-sonnet-4-5"))]
100    pub model_id: String,
101    /// Human-readable display name.
102    #[cfg_attr(feature = "openapi", schema(example = "Claude Sonnet 4.5"))]
103    pub display_name: String,
104    /// Capability tags supported by this model.
105    #[cfg_attr(feature = "openapi", schema(example = json!(["text", "tools", "vision", "thinking"])))]
106    pub capabilities: Vec<String>,
107    /// Whether this model is starred in the UI for quick access.
108    #[cfg_attr(feature = "openapi", schema(example = true))]
109    pub is_favorite: bool,
110    /// Whether this model is selectable. Controls UI visibility AND server-side resolution: `ProviderResolverService` requires `enabled = true`, and org default-model validation rejects disabled models.
111    #[cfg_attr(feature = "openapi", schema(example = true))]
112    pub enabled: bool,
113    /// How this model entry was added (manually, discovered, or seeded as predefined).
114    #[cfg_attr(feature = "openapi", schema(example = "predefined"))]
115    pub source: ModelSource,
116    /// Timestamp when this model was created (RFC 3339).
117    #[cfg_attr(feature = "openapi", schema(example = "2026-01-04T11:23:00Z"))]
118    pub created_at: DateTime<Utc>,
119    /// Timestamp when this model was last updated (RFC 3339).
120    #[cfg_attr(feature = "openapi", schema(example = "2026-05-27T15:24:00Z"))]
121    pub updated_at: DateTime<Utc>,
122    /// Joined provider display name.
123    #[cfg_attr(feature = "openapi", schema(example = "Anthropic"))]
124    pub provider_name: String,
125    /// Joined provider implementation type.
126    #[cfg_attr(feature = "openapi", schema(example = "anthropic"))]
127    pub provider_type: DriverId,
128    /// Derived: model is configured and ready for use. Currently means the
129    /// joined provider is active and has an API key set; over time this may
130    /// also incorporate live reachability checks. Not persisted.
131    #[cfg_attr(feature = "openapi", schema(example = true))]
132    pub healthy: bool,
133    /// Readonly profile with model capabilities (limits, pricing, modalities). Not persisted.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub profile: Option<ModelProfile>,
136    /// Vendor/brand of the model, derived from the model registry. Drives UI
137    /// branding (icons). `None` when the model id is not in the registry. Not persisted.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub model_vendor: Option<ModelVendor>,
140}
141
142// ============================================
143// LLM Model Profile types
144// Based on models.dev structure
145// ============================================
146
147/// Cost information for the model (per million tokens)
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[cfg_attr(feature = "openapi", derive(ToSchema))]
150pub struct ModelCost {
151    /// Input cost per million tokens (USD)
152    pub input: f64,
153    /// Output cost per million tokens (USD)
154    pub output: f64,
155    /// Cached read cost per million tokens (USD), if supported
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub cache_read: Option<f64>,
158    /// Tiered pricing that applies above certain context thresholds.
159    /// When present, the base cost fields apply up to the tier threshold,
160    /// and each tier's costs apply for tokens beyond that threshold.
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub cost_tiers: Vec<CostTier>,
163}
164
165/// A pricing tier that activates above a context token threshold.
166/// For example, OpenAI charges higher rates for prompts exceeding 200K tokens.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[cfg_attr(feature = "openapi", derive(ToSchema))]
169pub struct CostTier {
170    /// Context token threshold above which this tier applies
171    pub above_tokens: i32,
172    /// Input cost per million tokens (USD) for this tier
173    pub input: f64,
174    /// Output cost per million tokens (USD) for this tier
175    pub output: f64,
176    /// Cached read cost per million tokens (USD) for this tier, if supported
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub cache_read: Option<f64>,
179}
180
181/// Token limits for the model
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[cfg_attr(feature = "openapi", derive(ToSchema))]
184pub struct ModelLimits {
185    /// Maximum context window size in tokens
186    pub context: i32,
187    /// Maximum input tokens (if different from context - output)
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub input: Option<i32>,
190    /// Maximum output tokens
191    pub output: i32,
192    /// Maximum images or PDF pages per request
193    #[serde(skip_serializing_if = "Option::is_none", default)]
194    pub max_media: Option<i32>,
195}
196
197/// Modality type (text, image, audio, video)
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199#[cfg_attr(feature = "openapi", derive(ToSchema))]
200#[serde(rename_all = "snake_case")]
201pub enum Modality {
202    Text,
203    Image,
204    Audio,
205    Video,
206    Pdf,
207}
208
209/// Model modalities for input and output
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[cfg_attr(feature = "openapi", derive(ToSchema))]
212pub struct ModelModalities {
213    /// Supported input modalities
214    pub input: Vec<Modality>,
215    /// Supported output modalities
216    pub output: Vec<Modality>,
217}
218
219/// Reasoning effort level for models that support it
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221#[cfg_attr(feature = "openapi", derive(ToSchema))]
222#[serde(rename_all = "snake_case")]
223pub enum ReasoningEffort {
224    None,
225    Minimal,
226    Low,
227    Medium,
228    High,
229    Xhigh,
230}
231
232/// Named reasoning effort value for UI display
233#[derive(Debug, Clone, Serialize, Deserialize)]
234#[cfg_attr(feature = "openapi", derive(ToSchema))]
235pub struct ReasoningEffortValue {
236    /// The API value (e.g., "low", "medium")
237    pub value: ReasoningEffort,
238    /// Display name (e.g., "Low", "Medium")
239    pub name: String,
240}
241
242/// Reasoning effort configuration for a model
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[cfg_attr(feature = "openapi", derive(ToSchema))]
245pub struct ReasoningEffortConfig {
246    /// Available reasoning effort values for this model
247    pub values: Vec<ReasoningEffortValue>,
248    /// Default reasoning effort for this model
249    pub default: ReasoningEffort,
250}
251
252/// Speed level for models that expose a latency/price service tier.
253/// Wire values map 1:1 to the OpenAI `service_tier` request parameter:
254/// `flex` (slower, cheaper), `default` (standard), `priority` (faster,
255/// premium). `auto` is deliberately not offered — omitting the field
256/// preserves the provider's default routing.
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
258#[cfg_attr(feature = "openapi", derive(ToSchema))]
259#[serde(rename_all = "snake_case")]
260pub enum Speed {
261    Flex,
262    Default,
263    Priority,
264}
265
266/// Named speed value for UI display
267#[derive(Debug, Clone, Serialize, Deserialize)]
268#[cfg_attr(feature = "openapi", derive(ToSchema))]
269pub struct SpeedValue {
270    /// The API value (e.g., "flex", "priority")
271    pub value: Speed,
272    /// Display name (e.g., "Flex", "Fast")
273    pub name: String,
274}
275
276/// Speed configuration for a model
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[cfg_attr(feature = "openapi", derive(ToSchema))]
279pub struct SpeedConfig {
280    /// Available speed values for this model
281    pub values: Vec<SpeedValue>,
282    /// Default speed for this model
283    pub default: Speed,
284}
285
286/// Vendor / brand that authored a model. Independent of the provider type
287/// that serves it (the same model may be offered by several providers or
288/// gateways). Primarily drives UI iconography.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[cfg_attr(feature = "openapi", derive(ToSchema))]
291#[serde(rename_all = "lowercase")]
292pub enum ModelVendor {
293    OpenAi,
294    Anthropic,
295    Google,
296    Nvidia,
297    Qwen,
298    Microsoft,
299    MiniMax,
300    Moonshot,
301    XAi,
302    LlmSim,
303}
304
305impl ModelVendor {
306    /// Stable lowercase slug, the first segment of a model-profile key
307    /// (`"{vendor}/{canonical_id}"`, see specs/providers.md). Matches the
308    /// serde `lowercase` representation.
309    pub fn slug(&self) -> &'static str {
310        match self {
311            ModelVendor::OpenAi => "openai",
312            ModelVendor::Anthropic => "anthropic",
313            ModelVendor::Google => "google",
314            ModelVendor::Nvidia => "nvidia",
315            ModelVendor::Qwen => "qwen",
316            ModelVendor::Microsoft => "microsoft",
317            ModelVendor::MiniMax => "minimax",
318            ModelVendor::Moonshot => "moonshot",
319            ModelVendor::XAi => "xai",
320            ModelVendor::LlmSim => "llmsim",
321        }
322    }
323}
324
325/// LLM Model Profile describing model capabilities
326/// Based on models.dev structure (<https://models.dev/api.json>)
327///
328/// NOTE: Currently only includes profiles for:
329/// - OpenAI: gpt-4o, gpt-4o-mini, o1, o1-mini, o1-pro, o3-mini
330/// - Anthropic: claude-3-5-sonnet, claude-3-5-haiku, claude-3-opus, claude-3-sonnet, claude-3-haiku, claude-sonnet-4, claude-opus-4
331///
332/// Additional model profiles can be added as needed.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[cfg_attr(feature = "openapi", derive(ToSchema))]
335pub struct ModelProfile {
336    /// Display name of the model
337    pub name: String,
338    /// Model family (e.g., "gpt-4o", "claude-3-5-sonnet")
339    pub family: String,
340    /// Short human-readable description of the model's strengths and intended use
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub description: Option<String>,
343    /// Release date (YYYY-MM-DD format)
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub release_date: Option<String>,
346    /// Last updated date (YYYY-MM-DD format)
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub last_updated: Option<String>,
349    /// Whether the model supports file/image attachments
350    pub attachment: bool,
351    /// Whether the model has reasoning/chain-of-thought capabilities
352    pub reasoning: bool,
353    /// Whether temperature control is supported
354    pub temperature: bool,
355    /// Knowledge cutoff date (YYYY-MM-DD format)
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub knowledge: Option<String>,
358    /// Whether the model supports tool/function calling
359    pub tool_call: bool,
360    /// Whether the model supports structured output (JSON mode)
361    pub structured_output: bool,
362    /// Whether the model has open weights
363    pub open_weights: bool,
364    /// Cost per million tokens
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub cost: Option<ModelCost>,
367    /// Token limits
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub limits: Option<ModelLimits>,
370    /// Supported modalities
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub modalities: Option<ModelModalities>,
373    /// Reasoning effort configuration (for reasoning models)
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub reasoning_effort: Option<ReasoningEffortConfig>,
376    /// Speed (service tier) configuration, for models served with
377    /// selectable latency/price tiers (OpenAI `service_tier`).
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub speed: Option<SpeedConfig>,
380    /// Whether the model supports tool_search (deferred tool loading).
381    /// When true, the driver can use namespaces and defer_loading to reduce
382    /// token usage for large tool sets. Currently supported by GPT-5.4 and newer.
383    #[serde(default)]
384    pub tool_search: bool,
385    /// Provider-advertised request parameters supported by this model.
386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
387    pub supported_parameters: Vec<String>,
388    /// Whether the model supports native execution phases ("commentary" / "final_answer").
389    /// When true, the driver sends the `phase` field on assistant messages in the wire format.
390    /// Currently supported by GPT-5.4 and newer via OpenAI Responses API.
391    #[serde(default)]
392    pub supports_phases: bool,
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_provider_type_serialization() {
401        // Verify all provider types serialize correctly
402        assert_eq!(
403            serde_json::to_string(&DriverId::OpenAI).unwrap(),
404            "\"openai\""
405        );
406        assert_eq!(
407            serde_json::to_string(&DriverId::OpenRouter).unwrap(),
408            "\"openrouter\""
409        );
410        assert_eq!(
411            serde_json::to_string(&DriverId::OpenAICompletions).unwrap(),
412            "\"openai_completions\""
413        );
414        assert_eq!(
415            serde_json::to_string(&DriverId::AzureOpenAI).unwrap(),
416            "\"azure_openai\""
417        );
418        assert_eq!(
419            serde_json::to_string(&DriverId::Anthropic).unwrap(),
420            "\"anthropic\""
421        );
422        assert_eq!(
423            serde_json::to_string(&DriverId::Gemini).unwrap(),
424            "\"gemini\""
425        );
426        assert_eq!(
427            serde_json::to_string(&DriverId::LlmSim).unwrap(),
428            "\"llmsim\""
429        );
430    }
431
432    #[test]
433    fn test_provider_type_deserialization() {
434        // Verify all provider types deserialize correctly
435        assert!(matches!(
436            serde_json::from_str::<DriverId>("\"openai\"").unwrap(),
437            DriverId::OpenAI
438        ));
439        assert!(matches!(
440            serde_json::from_str::<DriverId>("\"openrouter\"").unwrap(),
441            DriverId::OpenRouter
442        ));
443        assert!(matches!(
444            serde_json::from_str::<DriverId>("\"openai_completions\"").unwrap(),
445            DriverId::OpenAICompletions
446        ));
447        assert!(matches!(
448            serde_json::from_str::<DriverId>("\"azure_openai\"").unwrap(),
449            DriverId::AzureOpenAI
450        ));
451        assert!(matches!(
452            serde_json::from_str::<DriverId>("\"anthropic\"").unwrap(),
453            DriverId::Anthropic
454        ));
455        assert!(matches!(
456            serde_json::from_str::<DriverId>("\"gemini\"").unwrap(),
457            DriverId::Gemini
458        ));
459        assert!(matches!(
460            serde_json::from_str::<DriverId>("\"llmsim\"").unwrap(),
461            DriverId::LlmSim
462        ));
463    }
464
465    #[test]
466    fn test_provider_type_from_str() {
467        // Verify FromStr works correctly
468        assert!(matches!(
469            "openai".parse::<DriverId>().unwrap(),
470            DriverId::OpenAI
471        ));
472        assert!(matches!(
473            "openrouter".parse::<DriverId>().unwrap(),
474            DriverId::OpenRouter
475        ));
476        assert!(matches!(
477            "openai_completions".parse::<DriverId>().unwrap(),
478            DriverId::OpenAICompletions
479        ));
480        assert!(matches!(
481            "azure_openai".parse::<DriverId>().unwrap(),
482            DriverId::AzureOpenAI
483        ));
484        assert!(matches!(
485            "anthropic".parse::<DriverId>().unwrap(),
486            DriverId::Anthropic
487        ));
488        assert!(matches!(
489            "gemini".parse::<DriverId>().unwrap(),
490            DriverId::Gemini
491        ));
492        assert!(matches!(
493            "llmsim".parse::<DriverId>().unwrap(),
494            DriverId::LlmSim
495        ));
496    }
497
498    #[test]
499    fn test_model_limits_input_omitted_when_none() {
500        let limits = ModelLimits {
501            context: 200_000,
502            input: None,
503            output: 64_000,
504            max_media: None,
505        };
506        let json = serde_json::to_value(&limits).unwrap();
507        assert!(!json.as_object().unwrap().contains_key("input"));
508    }
509
510    #[test]
511    fn test_model_limits_input_included_when_some() {
512        let limits = ModelLimits {
513            context: 200_000,
514            input: Some(150_000),
515            output: 64_000,
516            max_media: None,
517        };
518        let json = serde_json::to_value(&limits).unwrap();
519        assert_eq!(json["input"], 150_000);
520    }
521
522    #[test]
523    fn test_model_limits_deserialize_without_input() {
524        let json = r#"{"context": 200000, "output": 64000}"#;
525        let limits: ModelLimits = serde_json::from_str(json).unwrap();
526        assert_eq!(limits.context, 200_000);
527        assert!(limits.input.is_none());
528        assert_eq!(limits.output, 64_000);
529    }
530
531    #[test]
532    fn test_provider_type_display() {
533        // Verify Display works correctly
534        assert_eq!(DriverId::OpenAI.to_string(), "openai");
535        assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
536        assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
537        assert_eq!(
538            DriverId::OpenAICompletions.to_string(),
539            "openai_completions"
540        );
541        assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
542        assert_eq!(DriverId::Gemini.to_string(), "gemini");
543        assert_eq!(DriverId::LlmSim.to_string(), "llmsim");
544    }
545}