Skip to main content

sim_lib_agent_runner_http/
provider.rs

1//! Open HTTP-provider profile records.
2//!
3//! Profiles are ordinary data owned by the HTTP runner crate. Provider identity
4//! stays outside the kernel so new providers can be described without adding a
5//! closed enum to `sim-kernel`.
6
7use sim_kernel::Symbol;
8use sim_shape::GrammarDialect;
9use std::time::Duration;
10
11/// Default maximum decoded provider response size.
12pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
13
14/// Authentication shape used by a provider profile.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum ProviderAuth {
17    /// The provider does not need authentication by default.
18    None,
19    /// The provider expects an `Authorization: Bearer <secret>` value read from
20    /// an environment variable.
21    BearerEnv {
22        /// Default environment variable name.
23        env: String,
24    },
25    /// The provider may accept an `Authorization: Bearer <secret>` value, but
26    /// does not require one by default.
27    OptionalBearerEnv {
28        /// Suggested environment variable name.
29        env: String,
30    },
31    /// The provider expects a named header value read from an environment
32    /// variable.
33    HeaderEnv {
34        /// Header name to send.
35        header: String,
36        /// Default environment variable name.
37        env: String,
38    },
39}
40
41impl ProviderAuth {
42    /// Returns the default environment variable named by this auth profile.
43    pub fn default_env(&self) -> Option<&str> {
44        match self {
45            Self::None => None,
46            Self::BearerEnv { env } | Self::HeaderEnv { env, .. } => Some(env),
47            Self::OptionalBearerEnv { .. } => None,
48        }
49    }
50
51    /// Returns the documented environment variable for optional auth shapes.
52    pub fn env_hint(&self) -> Option<&str> {
53        match self {
54            Self::None => None,
55            Self::BearerEnv { env }
56            | Self::OptionalBearerEnv { env }
57            | Self::HeaderEnv { env, .. } => Some(env),
58        }
59    }
60}
61
62/// Data profile for a concrete HTTP model provider.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct ProviderProfile {
65    /// Open provider id such as `openai`, `anthropic`, or `ollama`.
66    pub provider: Symbol,
67    /// Default runner symbol exposed by the agent library.
68    pub runner_symbol: Symbol,
69    /// Runtime codec symbol for encoding/decoding provider payloads.
70    pub codec: Symbol,
71    /// Default base endpoint. An empty value means the caller must supply one.
72    pub default_endpoint: String,
73    /// Provider path that lists models relative to the base endpoint.
74    pub models_path: &'static str,
75    /// Provider path that performs chat inference relative to the base endpoint.
76    pub chat_path: &'static str,
77    /// Provider authentication profile.
78    pub auth: ProviderAuth,
79    /// Default runner locality, either `local` or `network`.
80    pub default_locality: Symbol,
81    /// Default model name used when the option map does not provide `model`.
82    pub default_model: String,
83    /// Default HTTP timeout.
84    pub default_timeout: Duration,
85    /// Default streaming flag.
86    pub default_stream: bool,
87    /// Default tool-use flag.
88    pub default_tools: bool,
89    /// Default response byte limit.
90    pub default_max_output_bytes: usize,
91    /// Grammar dialects this provider can enforce directly.
92    pub grammar_dialects: Vec<GrammarDialect>,
93}
94
95impl ProviderProfile {
96    /// Returns the native OpenAI profile.
97    pub fn openai() -> Self {
98        provider_profiles::openai()
99    }
100
101    /// Returns the native Anthropic profile.
102    pub fn anthropic() -> Self {
103        provider_profiles::anthropic()
104    }
105
106    /// Returns the native Ollama profile.
107    pub fn ollama() -> Self {
108        provider_profiles::ollama()
109    }
110
111    /// Returns the native LM Studio profile.
112    pub fn lm_studio() -> Self {
113        provider_profiles::lm_studio()
114    }
115
116    /// Returns the native Lemonade profile.
117    pub fn lemonade() -> Self {
118        provider_profiles::lemonade()
119    }
120
121    /// Returns the generic OpenAI-compatible fallback profile.
122    pub fn openai_compatible() -> Self {
123        provider_profiles::openai_compatible()
124    }
125}
126
127/// Provider profile constructors.
128pub mod provider_profiles {
129    use super::{DEFAULT_MAX_OUTPUT_BYTES, ProviderAuth, ProviderProfile};
130    use sim_kernel::Symbol;
131    use sim_shape::GrammarDialect;
132    use std::time::Duration;
133
134    /// Returns every built-in provider profile.
135    pub fn all() -> Vec<ProviderProfile> {
136        vec![
137            openai(),
138            anthropic(),
139            ollama(),
140            lm_studio(),
141            lemonade(),
142            openai_compatible(),
143        ]
144    }
145
146    /// Native OpenAI provider profile.
147    pub fn openai() -> ProviderProfile {
148        ProviderProfile {
149            provider: Symbol::new("openai"),
150            runner_symbol: Symbol::qualified("runner", "openai"),
151            codec: Symbol::qualified("codec", "openai"),
152            default_endpoint: "https://api.openai.com/v1".to_owned(),
153            models_path: "/models",
154            chat_path: "/chat/completions",
155            auth: ProviderAuth::BearerEnv {
156                env: "OPENAI_API_KEY".to_owned(),
157            },
158            default_locality: Symbol::new("network"),
159            default_model: "gpt-5-mini".to_owned(),
160            default_timeout: Duration::from_secs(60),
161            default_stream: false,
162            default_tools: true,
163            default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
164            grammar_dialects: vec![GrammarDialect::JsonSchema],
165        }
166    }
167
168    /// Native Anthropic provider profile.
169    pub fn anthropic() -> ProviderProfile {
170        ProviderProfile {
171            provider: Symbol::new("anthropic"),
172            runner_symbol: Symbol::qualified("runner", "anthropic"),
173            codec: Symbol::qualified("codec", "anthropic"),
174            default_endpoint: "https://api.anthropic.com/v1".to_owned(),
175            models_path: "/models",
176            chat_path: "/messages",
177            auth: ProviderAuth::HeaderEnv {
178                header: "x-api-key".to_owned(),
179                env: "ANTHROPIC_API_KEY".to_owned(),
180            },
181            default_locality: Symbol::new("network"),
182            default_model: "claude-sonnet-latest".to_owned(),
183            default_timeout: Duration::from_secs(60),
184            default_stream: false,
185            default_tools: true,
186            default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
187            grammar_dialects: Vec::new(),
188        }
189    }
190
191    /// Native Ollama provider profile.
192    pub fn ollama() -> ProviderProfile {
193        ProviderProfile {
194            provider: Symbol::new("ollama"),
195            runner_symbol: Symbol::qualified("runner", "ollama"),
196            codec: Symbol::qualified("codec", "ollama"),
197            default_endpoint: "http://127.0.0.1:11434".to_owned(),
198            models_path: "/api/tags",
199            chat_path: "/api/chat",
200            auth: ProviderAuth::None,
201            default_locality: Symbol::new("local"),
202            default_model: "qwen3.5:4b".to_owned(),
203            default_timeout: Duration::from_secs(120),
204            default_stream: true,
205            default_tools: false,
206            default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
207            grammar_dialects: vec![GrammarDialect::Gbnf],
208        }
209    }
210
211    /// Native LM Studio provider profile.
212    pub fn lm_studio() -> ProviderProfile {
213        ProviderProfile {
214            provider: Symbol::new("lm-studio"),
215            runner_symbol: Symbol::qualified("runner", "lm-studio"),
216            codec: Symbol::qualified("codec", "lm-studio"),
217            default_endpoint: "http://127.0.0.1:1234/v1".to_owned(),
218            models_path: "/models",
219            chat_path: "/chat/completions",
220            auth: ProviderAuth::OptionalBearerEnv {
221                env: "LM_STUDIO_API_KEY".to_owned(),
222            },
223            default_locality: Symbol::new("local"),
224            default_model: "local/default".to_owned(),
225            default_timeout: Duration::from_secs(120),
226            default_stream: false,
227            default_tools: true,
228            default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
229            grammar_dialects: Vec::new(),
230        }
231    }
232
233    /// Native Lemonade provider profile.
234    pub fn lemonade() -> ProviderProfile {
235        ProviderProfile {
236            provider: Symbol::new("lemonade"),
237            runner_symbol: Symbol::qualified("runner", "lemonade"),
238            codec: Symbol::qualified("codec", "lemonade"),
239            default_endpoint: "http://127.0.0.1:13305/v1".to_owned(),
240            models_path: "/models",
241            chat_path: "/chat/completions",
242            auth: ProviderAuth::None,
243            default_locality: Symbol::new("local"),
244            default_model: "Qwen3-Coder".to_owned(),
245            default_timeout: Duration::from_secs(120),
246            default_stream: false,
247            default_tools: true,
248            default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
249            grammar_dialects: Vec::new(),
250        }
251    }
252
253    /// Generic OpenAI-compatible fallback profile.
254    pub fn openai_compatible() -> ProviderProfile {
255        ProviderProfile {
256            provider: Symbol::new("openai-compatible"),
257            runner_symbol: Symbol::qualified("runner", "openai-compatible"),
258            codec: Symbol::qualified("codec", "openai"),
259            default_endpoint: String::new(),
260            models_path: "/models",
261            chat_path: "/chat/completions",
262            auth: ProviderAuth::BearerEnv {
263                env: "OPENAI_API_KEY".to_owned(),
264            },
265            default_locality: Symbol::new("network"),
266            default_model: "gpt-4.1-mini".to_owned(),
267            default_timeout: Duration::from_secs(60),
268            default_stream: false,
269            default_tools: true,
270            default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
271            grammar_dialects: Vec::new(),
272        }
273    }
274}
275
276/// Native OpenAI provider profile.
277pub fn openai_profile() -> ProviderProfile {
278    provider_profiles::openai()
279}
280
281/// Native Anthropic provider profile.
282pub fn anthropic_profile() -> ProviderProfile {
283    provider_profiles::anthropic()
284}
285
286/// Native Ollama provider profile.
287pub fn ollama_profile() -> ProviderProfile {
288    provider_profiles::ollama()
289}
290
291/// Native LM Studio provider profile.
292pub fn lm_studio_profile() -> ProviderProfile {
293    provider_profiles::lm_studio()
294}
295
296/// Native Lemonade provider profile.
297pub fn lemonade_profile() -> ProviderProfile {
298    provider_profiles::lemonade()
299}
300
301/// Generic OpenAI-compatible fallback provider profile.
302pub fn openai_compatible_profile() -> ProviderProfile {
303    provider_profiles::openai_compatible()
304}
305
306#[cfg(test)]
307mod tests {
308    use super::{ProviderAuth, ProviderProfile, provider_profiles};
309    use sim_kernel::Symbol;
310
311    #[test]
312    fn profiles_cover_known_providers_and_generic_fallback() {
313        let providers = provider_profiles::all()
314            .into_iter()
315            .map(|profile| profile.provider.to_string())
316            .collect::<Vec<_>>();
317        assert_eq!(
318            providers,
319            vec![
320                "openai",
321                "anthropic",
322                "ollama",
323                "lm-studio",
324                "lemonade",
325                "openai-compatible"
326            ]
327        );
328    }
329
330    #[test]
331    fn anthropic_profile_records_header_auth_and_messages_path() {
332        let profile = ProviderProfile::anthropic();
333        assert_eq!(profile.provider, Symbol::new("anthropic"));
334        assert_eq!(
335            profile.runner_symbol,
336            Symbol::qualified("runner", "anthropic")
337        );
338        assert_eq!(profile.codec, Symbol::qualified("codec", "anthropic"));
339        assert_eq!(profile.default_endpoint, "https://api.anthropic.com/v1");
340        assert_eq!(profile.models_path, "/models");
341        assert_eq!(profile.chat_path, "/messages");
342        assert_eq!(
343            profile.auth,
344            ProviderAuth::HeaderEnv {
345                header: "x-api-key".to_owned(),
346                env: "ANTHROPIC_API_KEY".to_owned()
347            }
348        );
349        assert_eq!(profile.default_locality, Symbol::new("network"));
350    }
351
352    #[test]
353    fn local_profiles_default_to_loopback_without_auth() {
354        for profile in [ProviderProfile::ollama(), ProviderProfile::lemonade()] {
355            assert_eq!(profile.auth, ProviderAuth::None);
356            assert_eq!(profile.default_locality, Symbol::new("local"));
357            assert!(profile.default_endpoint.starts_with("http://127.0.0.1:"));
358        }
359    }
360
361    #[test]
362    fn lm_studio_profile_records_optional_bearer_auth() {
363        let profile = ProviderProfile::lm_studio();
364
365        assert_eq!(
366            profile.auth,
367            ProviderAuth::OptionalBearerEnv {
368                env: "LM_STUDIO_API_KEY".to_owned()
369            }
370        );
371        assert_eq!(profile.auth.default_env(), None);
372        assert_eq!(profile.auth.env_hint(), Some("LM_STUDIO_API_KEY"));
373        assert_eq!(profile.default_locality, Symbol::new("local"));
374        assert!(profile.default_endpoint.starts_with("http://127.0.0.1:"));
375    }
376}