Skip to main content

everruns_provider/
model.rs

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