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/// Verbosity level for models that support output-length control.
287/// Wire values map 1:1 to the OpenAI `verbosity` request parameter:
288/// `low` (terse), `medium` (balanced, provider default), `high`
289/// (comprehensive). Independent of `ReasoningEffort`, which tunes the
290/// amount of reasoning rather than the length of the final answer.
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
292#[cfg_attr(feature = "openapi", derive(ToSchema))]
293#[serde(rename_all = "snake_case")]
294pub enum Verbosity {
295    Low,
296    Medium,
297    High,
298}
299
300/// Named verbosity value for UI display
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[cfg_attr(feature = "openapi", derive(ToSchema))]
303pub struct VerbosityValue {
304    /// The API value (e.g., "low", "high")
305    pub value: Verbosity,
306    /// Display name (e.g., "Low", "High")
307    pub name: String,
308}
309
310/// Verbosity configuration for a model
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[cfg_attr(feature = "openapi", derive(ToSchema))]
313pub struct VerbosityConfig {
314    /// Available verbosity values for this model
315    pub values: Vec<VerbosityValue>,
316    /// Default verbosity for this model
317    pub default: Verbosity,
318}
319
320/// Vendor / brand that authored a model. Independent of the provider type
321/// that serves it (the same model may be offered by several providers or
322/// gateways). Primarily drives UI iconography.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
324#[cfg_attr(feature = "openapi", derive(ToSchema))]
325#[serde(rename_all = "lowercase")]
326pub enum ModelVendor {
327    OpenAi,
328    Anthropic,
329    Google,
330    Nvidia,
331    Qwen,
332    Microsoft,
333    MiniMax,
334    Moonshot,
335    XAi,
336    LlmSim,
337}
338
339impl ModelVendor {
340    /// Stable lowercase slug, the first segment of a model-profile key
341    /// (`"{vendor}/{canonical_id}"`, see specs/providers.md). Matches the
342    /// serde `lowercase` representation.
343    pub fn slug(&self) -> &'static str {
344        match self {
345            ModelVendor::OpenAi => "openai",
346            ModelVendor::Anthropic => "anthropic",
347            ModelVendor::Google => "google",
348            ModelVendor::Nvidia => "nvidia",
349            ModelVendor::Qwen => "qwen",
350            ModelVendor::Microsoft => "microsoft",
351            ModelVendor::MiniMax => "minimax",
352            ModelVendor::Moonshot => "moonshot",
353            ModelVendor::XAi => "xai",
354            ModelVendor::LlmSim => "llmsim",
355        }
356    }
357}
358
359/// LLM Model Profile describing model capabilities
360/// Based on models.dev structure (<https://models.dev/api.json>)
361///
362/// NOTE: Currently only includes profiles for:
363/// - OpenAI: gpt-4o, gpt-4o-mini, o1, o1-mini, o1-pro, o3-mini
364/// - Anthropic: claude-3-5-sonnet, claude-3-5-haiku, claude-3-opus, claude-3-sonnet, claude-3-haiku, claude-sonnet-4, claude-opus-4
365///
366/// Additional model profiles can be added as needed.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368#[cfg_attr(feature = "openapi", derive(ToSchema))]
369pub struct ModelProfile {
370    /// Display name of the model
371    pub name: String,
372    /// Model family (e.g., "gpt-4o", "claude-3-5-sonnet")
373    pub family: String,
374    /// Short human-readable description of the model's strengths and intended use
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub description: Option<String>,
377    /// Release date (YYYY-MM-DD format)
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub release_date: Option<String>,
380    /// Last updated date (YYYY-MM-DD format)
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub last_updated: Option<String>,
383    /// Whether the model supports file/image attachments
384    pub attachment: bool,
385    /// Whether the model has reasoning/chain-of-thought capabilities
386    pub reasoning: bool,
387    /// Whether temperature control is supported
388    pub temperature: bool,
389    /// Knowledge cutoff date (YYYY-MM-DD format)
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub knowledge: Option<String>,
392    /// Whether the model supports tool/function calling
393    pub tool_call: bool,
394    /// Whether the model supports structured output (JSON mode)
395    pub structured_output: bool,
396    /// Whether the model has open weights
397    pub open_weights: bool,
398    /// Cost per million tokens
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub cost: Option<ModelCost>,
401    /// Token limits
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub limits: Option<ModelLimits>,
404    /// Supported modalities
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub modalities: Option<ModelModalities>,
407    /// Reasoning effort configuration (for reasoning models)
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub reasoning_effort: Option<ReasoningEffortConfig>,
410    /// Speed (service tier) configuration, for models served with
411    /// selectable latency/price tiers (OpenAI `service_tier`).
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub speed: Option<SpeedConfig>,
414    /// Verbosity configuration, for models that expose output-length
415    /// control (OpenAI `verbosity`).
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub verbosity: Option<VerbosityConfig>,
418    /// Whether the model supports tool_search (deferred tool loading).
419    /// When true, the driver can use namespaces and defer_loading to reduce
420    /// token usage for large tool sets. Currently supported by GPT-5.4 and newer.
421    #[serde(default)]
422    pub tool_search: bool,
423    /// Provider-advertised request parameters supported by this model.
424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
425    pub supported_parameters: Vec<String>,
426    /// Whether the model supports native execution phases ("commentary" / "final_answer").
427    /// When true, the driver sends the `phase` field on assistant messages in the wire format.
428    /// Currently supported by GPT-5.4 and newer via OpenAI Responses API.
429    #[serde(default)]
430    pub supports_phases: bool,
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_provider_type_serialization() {
439        // Verify all provider types serialize correctly
440        assert_eq!(
441            serde_json::to_string(&DriverId::OpenAI).unwrap(),
442            "\"openai\""
443        );
444        assert_eq!(
445            serde_json::to_string(&DriverId::OpenRouter).unwrap(),
446            "\"openrouter\""
447        );
448        assert_eq!(
449            serde_json::to_string(&DriverId::OpenAICompletions).unwrap(),
450            "\"openai_completions\""
451        );
452        assert_eq!(
453            serde_json::to_string(&DriverId::AzureOpenAI).unwrap(),
454            "\"azure_openai\""
455        );
456        assert_eq!(
457            serde_json::to_string(&DriverId::Anthropic).unwrap(),
458            "\"anthropic\""
459        );
460        assert_eq!(
461            serde_json::to_string(&DriverId::Gemini).unwrap(),
462            "\"gemini\""
463        );
464        assert_eq!(
465            serde_json::to_string(&DriverId::LlmSim).unwrap(),
466            "\"llmsim\""
467        );
468    }
469
470    #[test]
471    fn test_provider_type_deserialization() {
472        // Verify all provider types deserialize correctly
473        assert!(matches!(
474            serde_json::from_str::<DriverId>("\"openai\"").unwrap(),
475            DriverId::OpenAI
476        ));
477        assert!(matches!(
478            serde_json::from_str::<DriverId>("\"openrouter\"").unwrap(),
479            DriverId::OpenRouter
480        ));
481        assert!(matches!(
482            serde_json::from_str::<DriverId>("\"openai_completions\"").unwrap(),
483            DriverId::OpenAICompletions
484        ));
485        assert!(matches!(
486            serde_json::from_str::<DriverId>("\"azure_openai\"").unwrap(),
487            DriverId::AzureOpenAI
488        ));
489        assert!(matches!(
490            serde_json::from_str::<DriverId>("\"anthropic\"").unwrap(),
491            DriverId::Anthropic
492        ));
493        assert!(matches!(
494            serde_json::from_str::<DriverId>("\"gemini\"").unwrap(),
495            DriverId::Gemini
496        ));
497        assert!(matches!(
498            serde_json::from_str::<DriverId>("\"llmsim\"").unwrap(),
499            DriverId::LlmSim
500        ));
501    }
502
503    #[test]
504    fn test_provider_type_from_str() {
505        // Verify FromStr works correctly
506        assert!(matches!(
507            "openai".parse::<DriverId>().unwrap(),
508            DriverId::OpenAI
509        ));
510        assert!(matches!(
511            "openrouter".parse::<DriverId>().unwrap(),
512            DriverId::OpenRouter
513        ));
514        assert!(matches!(
515            "openai_completions".parse::<DriverId>().unwrap(),
516            DriverId::OpenAICompletions
517        ));
518        assert!(matches!(
519            "azure_openai".parse::<DriverId>().unwrap(),
520            DriverId::AzureOpenAI
521        ));
522        assert!(matches!(
523            "anthropic".parse::<DriverId>().unwrap(),
524            DriverId::Anthropic
525        ));
526        assert!(matches!(
527            "gemini".parse::<DriverId>().unwrap(),
528            DriverId::Gemini
529        ));
530        assert!(matches!(
531            "llmsim".parse::<DriverId>().unwrap(),
532            DriverId::LlmSim
533        ));
534    }
535
536    #[test]
537    fn test_model_limits_input_omitted_when_none() {
538        let limits = ModelLimits {
539            context: 200_000,
540            input: None,
541            output: 64_000,
542            max_media: None,
543        };
544        let json = serde_json::to_value(&limits).unwrap();
545        assert!(!json.as_object().unwrap().contains_key("input"));
546    }
547
548    #[test]
549    fn test_model_limits_input_included_when_some() {
550        let limits = ModelLimits {
551            context: 200_000,
552            input: Some(150_000),
553            output: 64_000,
554            max_media: None,
555        };
556        let json = serde_json::to_value(&limits).unwrap();
557        assert_eq!(json["input"], 150_000);
558    }
559
560    #[test]
561    fn test_model_limits_deserialize_without_input() {
562        let json = r#"{"context": 200000, "output": 64000}"#;
563        let limits: ModelLimits = serde_json::from_str(json).unwrap();
564        assert_eq!(limits.context, 200_000);
565        assert!(limits.input.is_none());
566        assert_eq!(limits.output, 64_000);
567    }
568
569    #[test]
570    fn test_provider_type_display() {
571        // Verify Display works correctly
572        assert_eq!(DriverId::OpenAI.to_string(), "openai");
573        assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
574        assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
575        assert_eq!(
576            DriverId::OpenAICompletions.to_string(),
577            "openai_completions"
578        );
579        assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
580        assert_eq!(DriverId::Gemini.to_string(), "gemini");
581        assert_eq!(DriverId::LlmSim.to_string(), "llmsim");
582    }
583}