Skip to main content

oxicode_sdk/ports/
catalog.rs

1//! Port 12 — `ModelCatalog`: source of truth for provider/model metadata.
2//!
3//! Replaces the legacy `OnceLock`/`LazyLock` globals in `oxicode-ai/src/catalog/`
4//! with a proper port trait that SDK consumers can implement.
5//!
6//! See `docs/designs/2026-06-17-catalog-port-design.md` (v3) for rationale.
7//!
8//! # Layering
9//!
10//! ```text
11//! models.dev JSON → FileModelCatalog (oxicode-sdk) → ModelCatalog port
12//!                                                         ↑
13//!                                                         │ lookup
14//!                                                         ▼
15//!                                              Oxicode / SDK consumer
16//! ```
17//!
18//! # Threading
19//!
20//! Read methods are async, return owned values. Reference implementations
21//! typically hold the snapshot behind `Arc<RwLock<_>>` and replace it
22//! atomically on refresh.
23
24use std::future::Future;
25use std::path::PathBuf;
26use std::pin::Pin;
27
28use serde::{Deserialize, Serialize};
29use tokio::sync::broadcast;
30
31use crate::error::SdkResult;
32
33use super::AuthMethod;
34
35// ═══════════════════════════════════════════════════════════════════════════
36// Protocol enum — SDK's own type, source of truth for catalog protocol IDs
37// ═══════════════════════════════════════════════════════════════════════════
38
39/// Protocol used to talk to a provider/model.
40///
41/// SDK-owned enum (does not depend on `oxicode_ai::Api`). The bridge layer
42/// (PR 3, `oxicode-sdk/src/bridge.rs`) converts to `oxicode_ai::Api` via
43/// [`CatalogProtocol::as_oxicode_api`].
44///
45/// New protocol = add a variant + a `protocol_for` mapping line + a bridge
46/// dispatch arm. Unknown npm values fall back to [`CatalogProtocol::OpenAiCompatible`].
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub enum CatalogProtocol {
49    /// Anthropic Messages API (`@ai-sdk/anthropic`).
50    AnthropicMessages,
51    /// OpenAI Chat Completions API (`@ai-sdk/openai`,
52    /// `@ai-sdk/openai-compatible`, most aggregators/gateways).
53    OpenAiCompletions,
54    /// OpenAI Responses API.
55    OpenAiResponses,
56    /// Azure OpenAI Responses (`@ai-sdk/azure`).
57    AzureOpenAiResponses,
58    /// Google Generative AI (`@ai-sdk/google`).
59    GoogleGenerativeAi,
60    /// Google Vertex (`@ai-sdk/google-vertex`,
61    /// `@ai-sdk/google-vertex/anthropic`).
62    GoogleVertex,
63    /// AWS Bedrock Converse Stream (`@ai-sdk/amazon-bedrock`).
64    BedrockConverseStream,
65    /// OpenAI-compatible fallback for unknown npm values.
66    OpenAiCompatible,
67}
68
69impl CatalogProtocol {
70    /// Default authentication header scheme for this protocol.
71    ///
72    /// Per-model override (`model.npm`) is applied BEFORE the entry's
73    /// `protocol` field is set, so this method's result is the auth for the
74    /// actual model — no separate `auth_method` field needed on entries.
75    pub fn default_auth(&self) -> AuthMethod {
76        match self {
77            CatalogProtocol::AnthropicMessages => AuthMethod::XApiKey,
78            CatalogProtocol::AzureOpenAiResponses => AuthMethod::ApiKey,
79            CatalogProtocol::GoogleGenerativeAi
80            | CatalogProtocol::GoogleVertex
81            | CatalogProtocol::BedrockConverseStream => AuthMethod::None,
82            CatalogProtocol::OpenAiCompletions
83            | CatalogProtocol::OpenAiResponses
84            | CatalogProtocol::OpenAiCompatible => AuthMethod::Bearer,
85        }
86    }
87
88    /// Convert to oxicode-ai's internal `Api` enum at the impl boundary.
89    ///
90    /// Always available — oxicode-sdk already depends on oxicode-ai (re-exports
91    /// `Api`/`Model`/`Provider` since v0.x). No feature flag (v3 정정).
92    ///
93    /// Called only by the SDK bridge layer
94    /// (`oxicode_sdk::bridge::create_provider_from_entry`, PR 3). Port
95    /// implementations never call this method.
96    pub fn as_oxicode_api(&self) -> oxicode_ai::Api {
97        use CatalogProtocol::*;
98        match self {
99            AnthropicMessages => oxicode_ai::Api::AnthropicMessages,
100            OpenAiCompletions => oxicode_ai::Api::OpenAiCompletions,
101            OpenAiResponses => oxicode_ai::Api::OpenAiResponses,
102            AzureOpenAiResponses => oxicode_ai::Api::AzureOpenAiResponses,
103            GoogleGenerativeAi => oxicode_ai::Api::GoogleGenerativeAi,
104            GoogleVertex => oxicode_ai::Api::GoogleVertex,
105            BedrockConverseStream => oxicode_ai::Api::BedrockConverseStream,
106            // OpenAI 호환은 oxicode-ai 에서 OpenAiCompletions 로 처리
107            OpenAiCompatible => oxicode_ai::Api::OpenAiCompletions,
108        }
109    }
110
111    /// Stable string identifier (kebab-case, matches `Api::to_str`).
112    ///
113    /// Used for serialization, debug output, and as the bridge key when
114    /// the SDK consumer needs to look up a string-form protocol.
115    pub fn as_str(&self) -> &'static str {
116        use CatalogProtocol::*;
117        match self {
118            AnthropicMessages => "anthropic-messages",
119            OpenAiCompletions => "openai-completions",
120            OpenAiResponses => "openai-responses",
121            AzureOpenAiResponses => "azure-openai-responses",
122            GoogleGenerativeAi => "google-generative-ai",
123            GoogleVertex => "google-vertex",
124            BedrockConverseStream => "bedrock-converse-stream",
125            OpenAiCompatible => "openai-compatible",
126        }
127    }
128}
129
130impl std::fmt::Display for CatalogProtocol {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.write_str(self.as_str())
133    }
134}
135
136// ═══════════════════════════════════════════════════════════════════════════
137// Data types — what the port returns
138// ═══════════════════════════════════════════════════════════════════════════
139
140/// Snapshot of a single model entry.
141///
142/// `Clone` is cheap (mostly strings + small numerics). Owned values rather
143/// than `&'static` so consumers can hold snapshots past the catalog's
144/// internal lock.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct CatalogModelEntry {
147    /// Provider identifier this model belongs to (e.g. `"anthropic"`).
148    pub provider: String,
149    /// Model identifier (e.g. `"claude-3-opus-20240229"`).
150    pub model_id: String,
151    /// Human-readable display name.
152    pub name: String,
153
154    /// Protocol for this specific model. May differ from the parent
155    /// provider's protocol (per-model override; dynamic-catalog §2.2).
156    pub protocol: CatalogProtocol,
157
158    /// Where this entry came from.
159    pub source: CatalogSource,
160
161    /// Model-level base URL override. `None` = inherit from provider.
162    /// See dynamic-catalog §2.2 (v3 — 55 models).
163    pub base_url: Option<String>,
164
165    /// Whether the model supports reasoning/thinking output.
166    pub reasoning: bool,
167    /// Whether the model accepts image inputs.
168    pub supports_vision: bool,
169
170    /// USD per million tokens. `0.0` = free or undisclosed by upstream.
171    pub cost_input: f64,
172    /// USD per million output tokens.
173    pub cost_output: f64,
174    /// USD per million cache-read tokens.
175    pub cost_cache_read: f64,
176    /// USD per million cache-write tokens.
177    pub cost_cache_write: f64,
178
179    /// Maximum context length in tokens.
180    pub context_window: u32,
181    /// Maximum output tokens per response.
182    pub max_tokens: u32,
183
184    /// Input modalities (e.g. `["text", "image"]`). Empty = unknown.
185    pub input_modalities: Vec<String>,
186    /// Release date (free-form string from upstream, may be absent).
187    pub release_date: Option<String>,
188    /// Lifecycle status (e.g. `"ga"`, `"preview"`, `"deprecated"`).
189    pub status: Option<String>,
190}
191
192/// Provider-level metadata snapshot.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CatalogProviderEntry {
195    /// Provider identifier (e.g. `"anthropic"`).
196    pub id: String,
197    /// Human-readable provider name.
198    pub display_name: String,
199    /// Alternate identifiers accepted on the CLI/config.
200    pub aliases: Vec<String>,
201    /// Default wire protocol for this provider's models.
202    pub protocol: CatalogProtocol,
203    /// Primary environment variable holding the API key (e.g. `ANTHROPIC_API_KEY`).
204    pub env_key: Option<String>,
205    /// Additional environment variables the provider requires.
206    pub extra_env_keys: Vec<String>,
207    /// API base URL override (`None` = protocol default).
208    pub base_url: Option<String>,
209    /// Extra HTTP headers appended to every request.
210    pub extra_headers: Vec<(String, String)>,
211    /// `category` and `description` may be empty (models.dev does not
212    /// provide them). The UI uses alphabet sort as fallback.
213    pub category: String,
214    /// Free-form provider description (may be empty; models.dev omits it).
215    pub description: String,
216    /// Whether the provider is enabled by default in the UI.
217    pub default_enabled: bool,
218}
219
220/// Outcome of a single refresh attempt.
221///
222/// Why `Result` and not `Result<_, _>`: `RefreshOutcome::Failed` is returned
223/// as `Ok(Failed)` on purpose. Lazy on-call refresh should not break the
224/// caller's success path with an `Err` — the snapshot still serves stale
225/// data. Callers that want to react to failure use `match`.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub enum RefreshOutcome {
228    /// Snapshot unchanged (HTTP 304, mtime fresh, etc.).
229    Unchanged,
230    /// Snapshot replaced with newer data.
231    Updated {
232        /// Number of providers in the new snapshot.
233        provider_count: usize,
234        /// Number of models in the new snapshot.
235        model_count: usize,
236    },
237    /// No network attempted; served from stale cache or SNAP.
238    /// (e.g. `OXICODE_MODELS_DEV_DISABLE_FETCH=1`, mtime window fresh.)
239    Offline {
240        /// Why no network was attempted.
241        reason: &'static str,
242    },
243    /// Refresh attempted and failed. Previous snapshot still in effect.
244    /// Not an `Err` — see type-level doc above.
245    Failed {
246        /// Why the refresh failed.
247        reason: String,
248    },
249}
250
251/// Per-entry origin. UI uses this for "local" badges; debugging uses it
252/// to trace where an entry came from.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
254pub enum CatalogSource {
255    /// Compile-time embedded SNAP (models.dev gzip).
256    Embedded,
257    /// Runtime cache from a previous successful live fetch.
258    Cache,
259    /// Fresh fetch from upstream (e.g. models.dev).
260    Live,
261    /// Local `/v1/models` discovery (ollama, lmstudio, vllm, sglang).
262    Local,
263    /// User override file (highest precedence).
264    Override,
265}
266
267/// Lifecycle event delivered to all subscribers.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub enum CatalogEvent {
270    /// Snapshot changed. New state available via read methods.
271    Updated {
272        /// Number of providers in the new snapshot.
273        provider_count: usize,
274        /// Number of models in the new snapshot.
275        model_count: usize,
276    },
277    /// Refresh failed; previous snapshot still in effect.
278    RefreshFailed {
279        /// Why the refresh failed.
280        reason: String,
281        /// Providers still in effect from the previous snapshot.
282        provider_count: usize,
283        /// Models still in effect from the previous snapshot.
284        model_count: usize,
285    },
286    /// User override file applied or modified.
287    OverrideApplied {
288        /// Path to the override file.
289        path: PathBuf,
290        /// Number of provider entries overridden.
291        provider_overrides: usize,
292        /// Number of model entries overridden.
293        model_overrides: usize,
294    },
295    /// Local discovery added new models.
296    LocalDiscovered {
297        /// Base URL of the local `/v1/models` endpoint.
298        base_url: String,
299        /// Number of models discovered.
300        model_count: usize,
301    },
302}
303
304// ═══════════════════════════════════════════════════════════════════════════
305// Port trait
306// ═══════════════════════════════════════════════════════════════════════════
307
308/// Source of truth for provider/model metadata.
309///
310/// # Threading & lifecycle
311///
312/// All read methods are async and return owned values. Implementations
313/// typically hold a snapshot behind an `Arc<RwLock<_>>`; the trait does
314/// not require any particular storage. The snapshot is replaced atomically
315/// on refresh.
316///
317/// # Subscription
318///
319/// [`subscribe`](Self::subscribe) returns a `broadcast::Receiver` (capacity
320/// 16). Slow consumers may miss intermediate updates — the latest state is
321/// always available via the read methods, so this is not a correctness
322/// issue.
323pub trait ModelCatalog: Send + Sync + 'static {
324    /// List all known provider IDs (sorted, no duplicates).
325    fn list_providers(&self) -> Pin<Box<dyn Future<Output = SdkResult<Vec<String>>> + Send + '_>>;
326
327    /// Look up a single provider by ID.
328    fn get_provider(
329        &self,
330        provider_id: &str,
331    ) -> Pin<Box<dyn Future<Output = SdkResult<Option<CatalogProviderEntry>>> + Send + '_>>;
332
333    /// List all models for a provider (order unspecified).
334    fn list_models(
335        &self,
336        provider_id: &str,
337    ) -> Pin<Box<dyn Future<Output = SdkResult<Vec<CatalogModelEntry>>> + Send + '_>>;
338
339    /// Look up a single model by `(provider, model_id)`.
340    fn get_model(
341        &self,
342        provider_id: &str,
343        model_id: &str,
344    ) -> Pin<Box<dyn Future<Output = SdkResult<Option<CatalogModelEntry>>> + Send + '_>>;
345
346    /// Search models by case-insensitive substring of `provider`, `model_id`,
347    /// or `name`. Empty pattern returns all.
348    fn search(
349        &self,
350        pattern: &str,
351    ) -> Pin<Box<dyn Future<Output = SdkResult<Vec<CatalogModelEntry>>> + Send + '_>>;
352
353    /// Total number of models across all providers.
354    fn model_count(&self) -> Pin<Box<dyn Future<Output = SdkResult<usize>> + Send + '_>>;
355
356    /// Force a refresh. The implementation decides what "refresh" means
357    /// (HTTP fetch, file re-read, etc.).
358    fn refresh(&self) -> Pin<Box<dyn Future<Output = SdkResult<RefreshOutcome>> + Send + '_>>;
359
360    /// Subscribe to catalog lifecycle events. Multiple consumers supported.
361    fn subscribe(&self) -> broadcast::Receiver<CatalogEvent>;
362
363    // ── Synchronous read-only API ───────────────────────────────────────
364    //
365    // The async methods above are the canonical contract, but some callers
366    // (TUI event handlers, model pickers) run in a synchronous context and
367    // cannot `.await`. The catalog's data already lives in memory behind a
368    // `RwLock`, so synchronous reads are cheap and safe — they acquire the
369    // read lock, clone the needed entries, and return immediately (no I/O,
370    // no allocation of a `Future`).
371    //
372    // Implementations MUST NOT perform network or file I/O in these
373    // methods. They reflect the *currently loaded* snapshot only.
374
375    /// Sync equivalent of [`list_providers`](Self::list_providers).
376    fn list_providers_sync(&self) -> Vec<String> {
377        Vec::new()
378    }
379
380    /// Sync equivalent of [`get_provider`](Self::get_provider).
381    fn get_provider_sync(&self, _provider_id: &str) -> Option<CatalogProviderEntry> {
382        None
383    }
384
385    /// Sync equivalent of [`list_models`](Self::list_models).
386    fn list_models_sync(&self, _provider_id: &str) -> Vec<CatalogModelEntry> {
387        Vec::new()
388    }
389
390    /// Sync equivalent of [`get_model`](Self::get_model).
391    fn get_model_sync(&self, _provider_id: &str, _model_id: &str) -> Option<CatalogModelEntry> {
392        None
393    }
394
395    /// Sync equivalent of [`search`](Self::search).
396    fn search_sync(&self, _pattern: &str) -> Vec<CatalogModelEntry> {
397        Vec::new()
398    }
399
400    /// Sync equivalent of [`model_count`](Self::model_count).
401    fn model_count_sync(&self) -> usize {
402        0
403    }
404}
405
406// ═══════════════════════════════════════════════════════════════════════════
407// Noop default
408// ═══════════════════════════════════════════════════════════════════════════
409
410/// Empty catalog — for products that don't need any model metadata
411/// (e.g. a single-provider app with hardcoded IDs).
412///
413/// No `Default` impl — `broadcast::Sender` has no `Default`. Use
414/// [`NoopModelCatalog::new`] instead. `Debug` is a manual impl to keep
415/// the output clean (the sender is opaque).
416pub struct NoopModelCatalog {
417    tx: broadcast::Sender<CatalogEvent>,
418}
419
420impl NoopModelCatalog {
421    /// Create a new empty noop catalog wrapped in an `Arc`.
422    pub fn new() -> std::sync::Arc<Self> {
423        let (tx, _) = broadcast::channel(16);
424        std::sync::Arc::new(Self { tx })
425    }
426}
427
428impl std::fmt::Debug for NoopModelCatalog {
429    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430        f.debug_struct("NoopModelCatalog").finish_non_exhaustive()
431    }
432}
433
434impl ModelCatalog for NoopModelCatalog {
435    fn list_providers(&self) -> Pin<Box<dyn Future<Output = SdkResult<Vec<String>>> + Send + '_>> {
436        Box::pin(async { Ok(vec![]) })
437    }
438
439    fn get_provider(
440        &self,
441        _: &str,
442    ) -> Pin<Box<dyn Future<Output = SdkResult<Option<CatalogProviderEntry>>> + Send + '_>> {
443        Box::pin(async { Ok(None) })
444    }
445
446    fn list_models(
447        &self,
448        _: &str,
449    ) -> Pin<Box<dyn Future<Output = SdkResult<Vec<CatalogModelEntry>>> + Send + '_>> {
450        Box::pin(async { Ok(vec![]) })
451    }
452
453    fn get_model(
454        &self,
455        _: &str,
456        _: &str,
457    ) -> Pin<Box<dyn Future<Output = SdkResult<Option<CatalogModelEntry>>> + Send + '_>> {
458        Box::pin(async { Ok(None) })
459    }
460
461    fn search(
462        &self,
463        _: &str,
464    ) -> Pin<Box<dyn Future<Output = SdkResult<Vec<CatalogModelEntry>>> + Send + '_>> {
465        Box::pin(async { Ok(vec![]) })
466    }
467
468    fn model_count(&self) -> Pin<Box<dyn Future<Output = SdkResult<usize>> + Send + '_>> {
469        Box::pin(async { Ok(0) })
470    }
471
472    fn refresh(&self) -> Pin<Box<dyn Future<Output = SdkResult<RefreshOutcome>> + Send + '_>> {
473        Box::pin(async { Ok(RefreshOutcome::Unchanged) })
474    }
475
476    fn subscribe(&self) -> broadcast::Receiver<CatalogEvent> {
477        self.tx.subscribe()
478    }
479}