1use sim_kernel::Symbol;
8use sim_shape::GrammarDialect;
9use std::time::Duration;
10
11pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum ProviderAuth {
17 None,
19 BearerEnv {
22 env: String,
24 },
25 OptionalBearerEnv {
28 env: String,
30 },
31 HeaderEnv {
34 header: String,
36 env: String,
38 },
39}
40
41impl ProviderAuth {
42 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 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#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct ProviderProfile {
65 pub provider: Symbol,
67 pub runner_symbol: Symbol,
69 pub codec: Symbol,
71 pub default_endpoint: String,
73 pub models_path: &'static str,
75 pub chat_path: &'static str,
77 pub auth: ProviderAuth,
79 pub default_locality: Symbol,
81 pub default_model: String,
83 pub default_timeout: Duration,
85 pub default_stream: bool,
87 pub default_tools: bool,
89 pub default_max_output_bytes: usize,
91 pub grammar_dialects: Vec<GrammarDialect>,
93}
94
95impl ProviderProfile {
96 pub fn openai() -> Self {
98 provider_profiles::openai()
99 }
100
101 pub fn anthropic() -> Self {
103 provider_profiles::anthropic()
104 }
105
106 pub fn ollama() -> Self {
108 provider_profiles::ollama()
109 }
110
111 pub fn lm_studio() -> Self {
113 provider_profiles::lm_studio()
114 }
115
116 pub fn lemonade() -> Self {
118 provider_profiles::lemonade()
119 }
120
121 pub fn openai_compatible() -> Self {
123 provider_profiles::openai_compatible()
124 }
125}
126
127pub 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 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 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 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 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 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 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 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
276pub fn openai_profile() -> ProviderProfile {
278 provider_profiles::openai()
279}
280
281pub fn anthropic_profile() -> ProviderProfile {
283 provider_profiles::anthropic()
284}
285
286pub fn ollama_profile() -> ProviderProfile {
288 provider_profiles::ollama()
289}
290
291pub fn lm_studio_profile() -> ProviderProfile {
293 provider_profiles::lm_studio()
294}
295
296pub fn lemonade_profile() -> ProviderProfile {
298 provider_profiles::lemonade()
299}
300
301pub 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}