Skip to main content

opencode_sdk_rs/resources/
app.rs

1//! App resource types and methods mirroring the JS SDK's `resources/app.ts`.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    client::{Opencode, RequestOptions},
9    error::OpencodeError,
10};
11
12// ---------------------------------------------------------------------------
13// App
14// ---------------------------------------------------------------------------
15
16/// Top-level application information.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct App {
19    /// Whether the project is a git repository.
20    pub git: bool,
21    /// The hostname of the machine.
22    pub hostname: String,
23    /// Relevant filesystem paths.
24    pub path: AppPath,
25    /// Timing information.
26    pub time: AppTime,
27}
28
29/// Filesystem paths used by the application.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct AppPath {
32    /// Path to the configuration directory.
33    pub config: String,
34    /// Current working directory.
35    pub cwd: String,
36    /// Path to the data directory.
37    pub data: String,
38    /// Project root directory.
39    pub root: String,
40    /// Path to the state directory.
41    pub state: String,
42}
43
44/// Timing metadata.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct AppTime {
47    /// Timestamp (epoch seconds) when the app was initialised.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub initialized: Option<f64>,
50}
51
52// ---------------------------------------------------------------------------
53// Mode
54// ---------------------------------------------------------------------------
55
56/// An operational mode with associated tools and optional model override.
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
58pub struct Mode {
59    /// Human-readable mode name.
60    pub name: String,
61    /// Map of tool names to their enabled state.
62    pub tools: HashMap<String, bool>,
63    /// Optional model override for this mode.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub model: Option<ModeModel>,
66    /// Optional system prompt override.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub prompt: Option<String>,
69    /// Optional temperature override.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub temperature: Option<f64>,
72}
73
74/// Model reference used inside a [`Mode`].
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct ModeModel {
77    /// The model identifier.
78    #[serde(rename = "modelID")]
79    pub model_id: String,
80    /// The provider identifier.
81    #[serde(rename = "providerID")]
82    pub provider_id: String,
83}
84
85// ---------------------------------------------------------------------------
86// Model
87// ---------------------------------------------------------------------------
88
89/// Media capabilities (input or output).
90#[allow(clippy::struct_excessive_bools)]
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
92pub struct ModelMediaCapabilities {
93    /// Whether text is supported.
94    pub text: bool,
95    /// Whether audio is supported.
96    pub audio: bool,
97    /// Whether images are supported.
98    pub image: bool,
99    /// Whether video is supported.
100    pub video: bool,
101    /// Whether PDF is supported.
102    pub pdf: bool,
103}
104
105/// Model capabilities.
106#[allow(clippy::struct_excessive_bools)]
107#[allow(clippy::derive_partial_eq_without_eq)]
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
109pub struct ModelCapabilities {
110    /// Whether the model supports temperature tuning.
111    pub temperature: bool,
112    /// Whether the model supports chain-of-thought reasoning.
113    pub reasoning: bool,
114    /// Whether the model supports file attachments.
115    pub attachment: bool,
116    /// Whether the model supports tool calling.
117    pub toolcall: bool,
118    /// Supported input media types.
119    #[serde(default)]
120    pub input: ModelMediaCapabilities,
121    /// Supported output media types.
122    #[serde(default)]
123    pub output: ModelMediaCapabilities,
124    /// Can be a bool or an object with a `field` key.
125    #[serde(default)]
126    pub interleaved: serde_json::Value,
127}
128
129/// API endpoint information for a model.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
131pub struct ModelApi {
132    /// API identifier.
133    pub id: String,
134    /// API endpoint URL.
135    pub url: String,
136    /// npm package name.
137    pub npm: String,
138}
139
140/// Model lifecycle status.
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142#[serde(rename_all = "lowercase")]
143pub enum ModelStatus {
144    /// Alpha stage model.
145    Alpha,
146    /// Beta stage model.
147    Beta,
148    /// Deprecated model.
149    Deprecated,
150    /// Active / generally-available model.
151    Active,
152}
153
154/// Cache cost information.
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
156pub struct CostCache {
157    /// Cost per cache-read token.
158    pub read: f64,
159    /// Cost per cache-write token.
160    pub write: f64,
161}
162
163/// Experimental cost tier for over 200K context.
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
165#[serde(rename_all = "camelCase")]
166pub struct CostExperimentalOver200K {
167    /// Cost per input token.
168    pub input: f64,
169    /// Cost per output token.
170    pub output: f64,
171    /// Cache cost information.
172    pub cache: CostCache,
173}
174
175/// A language-model definition exposed by a provider.
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177pub struct Model {
178    /// Unique model identifier.
179    pub id: String,
180    /// Provider identifier.
181    #[serde(rename = "providerID", default)]
182    pub provider_id: String,
183    /// API endpoint information.
184    #[serde(default)]
185    pub api: ModelApi,
186    /// Human-readable model name.
187    pub name: String,
188    /// Model family.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub family: Option<String>,
191    /// Model capabilities.
192    #[serde(default)]
193    pub capabilities: ModelCapabilities,
194    /// Cost information per token.
195    pub cost: ModelCost,
196    /// Context and output token limits.
197    pub limit: ModelLimit,
198    /// Model lifecycle status.
199    #[serde(default = "default_model_status")]
200    pub status: ModelStatus,
201    /// Arbitrary provider-specific options.
202    pub options: HashMap<String, serde_json::Value>,
203    /// Custom headers for API requests.
204    #[serde(default)]
205    pub headers: HashMap<String, String>,
206    /// ISO-8601 release date.
207    pub release_date: String,
208    /// Model variants.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub variants: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
211}
212
213const fn default_model_status() -> ModelStatus {
214    ModelStatus::Active
215}
216
217/// Per-token cost information for a [`Model`].
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
219pub struct ModelCost {
220    /// Cost per input token.
221    pub input: f64,
222    /// Cost per output token.
223    pub output: f64,
224    /// Cache cost information.
225    #[serde(default)]
226    pub cache: CostCache,
227    /// Experimental pricing for over 200K context.
228    #[serde(rename = "experimentalOver200K", skip_serializing_if = "Option::is_none")]
229    pub experimental_over_200k: Option<CostExperimentalOver200K>,
230}
231
232/// Token limits for a [`Model`].
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
234pub struct ModelLimit {
235    /// Maximum context window size in tokens.
236    pub context: f64,
237    /// Maximum input size in tokens.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub input: Option<f64>,
240    /// Maximum output size in tokens.
241    pub output: f64,
242}
243
244// ---------------------------------------------------------------------------
245// Provider
246// ---------------------------------------------------------------------------
247
248/// Provider source type.
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
250#[serde(rename_all = "lowercase")]
251pub enum ProviderSource {
252    /// Sourced from environment variables.
253    Env,
254    /// Sourced from configuration file.
255    Config,
256    /// Custom provider.
257    Custom,
258    /// Sourced from an API.
259    Api,
260}
261
262/// An LLM provider definition.
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
264pub struct Provider {
265    /// Unique provider identifier.
266    pub id: String,
267    /// Human-readable provider name.
268    pub name: String,
269    /// Source of the provider configuration.
270    #[serde(default = "default_provider_source")]
271    pub source: ProviderSource,
272    /// Environment variable names required for authentication.
273    pub env: Vec<String>,
274    /// Optional API key.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub key: Option<String>,
277    /// Arbitrary provider-specific options.
278    #[serde(default)]
279    pub options: HashMap<String, serde_json::Value>,
280    /// Map of model identifiers to their definitions.
281    pub models: HashMap<String, Model>,
282}
283
284const fn default_provider_source() -> ProviderSource {
285    ProviderSource::Env
286}
287
288// ---------------------------------------------------------------------------
289// Request / Response types
290// ---------------------------------------------------------------------------
291
292/// Type alias for `POST /app/init` response.
293pub type AppInitResponse = bool;
294
295/// Type alias for `POST /log` response.
296pub type AppLogResponse = bool;
297
298/// Type alias for `GET /mode` response.
299pub type AppModesResponse = Vec<Mode>;
300
301/// Response from `GET /config/providers`.
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
303pub struct AppProvidersResponse {
304    /// Map of provider ID to its default model ID.
305    pub default: HashMap<String, String>,
306    /// List of available providers.
307    pub providers: Vec<Provider>,
308}
309
310/// Log level for [`AppLogParams`].
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
312#[serde(rename_all = "lowercase")]
313pub enum LogLevel {
314    /// Debug-level log message.
315    Debug,
316    /// Informational log message.
317    Info,
318    /// Error-level log message.
319    Error,
320    /// Warning-level log message.
321    Warn,
322}
323
324/// Parameters for `POST /log`.
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
326pub struct AppLogParams {
327    /// Severity level.
328    pub level: LogLevel,
329    /// The log message body.
330    pub message: String,
331    /// Name of the originating service / component.
332    pub service: String,
333    /// Optional extra structured data.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub extra: Option<HashMap<String, serde_json::Value>>,
336}
337
338// ---------------------------------------------------------------------------
339// AppResource
340// ---------------------------------------------------------------------------
341
342/// Provides access to the App-related API endpoints.
343pub struct AppResource<'a> {
344    client: &'a Opencode,
345}
346
347impl<'a> AppResource<'a> {
348    /// Create a new `AppResource` bound to the given client.
349    pub(crate) const fn new(client: &'a Opencode) -> Self {
350        Self { client }
351    }
352
353    /// Retrieve application information (`GET /app`).
354    pub async fn get(&self, options: Option<&RequestOptions>) -> Result<App, OpencodeError> {
355        self.client.get("/app", options).await
356    }
357
358    /// Initialise the application (`POST /app/init`).
359    pub async fn init(
360        &self,
361        options: Option<&RequestOptions>,
362    ) -> Result<AppInitResponse, OpencodeError> {
363        self.client.post::<bool, ()>("/app/init", None, options).await
364    }
365
366    /// Send a log entry (`POST /log`).
367    pub async fn log(
368        &self,
369        params: &AppLogParams,
370        options: Option<&RequestOptions>,
371    ) -> Result<AppLogResponse, OpencodeError> {
372        self.client.post("/log", Some(params), options).await
373    }
374
375    /// List available modes (`GET /mode`).
376    pub async fn modes(
377        &self,
378        options: Option<&RequestOptions>,
379    ) -> Result<AppModesResponse, OpencodeError> {
380        self.client.get("/mode", options).await
381    }
382
383    /// List providers and their default models (`GET /config/providers`).
384    pub async fn providers(
385        &self,
386        options: Option<&RequestOptions>,
387    ) -> Result<AppProvidersResponse, OpencodeError> {
388        self.client.get("/config/providers", options).await
389    }
390}
391
392// ---------------------------------------------------------------------------
393// Tests
394// ---------------------------------------------------------------------------
395
396#[cfg(test)]
397mod tests {
398    use serde_json::json;
399
400    use super::*;
401
402    #[test]
403    fn app_round_trip() {
404        let app = App {
405            git: true,
406            hostname: "dev-machine".into(),
407            path: AppPath {
408                config: "/home/user/.config/opencode".into(),
409                cwd: "/home/user/project".into(),
410                data: "/home/user/.local/share/opencode".into(),
411                root: "/home/user/project".into(),
412                state: "/home/user/.local/state/opencode".into(),
413            },
414            time: AppTime { initialized: Some(1_700_000_000.0) },
415        };
416        let json_str = serde_json::to_string(&app).unwrap();
417        let back: App = serde_json::from_str(&json_str).unwrap();
418        assert_eq!(app, back);
419    }
420
421    #[test]
422    fn app_time_optional_initialized() {
423        let app = App {
424            git: false,
425            hostname: "ci".into(),
426            path: AppPath {
427                config: "/tmp/cfg".into(),
428                cwd: "/tmp".into(),
429                data: "/tmp/data".into(),
430                root: "/tmp".into(),
431                state: "/tmp/state".into(),
432            },
433            time: AppTime { initialized: None },
434        };
435        let json_str = serde_json::to_string(&app).unwrap();
436        // `initialized` should be absent from serialised output.
437        assert!(!json_str.contains("initialized"));
438        let back: App = serde_json::from_str(&json_str).unwrap();
439        assert_eq!(app, back);
440    }
441
442    #[test]
443    fn mode_full_round_trip() {
444        let mode = Mode {
445            name: "code".into(),
446            tools: HashMap::from([("bash".into(), true), ("edit".into(), false)]),
447            model: Some(ModeModel { model_id: "gpt-4o".into(), provider_id: "openai".into() }),
448            prompt: Some("You are a coding assistant.".into()),
449            temperature: Some(0.7),
450        };
451        let json_str = serde_json::to_string(&mode).unwrap();
452        // Verify camelCase renames.
453        assert!(json_str.contains("modelID"));
454        assert!(json_str.contains("providerID"));
455        let back: Mode = serde_json::from_str(&json_str).unwrap();
456        assert_eq!(mode, back);
457    }
458
459    #[test]
460    fn mode_minimal() {
461        let mode = Mode {
462            name: "default".into(),
463            tools: HashMap::new(),
464            model: None,
465            prompt: None,
466            temperature: None,
467        };
468        let json_str = serde_json::to_string(&mode).unwrap();
469        assert!(!json_str.contains("model"));
470        assert!(!json_str.contains("prompt"));
471        assert!(!json_str.contains("temperature"));
472        let back: Mode = serde_json::from_str(&json_str).unwrap();
473        assert_eq!(mode, back);
474    }
475
476    /// Helper to build a minimal spec-compliant [`Model`] for tests.
477    fn test_model() -> Model {
478        Model {
479            id: "gpt-4o".into(),
480            provider_id: "openai".into(),
481            api: ModelApi {
482                id: "openai".into(),
483                url: "https://api.openai.com/v1".into(),
484                npm: "openai".into(),
485            },
486            name: "GPT-4o".into(),
487            family: None,
488            capabilities: ModelCapabilities {
489                temperature: true,
490                reasoning: false,
491                attachment: true,
492                toolcall: true,
493                input: ModelMediaCapabilities {
494                    text: true,
495                    audio: false,
496                    image: true,
497                    video: false,
498                    pdf: false,
499                },
500                output: ModelMediaCapabilities { text: true, ..Default::default() },
501                interleaved: json!(false),
502            },
503            cost: ModelCost {
504                input: 5.0,
505                output: 15.0,
506                cache: CostCache { read: 2.5, write: 0.0 },
507                experimental_over_200k: None,
508            },
509            limit: ModelLimit { context: 128_000.0, input: None, output: 4_096.0 },
510            status: ModelStatus::Active,
511            options: HashMap::from([("streaming".into(), json!(true))]),
512            headers: HashMap::new(),
513            release_date: "2024-05-13".into(),
514            variants: None,
515        }
516    }
517
518    #[test]
519    fn model_round_trip() {
520        let model = test_model();
521        let json_str = serde_json::to_string(&model).unwrap();
522        assert!(json_str.contains("providerID"));
523        assert!(json_str.contains("capabilities"));
524        let back: Model = serde_json::from_str(&json_str).unwrap();
525        assert_eq!(model, back);
526    }
527
528    #[test]
529    fn model_cost_default_cache() {
530        let cost = ModelCost {
531            input: 1.0,
532            output: 2.0,
533            cache: CostCache::default(),
534            experimental_over_200k: None,
535        };
536        let json_str = serde_json::to_string(&cost).unwrap();
537        assert!(!json_str.contains("experimentalOver200K"));
538        let back: ModelCost = serde_json::from_str(&json_str).unwrap();
539        assert_eq!(cost, back);
540    }
541
542    #[test]
543    fn provider_round_trip() {
544        let provider = Provider {
545            id: "openai".into(),
546            name: "OpenAI".into(),
547            source: ProviderSource::Env,
548            env: vec!["OPENAI_API_KEY".into()],
549            key: None,
550            options: HashMap::new(),
551            models: HashMap::from([("gpt-4o".into(), test_model())]),
552        };
553        let json_str = serde_json::to_string(&provider).unwrap();
554        assert!(json_str.contains("\"source\":\"env\""));
555        let back: Provider = serde_json::from_str(&json_str).unwrap();
556        assert_eq!(provider, back);
557    }
558
559    #[test]
560    fn app_log_params_with_extra() {
561        let params = AppLogParams {
562            level: LogLevel::Info,
563            message: "server started".into(),
564            service: "api-gateway".into(),
565            extra: Some(HashMap::from([
566                ("port".into(), json!(8080)),
567                ("env".into(), json!("production")),
568            ])),
569        };
570        let json_str = serde_json::to_string(&params).unwrap();
571        assert!(json_str.contains(r#""level":"info"#));
572        let back: AppLogParams = serde_json::from_str(&json_str).unwrap();
573        assert_eq!(params, back);
574    }
575
576    #[test]
577    fn app_log_params_without_extra() {
578        let params = AppLogParams {
579            level: LogLevel::Error,
580            message: "something broke".into(),
581            service: "worker".into(),
582            extra: None,
583        };
584        let json_str = serde_json::to_string(&params).unwrap();
585        assert!(!json_str.contains("extra"));
586        assert!(json_str.contains(r#""level":"error"#));
587        let back: AppLogParams = serde_json::from_str(&json_str).unwrap();
588        assert_eq!(params, back);
589    }
590
591    #[test]
592    fn log_level_variants() {
593        for (variant, expected) in [
594            (LogLevel::Debug, "debug"),
595            (LogLevel::Info, "info"),
596            (LogLevel::Error, "error"),
597            (LogLevel::Warn, "warn"),
598        ] {
599            let json_str = serde_json::to_string(&variant).unwrap();
600            assert_eq!(json_str, format!("\"{expected}\""));
601            let back: LogLevel = serde_json::from_str(&json_str).unwrap();
602            assert_eq!(variant, back);
603        }
604    }
605
606    #[test]
607    fn app_providers_response_round_trip() {
608        let resp = AppProvidersResponse {
609            default: HashMap::from([
610                ("openai".into(), "gpt-4o".into()),
611                ("anthropic".into(), "claude-3-opus".into()),
612            ]),
613            providers: vec![Provider {
614                id: "openai".into(),
615                name: "OpenAI".into(),
616                source: ProviderSource::Env,
617                env: vec!["OPENAI_API_KEY".into()],
618                key: None,
619                options: HashMap::new(),
620                models: HashMap::new(),
621            }],
622        };
623        let json_str = serde_json::to_string(&resp).unwrap();
624        let back: AppProvidersResponse = serde_json::from_str(&json_str).unwrap();
625        assert_eq!(resp, back);
626    }
627
628    #[test]
629    fn mode_model_serde_rename() {
630        let m = ModeModel { model_id: "claude-3-opus".into(), provider_id: "anthropic".into() };
631        let v: serde_json::Value = serde_json::to_value(&m).unwrap();
632        assert_eq!(v["modelID"], "claude-3-opus");
633        assert_eq!(v["providerID"], "anthropic");
634        let back: ModeModel = serde_json::from_value(v).unwrap();
635        assert_eq!(m, back);
636    }
637
638    // -- Edge cases --
639
640    #[test]
641    fn provider_no_key() {
642        let provider = Provider {
643            id: "custom".into(),
644            name: "Custom".into(),
645            source: ProviderSource::Custom,
646            env: vec![],
647            key: None,
648            options: HashMap::new(),
649            models: HashMap::new(),
650        };
651        let json_str = serde_json::to_string(&provider).unwrap();
652        assert!(!json_str.contains("key"));
653        assert!(json_str.contains("\"source\":\"custom\""));
654        let back: Provider = serde_json::from_str(&json_str).unwrap();
655        assert_eq!(provider, back);
656    }
657
658    #[test]
659    fn cost_cache_round_trip() {
660        let cache = CostCache { read: 1.5, write: 3.0 };
661        let json_str = serde_json::to_string(&cache).unwrap();
662        let back: CostCache = serde_json::from_str(&json_str).unwrap();
663        assert_eq!(cache, back);
664    }
665
666    #[test]
667    fn model_cost_with_experimental() {
668        let cost = ModelCost {
669            input: 3.0,
670            output: 6.0,
671            cache: CostCache { read: 1.5, write: 0.0 },
672            experimental_over_200k: Some(CostExperimentalOver200K {
673                input: 6.0,
674                output: 12.0,
675                cache: CostCache { read: 3.0, write: 0.0 },
676            }),
677        };
678        let json_str = serde_json::to_string(&cost).unwrap();
679        assert!(json_str.contains("experimentalOver200K"));
680        let back: ModelCost = serde_json::from_str(&json_str).unwrap();
681        assert_eq!(cost, back);
682    }
683
684    #[test]
685    fn app_time_initialized_absent_from_json() {
686        // Verify deserialization when the key is completely absent
687        let raw = r#"{"git":true,"hostname":"h","path":{"config":"c","cwd":"w","data":"d","root":"r","state":"s"},"time":{}}"#;
688        let app: App = serde_json::from_str(raw).unwrap();
689        assert_eq!(app.time.initialized, None);
690    }
691
692    #[test]
693    fn app_log_params_extra_empty_map() {
694        let params = AppLogParams {
695            level: LogLevel::Debug,
696            message: "trace".into(),
697            service: "svc".into(),
698            extra: Some(HashMap::new()),
699        };
700        let json_str = serde_json::to_string(&params).unwrap();
701        assert!(json_str.contains("extra"));
702        let back: AppLogParams = serde_json::from_str(&json_str).unwrap();
703        assert_eq!(params, back);
704    }
705
706    #[test]
707    fn mode_with_empty_tools_and_some_model() {
708        let mode = Mode {
709            name: "review".into(),
710            tools: HashMap::new(),
711            model: Some(ModeModel { model_id: "o1".into(), provider_id: "openai".into() }),
712            prompt: None,
713            temperature: None,
714        };
715        let json_str = serde_json::to_string(&mode).unwrap();
716        assert!(!json_str.contains("prompt"));
717        assert!(!json_str.contains("temperature"));
718        assert!(json_str.contains("modelID"));
719        let back: Mode = serde_json::from_str(&json_str).unwrap();
720        assert_eq!(mode, back);
721    }
722
723    #[test]
724    fn model_with_empty_options() {
725        let model = Model {
726            id: "small".into(),
727            provider_id: "local".into(),
728            api: ModelApi::default(),
729            name: "Small Model".into(),
730            family: None,
731            capabilities: ModelCapabilities::default(),
732            cost: ModelCost::default(),
733            limit: ModelLimit { context: 4096.0, input: None, output: 512.0 },
734            status: ModelStatus::Active,
735            options: HashMap::new(),
736            headers: HashMap::new(),
737            release_date: "2025-01-01".into(),
738            variants: None,
739        };
740        let json_str = serde_json::to_string(&model).unwrap();
741        let back: Model = serde_json::from_str(&json_str).unwrap();
742        assert_eq!(model, back);
743    }
744
745    #[test]
746    fn model_from_spec_json() {
747        let raw = json!({
748            "id": "claude-sonnet-4-20250514",
749            "providerID": "anthropic",
750            "api": { "id": "anthropic", "url": "https://api.anthropic.com", "npm": "@anthropic-ai/sdk" },
751            "name": "Claude Sonnet 4",
752            "family": "claude",
753            "capabilities": {
754                "temperature": true,
755                "reasoning": true,
756                "attachment": true,
757                "toolcall": true,
758                "input": { "text": true, "audio": false, "image": true, "video": false, "pdf": true },
759                "output": { "text": true, "audio": false, "image": false, "video": false, "pdf": false },
760                "interleaved": { "field": "reasoning_content" }
761            },
762            "cost": {
763                "input": 3.0,
764                "output": 15.0,
765                "cache": { "read": 0.3, "write": 3.75 }
766            },
767            "limit": { "context": 200000, "input": 190000, "output": 16384 },
768            "status": "active",
769            "options": {},
770            "headers": { "anthropic-beta": "interleaved-thinking-2025-05-14" },
771            "release_date": "2025-05-14"
772        });
773        let model: Model = serde_json::from_value(raw).unwrap();
774        assert_eq!(model.id, "claude-sonnet-4-20250514");
775        assert_eq!(model.provider_id, "anthropic");
776        assert_eq!(model.family.as_deref(), Some("claude"));
777        assert!(model.capabilities.reasoning);
778        assert!(model.capabilities.input.pdf);
779        assert_eq!(model.cost.cache.read, 0.3);
780        assert_eq!(model.limit.input, Some(190_000.0));
781        assert_eq!(model.status, ModelStatus::Active);
782        assert_eq!(
783            model.headers.get("anthropic-beta").map(String::as_str),
784            Some("interleaved-thinking-2025-05-14")
785        );
786    }
787
788    #[test]
789    fn model_status_round_trip() {
790        for (variant, expected) in [
791            (ModelStatus::Alpha, "alpha"),
792            (ModelStatus::Beta, "beta"),
793            (ModelStatus::Deprecated, "deprecated"),
794            (ModelStatus::Active, "active"),
795        ] {
796            let json_str = serde_json::to_string(&variant).unwrap();
797            assert_eq!(json_str, format!("\"{}\"", expected));
798            let back: ModelStatus = serde_json::from_str(&json_str).unwrap();
799            assert_eq!(variant, back);
800        }
801    }
802
803    #[test]
804    fn model_capabilities_round_trip() {
805        let caps = ModelCapabilities {
806            temperature: true,
807            reasoning: true,
808            attachment: false,
809            toolcall: true,
810            input: ModelMediaCapabilities {
811                text: true,
812                audio: false,
813                image: true,
814                video: false,
815                pdf: true,
816            },
817            output: ModelMediaCapabilities { text: true, ..Default::default() },
818            interleaved: json!(true),
819        };
820        let json_str = serde_json::to_string(&caps).unwrap();
821        let back: ModelCapabilities = serde_json::from_str(&json_str).unwrap();
822        assert_eq!(caps, back);
823    }
824
825    #[test]
826    fn model_api_round_trip() {
827        let api = ModelApi {
828            id: "openai".into(),
829            url: "https://api.openai.com/v1".into(),
830            npm: "openai".into(),
831        };
832        let json_str = serde_json::to_string(&api).unwrap();
833        let back: ModelApi = serde_json::from_str(&json_str).unwrap();
834        assert_eq!(api, back);
835    }
836
837    #[test]
838    fn provider_from_spec_json() {
839        let raw = json!({
840            "id": "anthropic",
841            "name": "Anthropic",
842            "source": "env",
843            "env": ["ANTHROPIC_API_KEY"],
844            "key": "sk-ant-xxx",
845            "options": {},
846            "models": {}
847        });
848        let provider: Provider = serde_json::from_value(raw).unwrap();
849        assert_eq!(provider.id, "anthropic");
850        assert_eq!(provider.source, ProviderSource::Env);
851        assert_eq!(provider.key.as_deref(), Some("sk-ant-xxx"));
852    }
853
854    #[test]
855    fn provider_source_variants() {
856        for (variant, expected) in [
857            (ProviderSource::Env, "env"),
858            (ProviderSource::Config, "config"),
859            (ProviderSource::Custom, "custom"),
860            (ProviderSource::Api, "api"),
861        ] {
862            let json_str = serde_json::to_string(&variant).unwrap();
863            assert_eq!(json_str, format!("\"{}\"", expected));
864            let back: ProviderSource = serde_json::from_str(&json_str).unwrap();
865            assert_eq!(variant, back);
866        }
867    }
868
869    #[test]
870    fn model_limit_with_input() {
871        let limit = ModelLimit { context: 200_000.0, input: Some(190_000.0), output: 16_384.0 };
872        let json_str = serde_json::to_string(&limit).unwrap();
873        assert!(json_str.contains("input"));
874        let back: ModelLimit = serde_json::from_str(&json_str).unwrap();
875        assert_eq!(limit, back);
876    }
877
878    #[test]
879    fn model_limit_without_input() {
880        let limit = ModelLimit { context: 128_000.0, input: None, output: 4_096.0 };
881        let json_str = serde_json::to_string(&limit).unwrap();
882        assert!(!json_str.contains("input"));
883        let back: ModelLimit = serde_json::from_str(&json_str).unwrap();
884        assert_eq!(limit, back);
885    }
886}