1use std::sync::Arc;
2
3#[cfg(feature = "gemini")]
4use swink_agent::ApiVersion;
5use swink_agent::{CatalogPreset, ModelConnection, ProviderKind, StreamFn, model_catalog};
6use thiserror::Error;
7
8#[cfg(feature = "anthropic")]
9use crate::AnthropicStreamFn;
10#[cfg(feature = "bedrock")]
11use crate::BedrockStreamFn;
12#[cfg(feature = "gemini")]
13use crate::GeminiStreamFn;
14#[cfg(feature = "mistral")]
15use crate::MistralStreamFn;
16#[cfg(feature = "openai")]
17use crate::OpenAiStreamFn;
18#[cfg(feature = "xai")]
19use crate::XAiStreamFn;
20#[cfg(feature = "azure")]
21use crate::{AzureAuth, AzureStreamFn};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct RemotePresetKey {
25 pub provider_key: &'static str,
26 pub preset_id: &'static str,
27}
28
29impl RemotePresetKey {
30 #[must_use]
31 pub const fn new(provider_key: &'static str, preset_id: &'static str) -> Self {
32 Self {
33 provider_key,
34 preset_id,
35 }
36 }
37}
38
39#[derive(Debug, Error, PartialEq, Eq)]
40pub enum RemoteModelConnectionError {
41 #[error("Unknown remote preset {provider_key}.{preset_id}")]
42 UnknownPreset {
43 provider_key: &'static str,
44 preset_id: &'static str,
45 },
46 #[error("No remote preset found for model_id \"{model_id}\"")]
47 UnknownModelId { model_id: String },
48 #[error("{provider_key}.{preset_id} is not a remote preset")]
49 NotRemotePreset {
50 provider_key: String,
51 preset_id: String,
52 },
53 #[error(
54 "Missing {env_var} for {preset}. Set it in your environment or .env before launching the example."
55 )]
56 MissingCredential { preset: String, env_var: String },
57 #[error(
58 "Missing {env_var} for {preset}. Set it in your environment or .env before launching the example."
59 )]
60 MissingBaseUrl { preset: String, env_var: String },
61 #[error(
62 "Missing {env_var} for {preset}. Set it in your environment or .env before launching the example."
63 )]
64 MissingRegion { preset: String, env_var: String },
65 #[error(
66 "Missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY for {preset}. Set AWS credentials in your environment or .env before launching the example."
67 )]
68 MissingAwsCredentials { preset: String },
69 #[error("Unsupported provider \"{provider_key}\" — no adapter feature enabled")]
70 UnsupportedProvider { provider_key: String },
71}
72
73#[must_use]
79#[allow(clippy::match_like_matches_macro)] pub fn is_provider_compiled(provider_key: &str) -> bool {
81 match provider_key {
82 "anthropic" => cfg!(feature = "anthropic"),
83 "openai" => cfg!(feature = "openai"),
84 "google" => cfg!(feature = "gemini"),
85 "azure" => cfg!(feature = "azure"),
86 "xai" => cfg!(feature = "xai"),
87 "mistral" => cfg!(feature = "mistral"),
88 "bedrock" => cfg!(feature = "bedrock"),
89 _ => false,
90 }
91}
92
93#[must_use]
97pub fn remote_presets(provider_key: Option<&str>) -> Vec<CatalogPreset> {
98 all_remote_presets(provider_key)
99 .into_iter()
100 .filter(|p| is_provider_compiled(&p.provider_key))
101 .collect()
102}
103
104#[must_use]
109pub fn all_remote_presets(provider_key: Option<&str>) -> Vec<CatalogPreset> {
110 let catalog = model_catalog();
111 catalog
112 .providers
113 .iter()
114 .filter(|provider| provider.kind == ProviderKind::Remote)
115 .filter(|provider| provider_key.is_none_or(|key| provider.key == key))
116 .flat_map(|provider| {
117 provider
118 .presets
119 .iter()
120 .filter_map(|preset| catalog.preset(&provider.key, &preset.id))
121 })
122 .collect()
123}
124
125pub fn build_remote_connection(
126 key: RemotePresetKey,
127) -> Result<ModelConnection, RemoteModelConnectionError> {
128 let preset = required_catalog_preset(key)?;
129 build_connection_from_preset(
130 &preset,
131 preset
132 .credential_env_var
133 .as_deref()
134 .and_then(|env_var| std::env::var(env_var).ok()),
135 preset
136 .base_url_env_var
137 .as_deref()
138 .and_then(|env_var| std::env::var(env_var).ok())
139 .as_deref(),
140 )
141}
142
143pub fn build_remote_connection_with_credential(
149 key: RemotePresetKey,
150 api_key: Option<String>,
151 base_url: Option<&str>,
152) -> Result<ModelConnection, RemoteModelConnectionError> {
153 let preset = required_catalog_preset(key)?;
154 build_connection_from_preset(&preset, api_key, base_url)
155}
156
157#[allow(unreachable_code, unused_variables)]
158pub fn build_connection_from_preset(
159 preset: &CatalogPreset,
160 api_key: Option<String>,
161 base_url: Option<&str>,
162) -> Result<ModelConnection, RemoteModelConnectionError> {
163 if preset.provider_kind != ProviderKind::Remote {
164 return Err(RemoteModelConnectionError::NotRemotePreset {
165 provider_key: preset.provider_key.clone(),
166 preset_id: preset.preset_id.clone(),
167 });
168 }
169
170 let provider_key = preset.provider_key.as_str();
171
172 let api_key = if provider_key == "bedrock" {
173 String::new()
174 } else {
175 let env_var = preset.credential_env_var.clone().ok_or_else(|| {
176 RemoteModelConnectionError::UnsupportedProvider {
177 provider_key: provider_key.to_string(),
178 }
179 })?;
180 match api_key {
181 Some(value) if !value.trim().is_empty() => value,
182 _ => {
183 return Err(RemoteModelConnectionError::MissingCredential {
184 preset: preset.display_name.clone(),
185 env_var,
186 });
187 }
188 }
189 };
190
191 let resolved_base_url = || {
192 base_url
193 .map(str::to_string)
194 .or_else(|| preset.default_base_url.clone())
195 .ok_or_else(|| RemoteModelConnectionError::MissingBaseUrl {
196 preset: preset.display_name.clone(),
197 env_var: preset
198 .base_url_env_var
199 .clone()
200 .unwrap_or_else(|| "BASE_URL".to_string()),
201 })
202 };
203 let stream_fn: Arc<dyn StreamFn> = match provider_key {
204 #[cfg(feature = "anthropic")]
205 "anthropic" => Arc::new(AnthropicStreamFn::new(resolved_base_url()?, &api_key)),
206 #[cfg(feature = "openai")]
207 "openai" => Arc::new(OpenAiStreamFn::new(resolved_base_url()?, &api_key)),
208 #[cfg(feature = "gemini")]
209 "google" => Arc::new(GeminiStreamFn::new(
210 resolved_base_url()?,
211 &api_key,
212 preset.api_version.clone().unwrap_or(ApiVersion::V1beta),
213 )),
214 #[cfg(feature = "azure")]
215 #[allow(clippy::redundant_clone)]
216 "azure" => Arc::new(AzureStreamFn::new(
218 resolved_base_url()?,
219 AzureAuth::ApiKey(api_key.clone()),
220 )),
221 #[cfg(feature = "xai")]
222 "xai" => Arc::new(XAiStreamFn::new(resolved_base_url()?, &api_key)),
223 #[cfg(feature = "mistral")]
224 "mistral" => Arc::new(MistralStreamFn::new(resolved_base_url()?, &api_key)),
225 #[cfg(feature = "bedrock")]
226 "bedrock" => {
227 let region_env_var = preset
228 .region_env_var
229 .clone()
230 .unwrap_or_else(|| "AWS_REGION".to_string());
231 let region = std::env::var(®ion_env_var).map_err(|_| {
232 RemoteModelConnectionError::MissingRegion {
233 preset: preset.display_name.clone(),
234 env_var: region_env_var,
235 }
236 })?;
237 let access_key_id = std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| {
238 RemoteModelConnectionError::MissingAwsCredentials {
239 preset: preset.display_name.clone(),
240 }
241 })?;
242 let secret_access_key = std::env::var("AWS_SECRET_ACCESS_KEY").map_err(|_| {
243 RemoteModelConnectionError::MissingAwsCredentials {
244 preset: preset.display_name.clone(),
245 }
246 })?;
247 let session_token = std::env::var("AWS_SESSION_TOKEN").ok();
248 Arc::new(BedrockStreamFn::new(
249 region,
250 access_key_id,
251 secret_access_key,
252 session_token,
253 ))
254 }
255 _ => {
256 return Err(RemoteModelConnectionError::UnsupportedProvider {
257 provider_key: provider_key.to_string(),
258 });
259 }
260 };
261 Ok(ModelConnection::new(preset.model_spec(), stream_fn))
262}
263
264#[must_use]
270pub fn preset(model_id: &str) -> Option<CatalogPreset> {
271 remote_presets(None)
272 .into_iter()
273 .find(|p| p.model_id == model_id)
274}
275
276pub fn build_remote_connection_for_model(
288 model_id: &str,
289) -> Result<ModelConnection, RemoteModelConnectionError> {
290 let catalog_preset =
291 preset(model_id).ok_or_else(|| RemoteModelConnectionError::UnknownModelId {
292 model_id: model_id.to_string(),
293 })?;
294 build_connection_from_preset(
295 &catalog_preset,
296 catalog_preset
297 .credential_env_var
298 .as_deref()
299 .and_then(|env_var| std::env::var(env_var).ok()),
300 catalog_preset
301 .base_url_env_var
302 .as_deref()
303 .and_then(|env_var| std::env::var(env_var).ok())
304 .as_deref(),
305 )
306}
307
308fn required_catalog_preset(
309 key: RemotePresetKey,
310) -> Result<CatalogPreset, RemoteModelConnectionError> {
311 model_catalog()
312 .preset(key.provider_key, key.preset_id)
313 .ok_or(RemoteModelConnectionError::UnknownPreset {
314 provider_key: key.provider_key,
315 preset_id: key.preset_id,
316 })
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
326 fn all_remote_presets_are_loaded_from_catalog() {
327 let all = all_remote_presets(None);
328 assert!(!all.is_empty(), "catalog should have remote presets");
329 }
330
331 #[test]
332 fn every_remote_provider_has_at_least_one_unfiltered_preset() {
333 let catalog = model_catalog();
334 for provider in &catalog.providers {
335 if provider.kind == ProviderKind::Remote {
336 let presets = all_remote_presets(Some(&provider.key));
337 assert!(
338 !presets.is_empty(),
339 "remote provider '{}' should have presets in the catalog",
340 provider.key
341 );
342 }
343 }
344 }
345
346 #[test]
347 fn all_catalog_remote_presets_resolvable_by_provider_and_preset_id() {
348 let catalog = model_catalog();
349 for p in all_remote_presets(None) {
350 let found = catalog
351 .preset(&p.provider_key, &p.preset_id)
352 .unwrap_or_else(|| {
353 panic!(
354 "catalog.preset('{}', '{}') must resolve for model_id '{}'",
355 p.provider_key, p.preset_id, p.model_id
356 )
357 });
358 assert_eq!(found.model_id, p.model_id);
359 }
360 }
361
362 #[test]
365 fn is_provider_compiled_returns_false_for_unknown_provider() {
366 assert!(!is_provider_compiled("nonexistent"));
367 assert!(!is_provider_compiled("local"));
368 assert!(!is_provider_compiled(""));
369 }
370
371 #[test]
372 fn is_provider_compiled_matches_feature_gates() {
373 assert_eq!(
375 is_provider_compiled("anthropic"),
376 cfg!(feature = "anthropic")
377 );
378 assert_eq!(is_provider_compiled("openai"), cfg!(feature = "openai"));
379 assert_eq!(is_provider_compiled("google"), cfg!(feature = "gemini"));
380 assert_eq!(is_provider_compiled("azure"), cfg!(feature = "azure"));
381 assert_eq!(is_provider_compiled("xai"), cfg!(feature = "xai"));
382 assert_eq!(is_provider_compiled("mistral"), cfg!(feature = "mistral"));
383 assert_eq!(is_provider_compiled("bedrock"), cfg!(feature = "bedrock"));
384 }
385
386 #[test]
389 fn remote_presets_only_contains_compiled_providers() {
390 for p in remote_presets(None) {
391 assert!(
392 is_provider_compiled(&p.provider_key),
393 "remote_presets() returned preset '{}' for provider '{}' which is not compiled",
394 p.preset_id,
395 p.provider_key
396 );
397 }
398 }
399
400 #[test]
401 fn remote_presets_subset_of_all_remote_presets() {
402 let filtered = remote_presets(None);
403 let all = all_remote_presets(None);
404 assert!(
405 filtered.len() <= all.len(),
406 "filtered ({}) must be <= all ({})",
407 filtered.len(),
408 all.len()
409 );
410 for p in &filtered {
412 assert!(
413 all.iter()
414 .any(|a| a.model_id == p.model_id && a.provider_key == p.provider_key),
415 "filtered preset '{}.{}' not found in all_remote_presets",
416 p.provider_key,
417 p.preset_id
418 );
419 }
420 }
421
422 #[cfg(not(any(
423 feature = "anthropic",
424 feature = "openai",
425 feature = "gemini",
426 feature = "azure",
427 feature = "xai",
428 feature = "mistral",
429 feature = "bedrock",
430 )))]
431 #[test]
432 fn remote_presets_empty_when_no_adapters_compiled() {
433 let presets = remote_presets(None);
434 assert!(
435 presets.is_empty(),
436 "remote_presets() should be empty with no adapter features, got {} presets",
437 presets.len()
438 );
439 }
440
441 #[cfg(all(feature = "xai", not(feature = "openai")))]
442 #[test]
443 fn xai_feature_does_not_mark_openai_as_compiled() {
444 assert!(is_provider_compiled("xai"));
445 assert!(!is_provider_compiled("openai"));
446 assert!(
447 remote_presets(None)
448 .into_iter()
449 .all(|preset| preset.provider_key != "openai"),
450 "xai-only builds must not expose OpenAI presets as compiled",
451 );
452 }
453
454 #[test]
457 fn preset_only_finds_compiled_providers() {
458 for p in all_remote_presets(None) {
461 let result = preset(&p.model_id);
462 if is_provider_compiled(&p.provider_key) {
463 if let Some(found) = &result {
466 assert!(
467 is_provider_compiled(&found.provider_key),
468 "preset('{}') returned uncompiled provider '{}'",
469 p.model_id,
470 found.provider_key
471 );
472 }
473 }
474 }
475 }
476
477 #[test]
478 fn preset_returns_none_for_nonexistent_model() {
479 assert!(preset("nonexistent-model-xyz").is_none());
480 }
481
482 #[test]
485 fn preset_key_resolves_via_catalog() {
486 let key = RemotePresetKey::new("anthropic", "sonnet_46");
487 let catalog_preset = required_catalog_preset(key).unwrap();
488 assert_eq!(catalog_preset.model_id, "claude-sonnet-4-6");
489 }
490
491 #[cfg(feature = "anthropic")]
494 #[test]
495 fn preset_finds_anthropic_when_compiled() {
496 let sonnet = preset("claude-sonnet-4-6").expect("sonnet preset should exist");
497 assert_eq!(sonnet.provider_key, "anthropic");
498 assert_eq!(sonnet.preset_id, "sonnet_46");
499 }
500
501 #[cfg(feature = "openai")]
502 #[test]
503 fn preset_finds_openai_when_compiled() {
504 let gpt = preset("gpt-5.4").expect("gpt-5.4 preset should exist");
505 assert_eq!(gpt.provider_key, "openai");
506 }
507
508 #[cfg(feature = "anthropic")]
509 #[test]
510 fn remote_connection_uses_catalog_model_spec() {
511 let key = RemotePresetKey::new("anthropic", "sonnet_46");
512 let preset = required_catalog_preset(key).unwrap();
513 let connection =
514 build_connection_from_preset(&preset, Some("test-key".to_string()), None).unwrap();
515 assert_eq!(connection.model_spec(), &preset.model_spec());
516 }
517
518 #[cfg(feature = "openai")]
519 #[test]
520 fn remote_preset_requires_key() {
521 let preset = preset("gpt-5.4").unwrap();
522 let err = match build_connection_from_preset(&preset, None, None) {
523 Ok(_) => panic!("expected missing credential error"),
524 Err(err) => err,
525 };
526 assert_eq!(
527 err,
528 RemoteModelConnectionError::MissingCredential {
529 preset: "OpenAI GPT-5.4".to_string(),
530 env_var: "OPENAI_API_KEY".to_string(),
531 }
532 );
533 }
534
535 #[cfg(feature = "anthropic")]
536 #[test]
537 fn explicit_credential_builds_remote_connection_without_env_lookup() {
538 let key = RemotePresetKey::new("anthropic", "sonnet_46");
539 let connection =
540 build_remote_connection_with_credential(key, Some("test-key".to_string()), None)
541 .unwrap();
542 assert_eq!(connection.model_spec().provider, "anthropic");
543 assert_eq!(connection.model_spec().model_id, "claude-sonnet-4-6");
544 }
545
546 #[cfg(feature = "anthropic")]
547 #[test]
548 fn explicit_credential_reports_missing_key() {
549 let key = RemotePresetKey::new("anthropic", "sonnet_46");
550 let err = match build_remote_connection_with_credential(key, None, None) {
551 Ok(_) => panic!("expected missing credential error"),
552 Err(err) => err,
553 };
554 assert_eq!(
555 err,
556 RemoteModelConnectionError::MissingCredential {
557 preset: "Anthropic Sonnet 4.6".to_string(),
558 env_var: "ANTHROPIC_API_KEY".to_string(),
559 }
560 );
561 }
562
563 #[cfg(feature = "bedrock")]
564 #[test]
565 fn bedrock_explicit_credential_path_does_not_require_api_key() {
566 let key = RemotePresetKey::new("bedrock", "anthropic_claude_sonnet_45");
567 let err = match build_remote_connection_with_credential(key, None, None) {
568 Ok(_) => panic!("expected missing region or AWS credentials"),
569 Err(err) => err,
570 };
571 assert!(
572 matches!(
573 err,
574 RemoteModelConnectionError::MissingRegion { .. }
575 | RemoteModelConnectionError::MissingAwsCredentials { .. }
576 ),
577 "bedrock should skip API-key validation, got {err:?}",
578 );
579 }
580
581 #[cfg(feature = "anthropic")]
585 #[test]
586 fn build_with_credential_rejects_explicit_empty_string() {
587 let key = RemotePresetKey::new("anthropic", "sonnet_46");
588 for candidate in [String::new(), " ".to_string(), "\t\n".to_string()] {
589 let err = match build_remote_connection_with_credential(key, Some(candidate), None) {
590 Ok(_) => panic!("empty/whitespace explicit credential must error"),
591 Err(err) => err,
592 };
593 assert!(
594 matches!(err, RemoteModelConnectionError::MissingCredential { .. }),
595 "expected MissingCredential, got {err:?}"
596 );
597 }
598 }
599
600 #[test]
604 fn build_with_credential_rejects_unknown_preset() {
605 let key = RemotePresetKey::new("anthropic", "nonexistent_preset_xyz");
606 let err =
607 match build_remote_connection_with_credential(key, Some("irrelevant".into()), None) {
608 Ok(_) => panic!("unknown preset must error"),
609 Err(err) => err,
610 };
611 assert_eq!(
612 err,
613 RemoteModelConnectionError::UnknownPreset {
614 provider_key: "anthropic",
615 preset_id: "nonexistent_preset_xyz",
616 }
617 );
618 }
619
620 #[cfg(feature = "bedrock")]
624 #[test]
625 fn bedrock_ignores_explicit_empty_api_key() {
626 let key = RemotePresetKey::new("bedrock", "anthropic_claude_sonnet_45");
627 if let Err(err) = build_remote_connection_with_credential(key, Some(String::new()), None) {
628 assert!(
629 !matches!(err, RemoteModelConnectionError::MissingCredential { .. }),
630 "bedrock must not surface MissingCredential when api_key is Some(\"\"); got {err:?}"
631 );
632 }
633 }
634
635 #[test]
636 fn build_remote_connection_for_model_rejects_unknown() {
637 let result = build_remote_connection_for_model("nonexistent-xyz");
638 assert!(result.is_err());
639 let err = result.err().unwrap();
640 assert_eq!(
641 err,
642 RemoteModelConnectionError::UnknownModelId {
643 model_id: "nonexistent-xyz".to_string(),
644 }
645 );
646 }
647
648 #[test]
649 fn preset_by_model_id_returns_a_match_for_every_filtered_model_id() {
650 let mut seen = std::collections::HashSet::new();
651 for p in remote_presets(None) {
652 if seen.insert(p.model_id.clone()) {
653 assert!(
654 preset(&p.model_id).is_some(),
655 "preset('{}') must return Some for a compiled catalog model_id",
656 p.model_id
657 );
658 }
659 }
660 }
661
662 #[cfg(feature = "anthropic")]
663 #[test]
664 fn preset_finds_representative_anthropic_model() {
665 let p = preset("claude-sonnet-4-6").expect("anthropic preset should exist");
666 assert_eq!(p.provider_key, "anthropic");
667 }
668
669 #[cfg(feature = "openai")]
670 #[test]
671 fn preset_finds_representative_openai_model() {
672 let p = preset("gpt-5.4").expect("openai preset should exist");
673 assert_eq!(p.provider_key, "openai");
674 }
675
676 #[cfg(feature = "gemini")]
677 #[test]
678 fn preset_finds_representative_gemini_model() {
679 let p = preset("gemini-3-flash-preview").expect("gemini preset should exist");
680 assert_eq!(p.provider_key, "google");
681 }
682
683 #[cfg(feature = "mistral")]
684 #[test]
685 fn preset_finds_representative_mistral_model() {
686 let p = preset("mistral-large-latest").expect("mistral preset should exist");
687 assert_eq!(p.provider_key, "mistral");
688 }
689}