1use sim_kernel::Symbol;
8use std::time::Duration;
9
10pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
12
13#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum ProviderAuth {
16 None,
18 BearerEnv {
21 env: String,
23 },
24 OptionalBearerEnv {
27 env: String,
29 },
30 HeaderEnv {
33 header: String,
35 env: String,
37 },
38}
39
40impl ProviderAuth {
41 pub fn default_env(&self) -> Option<&str> {
43 match self {
44 Self::None => None,
45 Self::BearerEnv { env } | Self::HeaderEnv { env, .. } => Some(env),
46 Self::OptionalBearerEnv { .. } => None,
47 }
48 }
49
50 pub fn env_hint(&self) -> Option<&str> {
52 match self {
53 Self::None => None,
54 Self::BearerEnv { env }
55 | Self::OptionalBearerEnv { env }
56 | Self::HeaderEnv { env, .. } => Some(env),
57 }
58 }
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct ProviderProfile {
64 pub provider: Symbol,
66 pub runner_symbol: Symbol,
68 pub codec: Symbol,
70 pub default_endpoint: String,
72 pub models_path: &'static str,
74 pub chat_path: &'static str,
76 pub auth: ProviderAuth,
78 pub default_locality: Symbol,
80 pub default_model: String,
82 pub default_timeout: Duration,
84 pub default_stream: bool,
86 pub default_tools: bool,
88 pub default_max_output_bytes: usize,
90}
91
92impl ProviderProfile {
93 pub fn openai() -> Self {
95 provider_profiles::openai()
96 }
97
98 pub fn anthropic() -> Self {
100 provider_profiles::anthropic()
101 }
102
103 pub fn ollama() -> Self {
105 provider_profiles::ollama()
106 }
107
108 pub fn lm_studio() -> Self {
110 provider_profiles::lm_studio()
111 }
112
113 pub fn lemonade() -> Self {
115 provider_profiles::lemonade()
116 }
117
118 pub fn openai_compatible() -> Self {
120 provider_profiles::openai_compatible()
121 }
122}
123
124pub mod provider_profiles {
126 use super::{DEFAULT_MAX_OUTPUT_BYTES, ProviderAuth, ProviderProfile};
127 use sim_kernel::Symbol;
128 use std::time::Duration;
129
130 pub fn all() -> Vec<ProviderProfile> {
132 vec![
133 openai(),
134 anthropic(),
135 ollama(),
136 lm_studio(),
137 lemonade(),
138 openai_compatible(),
139 ]
140 }
141
142 pub fn openai() -> ProviderProfile {
144 ProviderProfile {
145 provider: Symbol::new("openai"),
146 runner_symbol: Symbol::qualified("runner", "openai"),
147 codec: Symbol::qualified("codec", "openai"),
148 default_endpoint: "https://api.openai.com/v1".to_owned(),
149 models_path: "/models",
150 chat_path: "/chat/completions",
151 auth: ProviderAuth::BearerEnv {
152 env: "OPENAI_API_KEY".to_owned(),
153 },
154 default_locality: Symbol::new("network"),
155 default_model: "gpt-5-mini".to_owned(),
156 default_timeout: Duration::from_secs(60),
157 default_stream: false,
158 default_tools: true,
159 default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
160 }
161 }
162
163 pub fn anthropic() -> ProviderProfile {
165 ProviderProfile {
166 provider: Symbol::new("anthropic"),
167 runner_symbol: Symbol::qualified("runner", "anthropic"),
168 codec: Symbol::qualified("codec", "anthropic"),
169 default_endpoint: "https://api.anthropic.com/v1".to_owned(),
170 models_path: "/models",
171 chat_path: "/messages",
172 auth: ProviderAuth::HeaderEnv {
173 header: "x-api-key".to_owned(),
174 env: "ANTHROPIC_API_KEY".to_owned(),
175 },
176 default_locality: Symbol::new("network"),
177 default_model: "claude-sonnet-latest".to_owned(),
178 default_timeout: Duration::from_secs(60),
179 default_stream: false,
180 default_tools: true,
181 default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
182 }
183 }
184
185 pub fn ollama() -> ProviderProfile {
187 ProviderProfile {
188 provider: Symbol::new("ollama"),
189 runner_symbol: Symbol::qualified("runner", "ollama"),
190 codec: Symbol::qualified("codec", "ollama"),
191 default_endpoint: "http://127.0.0.1:11434".to_owned(),
192 models_path: "/api/tags",
193 chat_path: "/api/chat",
194 auth: ProviderAuth::None,
195 default_locality: Symbol::new("local"),
196 default_model: "qwen3.5:4b".to_owned(),
197 default_timeout: Duration::from_secs(120),
198 default_stream: true,
199 default_tools: false,
200 default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
201 }
202 }
203
204 pub fn lm_studio() -> ProviderProfile {
206 ProviderProfile {
207 provider: Symbol::new("lm-studio"),
208 runner_symbol: Symbol::qualified("runner", "lm-studio"),
209 codec: Symbol::qualified("codec", "lm-studio"),
210 default_endpoint: "http://127.0.0.1:1234/v1".to_owned(),
211 models_path: "/models",
212 chat_path: "/chat/completions",
213 auth: ProviderAuth::OptionalBearerEnv {
214 env: "LM_STUDIO_API_KEY".to_owned(),
215 },
216 default_locality: Symbol::new("local"),
217 default_model: "local/default".to_owned(),
218 default_timeout: Duration::from_secs(120),
219 default_stream: false,
220 default_tools: true,
221 default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
222 }
223 }
224
225 pub fn lemonade() -> ProviderProfile {
227 ProviderProfile {
228 provider: Symbol::new("lemonade"),
229 runner_symbol: Symbol::qualified("runner", "lemonade"),
230 codec: Symbol::qualified("codec", "lemonade"),
231 default_endpoint: "http://127.0.0.1:13305/v1".to_owned(),
232 models_path: "/models",
233 chat_path: "/chat/completions",
234 auth: ProviderAuth::None,
235 default_locality: Symbol::new("local"),
236 default_model: "Qwen3-Coder".to_owned(),
237 default_timeout: Duration::from_secs(120),
238 default_stream: false,
239 default_tools: true,
240 default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
241 }
242 }
243
244 pub fn openai_compatible() -> ProviderProfile {
246 ProviderProfile {
247 provider: Symbol::new("openai-compatible"),
248 runner_symbol: Symbol::qualified("runner", "openai-compatible"),
249 codec: Symbol::qualified("codec", "openai"),
250 default_endpoint: String::new(),
251 models_path: "/models",
252 chat_path: "/chat/completions",
253 auth: ProviderAuth::BearerEnv {
254 env: "OPENAI_API_KEY".to_owned(),
255 },
256 default_locality: Symbol::new("network"),
257 default_model: "gpt-4.1-mini".to_owned(),
258 default_timeout: Duration::from_secs(60),
259 default_stream: false,
260 default_tools: true,
261 default_max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
262 }
263 }
264}
265
266pub fn openai_profile() -> ProviderProfile {
268 provider_profiles::openai()
269}
270
271pub fn anthropic_profile() -> ProviderProfile {
273 provider_profiles::anthropic()
274}
275
276pub fn ollama_profile() -> ProviderProfile {
278 provider_profiles::ollama()
279}
280
281pub fn lm_studio_profile() -> ProviderProfile {
283 provider_profiles::lm_studio()
284}
285
286pub fn lemonade_profile() -> ProviderProfile {
288 provider_profiles::lemonade()
289}
290
291pub fn openai_compatible_profile() -> ProviderProfile {
293 provider_profiles::openai_compatible()
294}
295
296#[cfg(test)]
297mod tests {
298 use super::{ProviderAuth, ProviderProfile, provider_profiles};
299 use sim_kernel::Symbol;
300
301 #[test]
302 fn profiles_cover_known_providers_and_generic_fallback() {
303 let providers = provider_profiles::all()
304 .into_iter()
305 .map(|profile| profile.provider.to_string())
306 .collect::<Vec<_>>();
307 assert_eq!(
308 providers,
309 vec![
310 "openai",
311 "anthropic",
312 "ollama",
313 "lm-studio",
314 "lemonade",
315 "openai-compatible"
316 ]
317 );
318 }
319
320 #[test]
321 fn anthropic_profile_records_header_auth_and_messages_path() {
322 let profile = ProviderProfile::anthropic();
323 assert_eq!(profile.provider, Symbol::new("anthropic"));
324 assert_eq!(
325 profile.runner_symbol,
326 Symbol::qualified("runner", "anthropic")
327 );
328 assert_eq!(profile.codec, Symbol::qualified("codec", "anthropic"));
329 assert_eq!(profile.default_endpoint, "https://api.anthropic.com/v1");
330 assert_eq!(profile.models_path, "/models");
331 assert_eq!(profile.chat_path, "/messages");
332 assert_eq!(
333 profile.auth,
334 ProviderAuth::HeaderEnv {
335 header: "x-api-key".to_owned(),
336 env: "ANTHROPIC_API_KEY".to_owned()
337 }
338 );
339 assert_eq!(profile.default_locality, Symbol::new("network"));
340 }
341
342 #[test]
343 fn local_profiles_default_to_loopback_without_auth() {
344 for profile in [ProviderProfile::ollama(), ProviderProfile::lemonade()] {
345 assert_eq!(profile.auth, ProviderAuth::None);
346 assert_eq!(profile.default_locality, Symbol::new("local"));
347 assert!(profile.default_endpoint.starts_with("http://127.0.0.1:"));
348 }
349 }
350
351 #[test]
352 fn lm_studio_profile_records_optional_bearer_auth() {
353 let profile = ProviderProfile::lm_studio();
354
355 assert_eq!(
356 profile.auth,
357 ProviderAuth::OptionalBearerEnv {
358 env: "LM_STUDIO_API_KEY".to_owned()
359 }
360 );
361 assert_eq!(profile.auth.default_env(), None);
362 assert_eq!(profile.auth.env_hint(), Some("LM_STUDIO_API_KEY"));
363 assert_eq!(profile.default_locality, Symbol::new("local"));
364 assert!(profile.default_endpoint.starts_with("http://127.0.0.1:"));
365 }
366}