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}