Skip to main content

perspt_core/
portfolio.rs

1//! Model portfolio: several concurrently live provider handles (PSP-9
2//! systems 1–2).
3//!
4//! The orchestrator historically owned one `Arc<GenAIProvider>` bound to one
5//! adapter and one credential, so the four model tiers were four *names* on
6//! one vendor. The portfolio holds one handle per `[providers.<id>]` entry,
7//! addressed by the entry key.
8//!
9//! The capability record here is the **core-native mirror** of
10//! `perspt-sdk::ProviderCapabilities` — `perspt-core` must not depend on the
11//! SDK, so the types exist twice by design and `perspt-agent::transport`
12//! performs the one translation (PSP-9 *Practical Decisions*).
13
14use std::collections::BTreeMap;
15
16use anyhow::{bail, Context, Result};
17
18use crate::config::{Config, ProviderEntry};
19use crate::llm_provider::GenAIProvider;
20
21/// Core-native mirror of the SDK capability record.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct ProviderCaps {
24    pub tool_calling: bool,
25    pub strict_schema: bool,
26    pub parallel_tool_calls: bool,
27    pub streaming_tool_calls: bool,
28    pub prompt_caching: bool,
29    pub structured_output: bool,
30    pub max_context_tokens: u32,
31    /// Whether the record was live-probed or is a declared adapter default.
32    /// Declared defaults are honest about being declarations (Gate U); the
33    /// transport probe (PSP-9 phase 1) upgrades them.
34    pub probed: bool,
35}
36
37/// One live provider route: the entry key, its adapter, a bound client, and
38/// its capability record.
39pub struct ProviderHandle {
40    /// Configuration key: `"anthropic"`, `"local"`, …
41    pub id: String,
42    /// genai adapter kind name this entry binds.
43    pub adapter: String,
44    /// Bound client wrapped in the existing provider type, so streaming,
45    /// retries, and token accounting are shared with single-provider mode.
46    pub provider: GenAIProvider,
47    /// Declared (or probed) capabilities.
48    pub caps: ProviderCaps,
49}
50
51impl std::fmt::Debug for ProviderHandle {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("ProviderHandle")
54            .field("id", &self.id)
55            .field("adapter", &self.adapter)
56            .field("caps", &self.caps)
57            .finish()
58    }
59}
60
61/// Several concurrently live provider handles, keyed by entry id.
62#[derive(Debug, Default)]
63pub struct ModelPortfolio {
64    handles: BTreeMap<String, ProviderHandle>,
65}
66
67impl ModelPortfolio {
68    /// Build a portfolio from the `[providers]` table.
69    ///
70    /// A configuration without a `[providers]` table yields a one-entry
71    /// portfolio from the flat provider fields, so single-provider setups
72    /// keep working unchanged. Credentials resolve per entry:
73    /// `api_key_env` > literal `api_key`; Vertex entries fall through to the
74    /// existing ADC token resolver rather than snapshotting a short-lived
75    /// token at startup.
76    pub fn from_config(config: &Config) -> Result<Self> {
77        let mut handles = BTreeMap::new();
78        if config.providers.is_empty() {
79            let (provider, resolved) = GenAIProvider::from_config(config, None)?;
80            let adapter = resolved.provider.clone();
81            handles.insert(
82                adapter.clone(),
83                ProviderHandle {
84                    id: adapter.clone(),
85                    caps: declared_caps(&adapter),
86                    adapter,
87                    provider,
88                },
89            );
90            return Ok(Self { handles });
91        }
92
93        for (id, entry) in &config.providers {
94            let handle = Self::build_handle(id, entry, config)
95                .with_context(|| format!("building provider handle {id:?}"))?;
96            handles.insert(id.clone(), handle);
97        }
98        Ok(Self { handles })
99    }
100
101    /// Build one handle from one `[providers.<id>]` entry.
102    fn build_handle(id: &str, entry: &ProviderEntry, config: &Config) -> Result<ProviderHandle> {
103        let adapter = entry.adapter_or(id).to_string();
104
105        // Resolve the credential without storing it: the env var the genai
106        // adapter reads is set process-wide, exactly as single-provider mode
107        // already does.
108        let api_key = match &entry.api_key_env {
109            Some(var) => std::env::var(var).ok(),
110            None => entry.api_key.clone(),
111        };
112
113        if let Some(base_url) = entry.base_url.as_deref() {
114            if let Some(env_var) = crate::llm_provider::provider_base_url_env_var(&adapter) {
115                if std::env::var(env_var).is_err() {
116                    std::env::set_var(env_var, base_url);
117                }
118            }
119        }
120        if adapter.eq_ignore_ascii_case("vertex") {
121            // Reuse the existing ADC resolver; entry fields override.
122            let vertex_config = Config {
123                vertex_project_id: entry
124                    .project_id
125                    .clone()
126                    .or(config.vertex_project_id.clone()),
127                vertex_location: entry.location.clone().or(config.vertex_location.clone()),
128                ..Config::default()
129            };
130            crate::llm_provider::configure_vertex_environment(&vertex_config);
131        }
132
133        let provider = GenAIProvider::new_with_config(Some(&adapter), api_key.as_deref())?;
134        Ok(ProviderHandle {
135            id: id.to_string(),
136            caps: declared_caps(&adapter),
137            adapter,
138            provider,
139        })
140    }
141
142    /// Resolve a provider handle by entry id.
143    pub fn resolve(&self, provider: &str) -> Result<&ProviderHandle> {
144        match self.handles.get(provider) {
145            Some(handle) => Ok(handle),
146            None => bail!(
147                "unknown provider {provider:?}; configured providers: [{}]",
148                self.provider_ids().join(", ")
149            ),
150        }
151    }
152
153    /// All configured provider ids, sorted.
154    pub fn provider_ids(&self) -> Vec<String> {
155        self.handles.keys().cloned().collect()
156    }
157
158    /// Iterate over the handles, sorted by id.
159    pub fn handles(&self) -> impl Iterator<Item = &ProviderHandle> {
160        self.handles.values()
161    }
162
163    /// Number of live handles.
164    pub fn len(&self) -> usize {
165        self.handles.len()
166    }
167
168    /// Whether the portfolio holds no handles.
169    pub fn is_empty(&self) -> bool {
170        self.handles.is_empty()
171    }
172}
173
174/// Declared capability defaults per adapter kind.
175///
176/// These are *declarations*, marked `probed: false`; the tool-loop transport
177/// verifies them per route before relying on them (Gate U). Context windows
178/// are conservative lower bounds, not marketing numbers.
179pub fn declared_caps(adapter: &str) -> ProviderCaps {
180    let full = ProviderCaps {
181        tool_calling: true,
182        strict_schema: false,
183        parallel_tool_calls: true,
184        streaming_tool_calls: true,
185        prompt_caching: false,
186        structured_output: true,
187        max_context_tokens: 128_000,
188        probed: false,
189    };
190    match adapter.to_ascii_lowercase().as_str() {
191        "openai" => ProviderCaps {
192            strict_schema: true,
193            prompt_caching: true,
194            ..full
195        },
196        "anthropic" => ProviderCaps {
197            prompt_caching: true,
198            max_context_tokens: 200_000,
199            ..full
200        },
201        "gemini" | "vertex" => ProviderCaps {
202            prompt_caching: true,
203            max_context_tokens: 1_000_000,
204            ..full
205        },
206        "groq" | "xai" | "deepseek" | "cohere" => full,
207        "ollama" => ProviderCaps {
208            // Local models vary; declare the floor and let probing raise it.
209            parallel_tool_calls: false,
210            streaming_tool_calls: false,
211            max_context_tokens: 32_000,
212            ..full
213        },
214        _ => ProviderCaps {
215            // Unknown adapters declare text-only; the tool loop then takes
216            // the recorded Bundle degradation instead of guessing (Gate U).
217            tool_calling: false,
218            parallel_tool_calls: false,
219            streaming_tool_calls: false,
220            structured_output: false,
221            max_context_tokens: 32_000,
222            ..full
223        },
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn table_config(toml: &str) -> Config {
232        Config::from_toml_str(toml).unwrap()
233    }
234
235    #[test]
236    fn builds_one_handle_per_provider_entry() {
237        let config = table_config(
238            r#"
239            [providers.local]
240            adapter  = "ollama"
241            base_url = "http://localhost:11434"
242
243            [providers.openai]
244            api_key = "sk-test"
245
246            [providers.groq]
247            api_key = "gsk-test"
248            "#,
249        );
250        let portfolio = ModelPortfolio::from_config(&config).unwrap();
251        assert_eq!(portfolio.len(), 3);
252        assert_eq!(portfolio.provider_ids(), ["groq", "local", "openai"]);
253        assert_eq!(portfolio.resolve("local").unwrap().adapter, "ollama");
254        assert!(portfolio.resolve("nope").is_err());
255    }
256
257    #[test]
258    fn one_entry_portfolio_from_flat_config_still_works() {
259        let config = table_config("provider = \"ollama\"\nmodel = \"qwen2.5-coder\"\n");
260        let portfolio = ModelPortfolio::from_config(&config).unwrap();
261        assert_eq!(portfolio.len(), 1);
262        assert_eq!(portfolio.provider_ids(), ["ollama"]);
263    }
264
265    #[test]
266    fn unknown_adapter_declares_text_only_not_a_guess() {
267        let caps = declared_caps("mystery-gateway");
268        assert!(!caps.tool_calling);
269        assert!(!caps.probed);
270    }
271
272    #[test]
273    fn declared_caps_are_marked_unprobed() {
274        for adapter in ["openai", "anthropic", "gemini", "ollama", "groq"] {
275            assert!(!declared_caps(adapter).probed, "{adapter}");
276            assert!(declared_caps(adapter).tool_calling, "{adapter}");
277        }
278    }
279}