everruns_core/provider.rs
1// Provider entity types (specs/providers.md)
2//
3// A Provider is an org-scoped instance of a driver: a configured vendor
4// account (credentials, endpoint) that powers services like chat. DriverId
5// names the driver implementation a provider uses.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::typed_id::ProviderId;
11
12#[cfg(feature = "openapi")]
13use utoipa::ToSchema;
14
15/// LLM provider type identifier.
16///
17/// Built-in variants cover providers shipped with everruns. Any string that
18/// does not match a built-in id becomes `External(id)`, so embedders can store
19/// and use custom provider ids without schema migrations.
20///
21/// Serializes to/from a plain string (e.g. `"anthropic"`, `"openai-codex"`).
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub enum DriverId {
24 /// OpenAI using Open Responses API (<https://www.openresponses.org/>)
25 OpenAI,
26 /// OpenRouter using the OpenAI-compatible Responses API
27 OpenRouter,
28 /// Azure OpenAI using the Azure-hosted OpenAI v1 API
29 AzureOpenAI,
30 /// OpenAI using Chat Completions API (for backward compatibility)
31 OpenAICompletions,
32 Anthropic,
33 /// Google Gemini API
34 Gemini,
35 /// LLM simulator for testing
36 LlmSim,
37 /// AWS Bedrock Runtime (ConverseStream API)
38 Bedrock,
39 /// Microsoft MAI models (e.g. MAI-Code-1-Flash) served via Azure AI Foundry.
40 /// Uses an OpenAI-compatible Chat Completions API and supports either an
41 /// Azure AI Foundry API key or Microsoft Entra ID (OAuth) authentication.
42 Mai,
43 /// Fireworks AI — open-model inference (Llama, Qwen, DeepSeek, GLM, ...)
44 /// served via an OpenAI-compatible Chat Completions API.
45 Fireworks,
46 /// Embedder-defined provider not compiled into everruns-core. The inner id
47 /// is the canonical wire string (e.g. `"openai-codex"`).
48 External(std::sync::Arc<str>),
49}
50
51impl std::fmt::Display for DriverId {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.write_str(self.as_str())
54 }
55}
56
57impl DriverId {
58 /// Construct an external driver id from its canonical wire id.
59 ///
60 /// The id is normalized to lowercase so registration and lookup match
61 /// case-insensitively, consistent with built-in parsing.
62 pub fn external(id: impl Into<std::sync::Arc<str>>) -> Self {
63 let id: std::sync::Arc<str> = id.into();
64 // Avoid reallocating when the id is already lowercase.
65 if id.bytes().any(|b| b.is_ascii_uppercase()) {
66 DriverId::External(std::sync::Arc::from(id.to_lowercase().as_str()))
67 } else {
68 DriverId::External(id)
69 }
70 }
71
72 /// Return the canonical string identifier for this provider.
73 pub fn as_str(&self) -> &str {
74 match self {
75 DriverId::OpenAI => "openai",
76 DriverId::OpenRouter => "openrouter",
77 DriverId::AzureOpenAI => "azure_openai",
78 DriverId::OpenAICompletions => "openai_completions",
79 DriverId::Anthropic => "anthropic",
80 DriverId::Gemini => "gemini",
81 DriverId::LlmSim => "llmsim",
82 DriverId::Bedrock => "bedrock",
83 DriverId::Mai => "mai",
84 DriverId::Fireworks => "fireworks",
85 DriverId::External(id) => id.as_ref(),
86 }
87 }
88
89 /// Default trace-link URL templates for this driver, as
90 /// `(generation_url_template, session_url_template)`.
91 ///
92 /// These are best-effort defaults for vendors that expose an observability
93 /// dashboard. They are only *defaults*: an org overrides them per provider
94 /// (`ProviderTraceConfig`) and must opt in via `enabled`, since most vendors
95 /// retain prompt/completion content only when logging is explicitly turned
96 /// on. Templates support the `{response_id}`, `{session_id}`, `{turn_id}`
97 /// and `{model}` placeholders.
98 ///
99 /// OpenRouter stores logged generations on its **Logs** page
100 /// (<https://openrouter.ai/logs>, gated behind the account's
101 /// "Input & Output Logging" Observability setting). OpenRouter does not
102 /// document a public deep-link by generation id, so the generation template
103 /// passes the id best-effort; worst case it lands on the Logs page where the
104 /// generation can be found by recency.
105 pub fn default_trace_templates(&self) -> (Option<String>, Option<String>) {
106 match self {
107 DriverId::OpenRouter => (
108 Some("https://openrouter.ai/logs?id={response_id}".to_string()),
109 Some("https://openrouter.ai/logs".to_string()),
110 ),
111 _ => (None, None),
112 }
113 }
114}
115
116impl std::str::FromStr for DriverId {
117 // Parsing never fails: unknown ids become `External`.
118 type Err = std::convert::Infallible;
119
120 fn from_str(s: &str) -> Result<Self, Self::Err> {
121 // Normalize once so built-in matching and the External id share the
122 // same lowercased form; casing variance never yields duplicate ids.
123 let lower = s.to_lowercase();
124 Ok(match lower.as_str() {
125 "openai" => DriverId::OpenAI,
126 "openrouter" => DriverId::OpenRouter,
127 "azure_openai" => DriverId::AzureOpenAI,
128 "openai_completions" => DriverId::OpenAICompletions,
129 "anthropic" => DriverId::Anthropic,
130 "gemini" => DriverId::Gemini,
131 "llmsim" => DriverId::LlmSim,
132 "bedrock" => DriverId::Bedrock,
133 "mai" => DriverId::Mai,
134 "fireworks" => DriverId::Fireworks,
135 _ => DriverId::External(std::sync::Arc::from(lower.as_str())),
136 })
137 }
138}
139
140impl Serialize for DriverId {
141 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
142 s.serialize_str(self.as_str())
143 }
144}
145
146impl<'de> Deserialize<'de> for DriverId {
147 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
148 let s = String::deserialize(d)?;
149 // FromStr is infallible (unknown ids become External).
150 Ok(s.parse().unwrap_or_else(|_| unreachable!()))
151 }
152}
153
154// `Arc<str>` does not implement `ToSchema`, so the schema is written by hand.
155// It is a plain string at the wire level regardless of the variant.
156#[cfg(feature = "openapi")]
157impl utoipa::ToSchema for DriverId {
158 fn name() -> std::borrow::Cow<'static, str> {
159 std::borrow::Cow::Borrowed("DriverId")
160 }
161}
162
163#[cfg(feature = "openapi")]
164impl utoipa::PartialSchema for DriverId {
165 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
166 utoipa::openapi::ObjectBuilder::new()
167 .schema_type(utoipa::openapi::schema::SchemaType::new(
168 utoipa::openapi::schema::Type::String,
169 ))
170 .description(Some(
171 "LLM provider type. Built-in: openai, openrouter, azure_openai, \
172 openai_completions, anthropic, gemini, llmsim, bedrock, mai, fireworks. \
173 Any other string is treated as an embedder-defined external provider.",
174 ))
175 .build()
176 .into()
177 }
178}
179
180/// LLM provider status
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[cfg_attr(feature = "openapi", derive(ToSchema))]
183#[serde(rename_all = "snake_case")]
184pub enum ProviderStatus {
185 Active,
186 Disabled,
187}
188
189/// Configuration for linking from the chat UI to a provider's observability
190/// dashboard ("trace"/"logs").
191///
192/// This is provider-agnostic: any driver with a dashboard can supply default
193/// templates (see [`DriverId::default_trace_templates`]), and an org enables
194/// links per provider once it has confirmed logging is on for that account.
195/// URL templates support the `{response_id}`, `{session_id}`, `{turn_id}` and
196/// `{model}` placeholders, so the same mechanism works for OpenRouter today and
197/// for third-party observability backends (Langfuse, Helicone, ...) via an
198/// override.
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200#[cfg_attr(feature = "openapi", derive(ToSchema))]
201pub struct ProviderTraceConfig {
202 /// Whether trace links should be shown for this provider. Defaults to
203 /// `false`: vendors typically do not retain trace content unless logging is
204 /// explicitly enabled, so the org opts in once that is set up.
205 pub enabled: bool,
206 /// URL template for a single generation's trace, e.g.
207 /// `"https://openrouter.ai/logs?id={response_id}"`.
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub generation_url_template: Option<String>,
210 /// URL template for a session's grouped trace, e.g.
211 /// `"https://openrouter.ai/logs"`.
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub session_url_template: Option<String>,
214}
215
216/// LLM Provider entity (API keys never exposed)
217/// Note: This is the entity struct, separate from the Provider trait in llm.rs
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[cfg_attr(feature = "openapi", derive(ToSchema))]
220pub struct Provider {
221 /// Prefixed public identifier. See [ID Schema](https://docs.everruns.com/advanced/id-schema/).
222 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "provider_01933b5a00007000800000000000001"))]
223 pub id: ProviderId,
224 /// Human-readable provider name. Safe to render in user-facing messages.
225 pub name: String,
226 /// Provider implementation type (OpenAI, Anthropic, Gemini, etc.).
227 pub provider_type: DriverId,
228 /// Custom base URL for self-hosted / proxied providers. `None` means use the provider's default endpoint.
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub base_url: Option<String>,
231 /// Whether an API key is configured. The key itself is never returned.
232 pub api_key_set: bool,
233 /// Current lifecycle status of this provider.
234 pub status: ProviderStatus,
235 /// Timestamp of the most recent successful model sync from the provider's API (RFC 3339).
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub last_synced_at: Option<DateTime<Utc>>,
238 /// Timestamp when this provider was created (RFC 3339).
239 pub created_at: DateTime<Utc>,
240 /// Timestamp when this provider was last updated (RFC 3339).
241 pub updated_at: DateTime<Utc>,
242 /// Resolved trace/observability link configuration: the driver's default
243 /// templates overlaid with this provider's stored overrides. `None` when the
244 /// driver exposes no dashboard and the org configured nothing.
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub trace: Option<ProviderTraceConfig>,
247}