Skip to main content

car_inference/
schema.rs

1//! Model schema — declarative metadata for models, analogous to ToolSchema for tools.
2//!
3//! Every model (local GGUF, remote API, Ollama) is described by a `ModelSchema`
4//! that declares identity, capabilities, constraints, cost, and source.
5//! The router uses this schema for initial routing; observed outcomes refine it.
6
7use serde::{Deserialize, Serialize};
8
9/// What a model can do.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
11#[serde(rename_all = "snake_case")]
12pub enum ModelCapability {
13    /// Text completion / chat generation
14    Generate,
15    /// Vector embeddings
16    Embed,
17    /// Cross-encoder relevance scoring (query + document → relevance
18    /// score). Qwen3-Reranker is the canonical local implementation.
19    Rerank,
20    /// Label assignment / classification
21    Classify,
22    /// Code generation, repair, refactoring
23    Code,
24    /// Chain-of-thought, planning, analysis
25    Reasoning,
26    /// Text condensation
27    Summarize,
28    /// Function/tool calling
29    ToolUse,
30    /// Multiple tool calls in a single response (parallel tool execution)
31    MultiToolCall,
32    /// Vision / image understanding
33    Vision,
34    /// Video understanding (multi-frame sampling + temporal tokens).
35    /// Distinct from `Vision` so routing can prefer video-trained
36    /// models when the caller attaches a video content block.
37    VideoUnderstanding,
38    /// Audio understanding (speech + non-speech audio as an input to
39    /// a chat/reasoning model). Distinct from `SpeechToText` which is
40    /// the transcription-only task. Gemma 4 E2B/E4B and Gemini do
41    /// this; Qwen2.5-VL does not.
42    AudioUnderstanding,
43    /// Visual grounding — structured object-localization output
44    /// (bounding boxes keyed to object labels) in addition to text.
45    Grounding,
46    /// Speech recognition / transcription
47    SpeechToText,
48    /// Speech synthesis / text-to-speech
49    TextToSpeech,
50    /// Image generation
51    ImageGeneration,
52    /// Video generation
53    VideoGeneration,
54}
55
56/// How much the project vouches for a model. Gates automatic upgrades and
57/// is surfaced in recommendation rationale. Closed enum — a new tier is a
58/// deliberate FFI-visible change, never a silent string fallback.
59#[derive(
60    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, schemars::JsonSchema,
61)]
62#[serde(rename_all = "snake_case")]
63pub enum TrustTier {
64    /// Vetted by the project — the built-in catalog and verified upgrades.
65    /// Eligible for background auto-apply when the user opts in.
66    #[default]
67    Curated,
68    /// User-registered or upstream-discovered, not project-vetted. Always
69    /// notify-only; never auto-applied regardless of update policy.
70    Community,
71}
72
73/// How to access the model.
74#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
75#[serde(tag = "type", rename_all = "snake_case")]
76pub enum ModelSource {
77    /// Local GGUF file via Candle backend.
78    Local {
79        hf_repo: String,
80        hf_filename: String,
81        tokenizer_repo: String,
82    },
83    /// Remote API endpoint (OpenAI-compatible, Anthropic, etc.)
84    RemoteApi {
85        endpoint: String,
86        /// Environment variable name containing the API key (never the key itself).
87        /// The env var value may contain comma-separated keys for load balancing.
88        api_key_env: String,
89        /// Additional environment variable names for load balancing across multiple keys.
90        /// Each env var may also contain comma-separated keys.
91        #[serde(default)]
92        api_key_envs: Vec<String>,
93        #[serde(default)]
94        api_version: Option<String>,
95        protocol: ApiProtocol,
96    },
97    /// One text-generation turn through the locally installed OpenAI Codex CLI.
98    ///
99    /// Codex remains the sole holder of its ChatGPT-subscription credential:
100    /// CAR neither reads that credential nor accepts an API key for this source.
101    /// An optional reasoning suffix is part of the model string (for example,
102    /// `gpt-5.6-sol:high`) and maps to Codex's `model_reasoning_effort` config.
103    CodexCli { model: String },
104    /// Ollama local server.
105    Ollama {
106        model_tag: String,
107        #[serde(default = "default_ollama_host")]
108        host: String,
109    },
110    /// Local MLX model via mlx-rs backend (Apple Silicon, safetensors format).
111    /// Models from mlx-community on HuggingFace.
112    Mlx {
113        /// HuggingFace repo (e.g., "mlx-community/Qwen3-4B-4bit").
114        hf_repo: String,
115        /// Optional specific weight filename. If None, auto-discovers safetensors files.
116        #[serde(default)]
117        hf_weight_file: Option<String>,
118    },
119    /// Local whisper.cpp speech-to-text model — a ggml `.bin` from the
120    /// `ggerganov/whisper.cpp` HF repo, run in-process via the shared
121    /// `car-whisper` crate. Cross-platform (Windows/Linux/macOS): this is the
122    /// on-device STT path where MLX isn't available. Cached at
123    /// `~/.tokhn/whisper/ggml-<model>.bin`.
124    WhisperCpp {
125        /// whisper.cpp model id — the suffix of `ggml-<model>.bin`
126        /// (e.g. `"large-v3-turbo-q5_0"`).
127        model: String,
128    },
129    /// Windows OS text-to-speech via `Windows.Media.SpeechSynthesis` (WinRT),
130    /// run in-process. The catalog-side analog of the `car-voice`
131    /// `TtsProvider::WindowsSpeech` live path and the parity counterpart of
132    /// Apple's OS synthesizer — free, on-device, no model download, no MLX.
133    /// Windows-only; availability is `false` on every other target (like
134    /// `AppleFoundationModels`).
135    WindowsSpeech {},
136    /// Local vLLM-MLX server (Apple Silicon, OpenAI-compatible API).
137    /// Routes through RemoteBackend with OpenAI protocol handler.
138    VllmMlx {
139        /// Server endpoint (e.g., "http://localhost:8000").
140        endpoint: String,
141        /// The model name as known to vLLM-MLX (e.g., "mlx-community/Qwen3-4B-4bit").
142        model_name: String,
143    },
144    /// CAR-owned supervised vLLM-MLX process backed by a managed HuggingFace
145    /// artifact. Unlike `VllmMlx`, CAR downloads, admits, spawns, reaps, and
146    /// accounts this allocation. Dispatch rewrites a clone to `VllmMlx` only
147    /// after the child reports healthy.
148    ManagedVllmMlx {
149        hf_repo: String,
150        #[serde(default)]
151        hf_weight_file: Option<String>,
152    },
153    /// Apple's on-device system model via the FoundationModels framework
154    /// (macOS 26+, Apple Silicon). Inference happens in-process through a
155    /// Swift shim — there is no HTTP, no API key, and no model file: the
156    /// OS owns the weights. Availability is checked at runtime via
157    /// `@available(macOS 26.0, *)`; on older macOS or non-Apple-Silicon
158    /// hosts the backend reports `UnsupportedMode` and the router falls
159    /// through to the next candidate.
160    AppleFoundationModels {
161        /// Optional Apple use-case hint passed through to
162        /// `LanguageModelSession`. Apple's framework tunes its prompt and
163        /// safety scaffolding per use case (e.g. "general", "summarize").
164        /// `None` uses the default.
165        #[serde(default)]
166        use_case: Option<String>,
167    },
168    /// Proprietary provider with custom auth and protocol.
169    ///
170    /// For vendor-specific APIs that aren't generic OpenAI-compatible endpoints.
171    /// Parslee is the first proprietary provider — custom auth (OAuth2),
172    /// custom response format, multi-provider routing built into the API.
173    Proprietary {
174        /// Provider identifier (e.g., "parslee").
175        provider: String,
176        /// Base URL for the API.
177        endpoint: String,
178        /// Auth configuration.
179        auth: ProprietaryAuth,
180        /// Custom protocol details.
181        protocol: ProprietaryProtocol,
182    },
183    /// Inference is delegated to a host-registered runner. CAR does
184    /// not own the wire format — the runner (typically a JS / Python
185    /// host) translates the `GenerateRequest` to its provider's API,
186    /// streams chunks back through the runner's event callback, and
187    /// returns the final aggregated result.
188    ///
189    /// Closes Parslee-ai/car-releases#24. Use this when the host
190    /// already has an SDK relationship with a provider (Anthropic,
191    /// OpenAI, GitHub Models, Vercel AI SDK) and wants CAR to sit in
192    /// the lifecycle / policy / replay path without learning every
193    /// provider's wire format.
194    ///
195    /// Routing requires that a runner has been registered via
196    /// [`crate::set_inference_runner`] (or its FFI equivalent —
197    /// `registerInferenceRunner` on JS, `register_inference_runner`
198    /// on Python, the `InferenceRunner` foreign trait on UniFFI,
199    /// `inference.register_runner` on the WebSocket protocol).
200    /// Without a runner, dispatch fails with `InferenceFailed`.
201    Delegated {
202        /// Opaque hint passed through to the runner — typically the
203        /// provider id (`"anthropic"`, `"openai"`, `"vercel-ai-sdk"`)
204        /// so a multi-provider runner can dispatch internally. CAR
205        /// does not interpret this string.
206        #[serde(default)]
207        hint: Option<String>,
208    },
209}
210
211/// Authentication method for proprietary providers.
212#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
213#[serde(tag = "type", rename_all = "snake_case")]
214pub enum ProprietaryAuth {
215    /// OAuth2 PKCE flow (e.g., Azure AD for Parslee).
216    #[serde(rename = "oauth2_pkce", alias = "o_auth2_pkce")]
217    OAuth2Pkce {
218        authority: String,
219        client_id: String,
220        scopes: Vec<String>,
221    },
222    /// Static API key from environment variable.
223    ApiKeyEnv { env_var: String },
224    /// Bearer token from environment variable.
225    BearerTokenEnv { env_var: String },
226}
227
228/// Protocol configuration for proprietary providers.
229#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
230pub struct ProprietaryProtocol {
231    /// Chat/completion endpoint path (appended to base URL).
232    #[serde(default = "default_chat_path")]
233    pub chat_path: String,
234    /// Content type for requests.
235    #[serde(default = "default_content_type")]
236    pub content_type: String,
237    /// Whether the API streams responses via SSE.
238    #[serde(default)]
239    pub streaming: bool,
240    /// Custom headers to include in every request.
241    #[serde(default)]
242    pub extra_headers: std::collections::HashMap<String, String>,
243}
244
245impl Default for ProprietaryProtocol {
246    fn default() -> Self {
247        Self {
248            chat_path: default_chat_path(),
249            content_type: default_content_type(),
250            streaming: false,
251            extra_headers: std::collections::HashMap::new(),
252        }
253    }
254}
255
256fn default_chat_path() -> String {
257    "/chat".to_string()
258}
259
260fn default_content_type() -> String {
261    "application/json".to_string()
262}
263
264fn default_ollama_host() -> String {
265    "http://localhost:11434".to_string()
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
269#[serde(rename_all = "snake_case")]
270pub enum ApiProtocol {
271    OpenAiCompat,
272    /// OpenRouter's OpenAI-compatible Chat Completions surface. Distinct so
273    /// credential precedence and error translation stay provider-specific.
274    OpenRouter,
275    /// OpenAI Responses API (/v1/responses) — works with all OpenAI models including codex.
276    OpenAiResponses,
277    Anthropic,
278    Google,
279    /// Azure OpenAI — uses api-key header and deployment-based URLs.
280    /// Endpoint format: {base}/openai/deployments/{model}/chat/completions?api-version={version}
281    AzureOpenAi,
282    /// Google Vertex AI — the enterprise Gemini surface. Same request/response
283    /// shape as the AI-Studio `Google` protocol, but a project/location URL and
284    /// OAuth Bearer auth (a GCP access token from `gcloud auth print-access-token`
285    /// or a service account) instead of an `?key=` query param. Endpoint format:
286    /// `{base}/publishers/google/models/{model}:generateContent`, where `base`
287    /// is `https://{loc}-aiplatform.googleapis.com/v1/projects/{proj}/locations/{loc}`.
288    VertexAi,
289    /// AWS Bedrock — the **Converse** API (`bedrock-runtime`), a unified
290    /// messages surface across Bedrock-hosted models (Claude, Llama, Mistral,
291    /// Titan, …). Auth is **SigV4** request signing (not a bearer token), with
292    /// credentials from the standard AWS env vars; the model's `endpoint` is the
293    /// region (e.g. `us-east-1`) and `name` is the Bedrock model id. Non-stream
294    /// only for now (Converse streaming uses a separate binary event-stream).
295    Bedrock,
296}
297
298impl ApiProtocol {
299    /// Prompt-cache economics for this provider, relative to its base input
300    /// rate — used by the cost scoreboard to price cached tokens correctly.
301    /// Anthropic uses explicit breakpoints (deep read discount + write
302    /// premium); OpenAI/Azure cache automatically (~0.5× read, no write
303    /// charge). Providers whose cache tokens CAR does not parse (Google/Vertex/
304    /// Bedrock) report zero cache tokens, so their rates are inert.
305    pub fn cache_rates(&self) -> crate::outcome::CacheRates {
306        use crate::outcome::CacheRates;
307        match self {
308            ApiProtocol::Anthropic => CacheRates::ANTHROPIC,
309            ApiProtocol::OpenAiCompat | ApiProtocol::OpenAiResponses | ApiProtocol::AzureOpenAi => {
310                CacheRates::OPENAI
311            }
312            // OpenRouter rates differ per upstream model and are carried by
313            // ModelSchema::cost. A blanket OpenAI-shaped discount is false.
314            ApiProtocol::OpenRouter => CacheRates::NONE,
315            ApiProtocol::Google | ApiProtocol::VertexAi | ApiProtocol::Bedrock => CacheRates::NONE,
316        }
317    }
318}
319
320/// The numeric format a checkpoint's weights are stored in.
321///
322/// Deliberately says nothing about *which engine* serves the file —
323/// [`ModelSource`] already carries that, and keying this on the container
324/// instead of the format produces falsehoods: whisper.cpp's `q5_0` ggml
325/// checkpoints use the same round-to-nearest block format as llama.cpp's, and
326/// a `Gguf*` variant would assert a GGUF text path that cannot load them.
327///
328/// What it does express is the axis neither the bit width nor the source can:
329/// an MLX 4-bit affine checkpoint and a `Q4_K_M` are both "4-bit", were
330/// produced by different algorithms, and do not have the same quality.
331#[derive(
332    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, schemars::JsonSchema,
333)]
334#[serde(rename_all = "snake_case")]
335pub enum QuantScheme {
336    /// Integer weights with a scale and bias shared across `group_size`
337    /// elements (`4bit`, `6bit`). MLX's default and only affine format.
338    AffineGroupInt,
339    /// Block-scaled float — MX formats (`mxfp4`, `mxfp8`). A genuinely
340    /// different loader path from [`QuantScheme::AffineGroupInt`] at the same
341    /// nominal width; see the microscaling-mode rejection in `backend/mlx.rs`,
342    /// which currently accepts exactly one of these.
343    BlockScaledFloat,
344    /// Mixed per-tensor bit allocation, optionally importance-weighted
345    /// (`Q4_K_M`, `Q5_K_S`, `IQ4_XS`, `TQ1_0`).
346    KQuantMixed,
347    /// Uniform round-to-nearest blocks, no per-tensor mixing and no importance
348    /// weighting (`Q8_0`, `q5_0`, `Q4_0_4_4`).
349    RtnBlock,
350    /// Full-precision weights (`bf16`, `f16`, `f32`). Distinct from `None` at
351    /// the [`ModelSchema::quantization`] level: a positive claim that the
352    /// checkpoint is unquantized, not an absence of information.
353    Unquantized,
354    /// A format this parser could not identify. The label is still preserved
355    /// verbatim; only the classification is missing.
356    #[default]
357    Unknown,
358}
359
360/// Structured quantization descriptor.
361///
362/// This replaces a free-text string that mixed two vocabularies — MLX's
363/// `4bit`/`6bit` and GGUF's `Q4_K_M`/`Q8_0` — under one field, so nothing could
364/// tell a group-quantized integer checkpoint from a mixed k-quant one. It also
365/// threw away what the ingest paths already had in hand: MLX `config.json`
366/// declares `{bits, group_size, mode}` and only `bits` survived.
367///
368/// Deserializes from **either** the legacy bare string or the structured
369/// object, so catalogs, user `models.json` files, and `models.register`
370/// payloads written before this change keep loading unchanged. The object form
371/// requires `label` and rejects unknown fields: a misspelled key is a producer
372/// bug, and accepting it silently is how the field it replaced lost
373/// information in the first place.
374///
375/// **Writes the bare label back whenever that is lossless**, and the object
376/// only when it carries something parsing cannot recover — a `group_size`, or
377/// a `scheme` that disambiguates a label the parser reads as `Unknown`. Two
378/// reasons, both about blast radius rather than taste. This value is inside
379/// `catalog_identity::row_digest`, so an unconditional shape change would move
380/// the digest of every quantized row and hard-fail any client that pinned
381/// `expected_catalog_revision`. And `registry::load_user_config` fails a
382/// `models.json` **whole**, with its only production caller discarding the
383/// error — so an older daemon meeting an object it cannot parse boots with
384/// zero registered models and says nothing. Emitting the object only where it
385/// adds information keeps both costs proportional to what actually changed.
386#[allow(dead_code)]
387#[derive(schemars::JsonSchema)]
388#[serde(untagged)]
389enum QuantizationWireSchema {
390    Label(String),
391    Structured(QuantizationObjectWireSchema),
392}
393
394#[allow(dead_code)]
395#[derive(schemars::JsonSchema)]
396#[serde(deny_unknown_fields)]
397struct QuantizationObjectWireSchema {
398    bits: Option<u8>,
399    scheme: QuantScheme,
400    group_size: Option<u32>,
401    label: String,
402}
403
404#[derive(Debug, Clone, PartialEq, Eq)]
405pub struct Quantization {
406    /// Weight bit width. `None` when the label names none.
407    pub bits: Option<u8>,
408    /// Which quantization family this is.
409    pub scheme: QuantScheme,
410    /// Elements sharing one scale/zero point (MLX: 32, 64, 128). `None` when
411    /// the scheme has no group concept, or the source declared none.
412    pub group_size: Option<u32>,
413    /// The label exactly as published. Never synthesized: it is what the user
414    /// sees, what Hugging Face repos are named after, and the only thing that
415    /// survives a scheme CAR does not recognize yet.
416    pub label: String,
417}
418
419/// Widths affine group quantization actually uses. A `16bit` or `32bit`
420/// label is full precision that happens to be spelled like a quant.
421const AFFINE_GROUP_WIDTHS: std::ops::RangeInclusive<u8> = 2..=8;
422
423impl Quantization {
424    /// Best-effort structure from a published label.
425    ///
426    /// Never fails and never invents: an unrecognized label yields
427    /// [`QuantScheme::Unknown`] with the label intact.
428    pub fn parse(label: &str) -> Self {
429        let label = label.trim();
430        let lower = label.to_ascii_lowercase();
431        let build = |bits: Option<u8>, scheme: QuantScheme| Self {
432            bits,
433            scheme,
434            group_size: None,
435            label: label.to_string(),
436        };
437
438        // Full precision. `fp8` is deliberately NOT here — an 8-bit float is a
439        // quantized weight format, just not one this parser can attribute.
440        match lower.as_str() {
441            "bf16" | "f16" | "fp16" | "float16" | "half" => {
442                return build(Some(16), QuantScheme::Unquantized)
443            }
444            "f32" | "fp32" | "float32" | "full" => {
445                return build(Some(32), QuantScheme::Unquantized)
446            }
447            "none" => return build(None, QuantScheme::Unquantized),
448            "f8" | "fp8" | "float8" => return build(Some(8), QuantScheme::Unknown),
449            // Empty means nobody said, which is not a claim of full precision.
450            "" => return build(None, QuantScheme::Unknown),
451            _ => {}
452        }
453
454        // MLX block-scaled floats: `mxfp4`, `mxfp8`. A bare `mxfp` names no
455        // width, so it identifies nothing.
456        if let Some(rest) = lower.strip_prefix("mxfp") {
457            return match leading_number(rest) {
458                Some((bits, _)) => build(Some(bits), QuantScheme::BlockScaledFloat),
459                None => build(None, QuantScheme::Unknown),
460            };
461        }
462
463        // MLX affine group quant: `4bit`, `4-bit`, `6bit`.
464        if let Some(width) = lower.strip_suffix("bit").map(|w| w.trim_end_matches('-')) {
465            if let Some((bits, consumed)) = leading_number(width) {
466                if consumed == width.len() && AFFINE_GROUP_WIDTHS.contains(&bits) {
467                    return build(Some(bits), QuantScheme::AffineGroupInt);
468                }
469                // `16bit`/`32bit` are full precision spelled as a width.
470                if consumed == width.len() && (bits == 16 || bits == 32) {
471                    return build(Some(bits), QuantScheme::Unquantized);
472                }
473            }
474        }
475
476        // GGUF. `iq`/`tq` are k-quant families; a bare `q` needs its suffix
477        // read to tell k-quant from legacy round-to-nearest.
478        let gguf = lower
479            .strip_prefix("iq")
480            .or_else(|| lower.strip_prefix("tq"))
481            .map(|rest| (rest, true))
482            .or_else(|| lower.strip_prefix('q').map(|rest| (rest, false)));
483        if let Some((rest, k_family)) = gguf {
484            if let Some((bits, consumed)) = leading_number(rest) {
485                if bits == 0 {
486                    return build(None, QuantScheme::Unknown);
487                }
488                // Slice by digits consumed, not by the width's decimal length —
489                // `q08_0` has a two-character prefix for a one-character number.
490                let suffix = &rest[consumed..];
491                let scheme = if k_family || suffix.contains("_k") {
492                    QuantScheme::KQuantMixed
493                } else if suffix.starts_with("_0") || suffix.starts_with("_1") {
494                    // `starts_with`, not equality: the aarch64 repack quants
495                    // are `Q4_0_4_4`, `Q4_0_4_8`, `Q4_0_8_8`.
496                    QuantScheme::RtnBlock
497                } else {
498                    // A bare `Q4` names a width and no producer — llama.cpp
499                    // has no such format, so this came from somewhere else.
500                    QuantScheme::Unknown
501                };
502                return build(Some(bits), scheme);
503            }
504        }
505
506        build(None, QuantScheme::Unknown)
507    }
508
509    /// Recover the quantization a GGUF file names in its own filename
510    /// (`Qwen3-8B-Q4_K_M.gguf`, `ggml-large-v3-turbo-q5_0.gguf`).
511    ///
512    /// Scans the hyphen-separated segments from the right and takes the first
513    /// that classifies, so a model whose *name* contains something quant-shaped
514    /// does not outrank the real suffix. Returns `None` rather than guessing
515    /// when nothing in the name is recognizable.
516    pub fn from_gguf_filename(filename: &str) -> Option<Self> {
517        let stem = filename
518            .rsplit_once('.')
519            .map(|(stem, _)| stem)
520            .unwrap_or(filename);
521        stem.rsplit('-')
522            .map(Self::parse)
523            .find(|q| q.scheme != QuantScheme::Unknown)
524    }
525
526    /// Descriptor for an MLX checkpoint, from the `quantization` block of its
527    /// `config.json`. `mode` is MLX's own name for the format (`affine`,
528    /// `mxfp4`, `mxfp8`); absent means affine, which is MLX's default.
529    ///
530    /// Returns `None` when the block carried nothing usable — declaring
531    /// `AffineGroupInt` on the strength of an empty object would assert exactly the
532    /// thing this type exists to establish.
533    pub fn from_mlx_config(
534        bits: Option<u8>,
535        group_size: Option<u32>,
536        mode: Option<&str>,
537    ) -> Option<Self> {
538        if bits.is_none() && group_size.is_none() && mode.is_none() {
539            return None;
540        }
541        let normalized = mode.map(str::to_ascii_lowercase);
542        let scheme = match normalized.as_deref() {
543            Some(m) if m.starts_with("mxfp") => QuantScheme::BlockScaledFloat,
544            Some("affine") | None => QuantScheme::AffineGroupInt,
545            Some(_) => QuantScheme::Unknown,
546        };
547        let label = match (normalized.as_deref(), bits) {
548            (Some(m), _) if m != "affine" => m.to_string(),
549            (_, Some(b)) => format!("{b}bit"),
550            // No width and no distinguishing mode: MLX said "affine" and
551            // nothing else. Say that rather than inventing a width.
552            (_, None) => "affine".to_string(),
553        };
554        Some(Self {
555            bits,
556            scheme,
557            group_size,
558            label,
559        })
560    }
561}
562
563/// Leading run of ASCII digits as a bit width, with the number of bytes it
564/// occupied. The byte count is returned because it is not recoverable from the
565/// value — `08` and `8` parse the same and slice differently.
566fn leading_number(s: &str) -> Option<(u8, usize)> {
567    let digits: String = s.chars().take_while(char::is_ascii_digit).collect();
568    if digits.is_empty() {
569        return None;
570    }
571    // Overflow (`Q256_K`) is a parse failure, not a silent truncation.
572    digits.parse().ok().map(|n| (n, digits.len()))
573}
574
575impl std::fmt::Display for Quantization {
576    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577        f.write_str(&self.label)
578    }
579}
580
581impl Serialize for Quantization {
582    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
583        use serde::ser::SerializeStruct;
584
585        // Round-tripping through `parse` is the exact test for "the label
586        // carries everything": if it reproduces this value, the object form
587        // would be a longer spelling of the same information.
588        if Self::parse(&self.label) == *self {
589            return serializer.serialize_str(&self.label);
590        }
591
592        let len = 2 + usize::from(self.bits.is_some()) + usize::from(self.group_size.is_some());
593        let mut row = serializer.serialize_struct("Quantization", len)?;
594        if let Some(bits) = self.bits {
595            row.serialize_field("bits", &bits)?;
596        }
597        row.serialize_field("scheme", &self.scheme)?;
598        if let Some(group_size) = self.group_size {
599            row.serialize_field("group_size", &group_size)?;
600        }
601        row.serialize_field("label", &self.label)?;
602        row.end()
603    }
604}
605
606impl<'de> Deserialize<'de> for Quantization {
607    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
608        /// The object form. `label` is required and unknown fields are
609        /// rejected, so a typo'd or double-nested payload is an error rather
610        /// than a row that silently claims `Unknown`.
611        #[derive(Deserialize)]
612        #[serde(deny_unknown_fields)]
613        struct Structured {
614            #[serde(default)]
615            bits: Option<u8>,
616            #[serde(default)]
617            scheme: Option<QuantScheme>,
618            #[serde(default)]
619            group_size: Option<u32>,
620            label: String,
621        }
622
623        struct QuantizationVisitor;
624
625        impl<'de> serde::de::Visitor<'de> for QuantizationVisitor {
626            type Value = Quantization;
627
628            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629                f.write_str("a quantization label string, or an object with a `label` field")
630            }
631
632            fn visit_str<E: serde::de::Error>(self, label: &str) -> Result<Self::Value, E> {
633                Ok(Quantization::parse(label))
634            }
635
636            fn visit_map<M: serde::de::MapAccess<'de>>(
637                self,
638                map: M,
639            ) -> Result<Self::Value, M::Error> {
640                // Dispatched by hand rather than via `#[serde(untagged)]`,
641                // which buffers the input and reports only "data did not match
642                // any variant" — for a `models.json` holding dozens of models
643                // that error names neither the field nor the row.
644                let s = Structured::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
645                // A row that omits `scheme` or `bits` recovers what its label
646                // implies; an explicit value always wins.
647                let inferred = Quantization::parse(&s.label);
648                Ok(Quantization {
649                    bits: s.bits.or(inferred.bits),
650                    scheme: s.scheme.unwrap_or(inferred.scheme),
651                    group_size: s.group_size,
652                    label: s.label,
653                })
654            }
655        }
656
657        d.deserialize_any(QuantizationVisitor)
658    }
659}
660
661/// Declared performance expectations. Overridden by observed data once available.
662#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
663pub struct PerformanceEnvelope {
664    /// Median latency in milliseconds (declared/estimated).
665    #[serde(default)]
666    pub latency_p50_ms: Option<u64>,
667    /// 99th percentile latency in milliseconds.
668    #[serde(default)]
669    pub latency_p99_ms: Option<u64>,
670    /// Tokens per second throughput.
671    #[serde(default)]
672    pub tokens_per_second: Option<f64>,
673}
674
675/// Cost model for routing optimization.
676/// Generation parameters that a model may or may not support.
677/// Models declare which params they accept. The inference layer
678/// strips unsupported params before sending to the API.
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
680#[serde(rename_all = "snake_case")]
681pub enum GenerateParam {
682    Temperature,
683    TopP,
684    TopK,
685    MaxTokens,
686    StopSequences,
687    FrequencyPenalty,
688    PresencePenalty,
689    Seed,
690    ResponseFormat,
691    /// Extended thinking / internal reasoning before responding.
692    ExtendedThinking,
693}
694
695/// Standard parameter set for most models.
696pub fn standard_params() -> Vec<GenerateParam> {
697    vec![
698        GenerateParam::Temperature,
699        GenerateParam::TopP,
700        GenerateParam::MaxTokens,
701        GenerateParam::StopSequences,
702        GenerateParam::FrequencyPenalty,
703        GenerateParam::PresencePenalty,
704        GenerateParam::Seed,
705    ]
706}
707
708/// Parameter set for reasoning models (no temperature, no top_p).
709pub fn reasoning_params() -> Vec<GenerateParam> {
710    vec![GenerateParam::MaxTokens, GenerateParam::StopSequences]
711}
712
713#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
714pub struct TokenPrices {
715    /// USD per 1M uncached input tokens.
716    #[serde(default)]
717    pub input_per_mtok: Option<f64>,
718    /// USD per 1M output tokens.
719    #[serde(default)]
720    pub output_per_mtok: Option<f64>,
721    /// USD per 1M cache-read input tokens.
722    #[serde(default)]
723    pub cache_read_input_per_mtok: Option<f64>,
724    /// USD per 1M cache-write input tokens.
725    #[serde(default)]
726    pub cache_write_input_per_mtok: Option<f64>,
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
730pub struct TokenPricingTier {
731    /// Inclusive prompt-token threshold at which this tier applies.
732    pub min_prompt_tokens: usize,
733    #[serde(flatten)]
734    pub prices: TokenPrices,
735}
736
737#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
738pub struct CostModel {
739    /// USD per 1M input tokens (remote models).
740    #[serde(default)]
741    pub input_per_mtok: Option<f64>,
742    /// USD per 1M output tokens (remote models).
743    #[serde(default)]
744    pub output_per_mtok: Option<f64>,
745    /// USD per 1M cache-read input tokens. Unlike protocol-wide cache
746    /// multipliers, this is model-specific and comes from the provider's
747    /// published catalog.
748    #[serde(default)]
749    pub cache_read_input_per_mtok: Option<f64>,
750    /// USD per 1M cache-write input tokens, when the provider charges one.
751    #[serde(default)]
752    pub cache_write_input_per_mtok: Option<f64>,
753    /// Prompt-size pricing overrides, sorted by increasing threshold.
754    /// The highest threshold not greater than the prompt size wins.
755    #[serde(default)]
756    pub pricing_tiers: Vec<TokenPricingTier>,
757    /// On-disk size in MB (local models).
758    #[serde(default)]
759    pub size_mb: Option<u64>,
760    /// RAM required during inference in MB.
761    #[serde(default)]
762    pub ram_mb: Option<u64>,
763}
764
765impl CostModel {
766    pub fn prices_for(&self, prompt_tokens: usize) -> TokenPrices {
767        let mut prices = TokenPrices {
768            input_per_mtok: self.input_per_mtok,
769            output_per_mtok: self.output_per_mtok,
770            cache_read_input_per_mtok: self.cache_read_input_per_mtok,
771            cache_write_input_per_mtok: self.cache_write_input_per_mtok,
772        };
773        for tier in self
774            .pricing_tiers
775            .iter()
776            .filter(|tier| tier.min_prompt_tokens <= prompt_tokens)
777        {
778            if tier.prices.input_per_mtok.is_some() {
779                prices.input_per_mtok = tier.prices.input_per_mtok;
780            }
781            if tier.prices.output_per_mtok.is_some() {
782                prices.output_per_mtok = tier.prices.output_per_mtok;
783            }
784            if tier.prices.cache_read_input_per_mtok.is_some() {
785                prices.cache_read_input_per_mtok = tier.prices.cache_read_input_per_mtok;
786            }
787            if tier.prices.cache_write_input_per_mtok.is_some() {
788                prices.cache_write_input_per_mtok = tier.prices.cache_write_input_per_mtok;
789            }
790        }
791        prices
792    }
793
794    /// Estimated request cost from the provider's declared token prices.
795    /// Unknown price components contribute zero; callers that need to
796    /// distinguish unknown pricing should inspect `prices_for` first.
797    ///
798    /// **This is the routing-score input, not a display or billing figure.**
799    /// The zero-fill is load-bearing here — `adaptive_router` normalizes the
800    /// result into a 0..1 cost score, and it separately neutralizes models
801    /// with no pricing at all, so changing the fill would change routing.
802    /// For anything a human reads, use
803    /// [`estimated_usd_bounded`](Self::estimated_usd_bounded), which refuses
804    /// to bill an unrated bucket at zero and says which way it can be wrong.
805    pub fn estimated_usd(
806        &self,
807        prompt_tokens: usize,
808        output_tokens: usize,
809        cache_read_tokens: usize,
810        cache_write_tokens: usize,
811    ) -> f64 {
812        let prices = self.prices_for(prompt_tokens);
813        let uncached_input = prompt_tokens
814            .saturating_sub(cache_read_tokens)
815            .saturating_sub(cache_write_tokens);
816        (uncached_input as f64 * prices.input_per_mtok.unwrap_or(0.0)
817            + output_tokens as f64 * prices.output_per_mtok.unwrap_or(0.0)
818            + cache_read_tokens as f64 * prices.cache_read_input_per_mtok.unwrap_or(0.0)
819            + cache_write_tokens as f64 * prices.cache_write_input_per_mtok.unwrap_or(0.0))
820            / 1_000_000.0
821    }
822
823    /// Effective per-bucket rates under one resolved price sheet, in the
824    /// bucket order `[uncached input, output, cache read, cache write]`, each
825    /// paired with which way a *substituted* rate can be wrong:
826    /// `(rate, may_overstate, may_understate)`.
827    ///
828    /// The two cache buckets substitute the uncached-input rate but are **not
829    /// governed by one rule**, because the providers in this catalog do not
830    /// price them the same way relative to input:
831    ///
832    /// | bucket | observed vs input, curated table |
833    /// |---|---|
834    /// | cache read | `0.10x`–`0.64x` — always a discount |
835    /// | cache write | `0.1875x` (Google) … `1.25x` (Anthropic) — **both sides** |
836    ///
837    /// So substituting input for a missing **cache-read** rate can only be too
838    /// high (`may_overstate`), while for a missing **cache-write** rate it can
839    /// land either side and gets both flags, rendering `~` rather than a `≤`
840    /// the figure cannot honour. Treating the two alike is how `claude-opus-4.8`
841    /// — `input 5.0`, `cache_write 6.25` — would have worn a `≤$5.00` ceiling
842    /// over a true cost of `$6.25`, or `$10.00` at OpenRouter's 1h-TTL rate.
843    ///
844    /// **Output** substitutes nothing and refuses instead: output runs
845    /// `1.5x`–`8x` input across this same table, which is not a ballpark
846    /// estimate in either direction, merely a wrong number wearing a marker.
847    /// The line is whether a substitute is *within range and merely of unknown
848    /// sign* (estimate, flag it) or *out of range entirely* (refuse) — see
849    /// [`estimated_usd_bounded`](Self::estimated_usd_bounded).
850    fn bucket_rates(prices: &TokenPrices) -> [(Option<f64>, bool, bool); 4] {
851        // A cached READ is always discounted relative to uncached input — the
852        // entire point of the cache — so the input rate is a true ceiling.
853        let cache_read = match prices.cache_read_input_per_mtok {
854            Some(rate) => (Some(rate), false, false),
855            None => {
856                let substituted = prices.input_per_mtok.is_some();
857                (prices.input_per_mtok, substituted, false)
858            }
859        };
860        // A cache WRITE may be a surcharge (Anthropic 1.25x, OpenRouter's 1h
861        // TTL 2x) or a discount (Google 0.1875x). Unknown sign, so no bound.
862        let cache_write = match prices.cache_write_input_per_mtok {
863            Some(rate) => (Some(rate), false, false),
864            None => {
865                let substituted = prices.input_per_mtok.is_some();
866                (prices.input_per_mtok, substituted, substituted)
867            }
868        };
869        [
870            (prices.input_per_mtok, false, false),
871            (prices.output_per_mtok, false, false),
872            cache_read,
873            cache_write,
874        ]
875    }
876
877    /// Cost estimate for a figure a person will read, carrying which way it
878    /// can be wrong.
879    ///
880    /// Differs from [`estimated_usd`](Self::estimated_usd) in refusing to
881    /// invent numbers. A token bucket the provider charges for but whose rate
882    /// this catalog does not declare is **never billed at zero**, and a figure
883    /// that might be wrong never presents itself as exact.
884    ///
885    /// `tier_prompt_tokens` selects the prompt-size pricing tier and is the
886    /// parameter callers most often get wrong:
887    ///
888    /// - `Some(n)` — the prompt size of **one request**. Tiers resolve exactly.
889    /// - `None` — the caller cannot say (a lifetime accumulator has summed
890    ///   many requests and lost their boundaries). Base rates are used and the
891    ///   result is flagged in whichever direction the model's own tiers run.
892    ///
893    /// Passing a *summed* token count as `Some` is the bug this signature
894    /// exists to prevent: thirty 10K-token requests sum to 300K, which crosses
895    /// a 272K threshold that no individual request came near, and every token
896    /// ever sent gets priced at the high-context rate — roughly double, stated
897    /// with total confidence.
898    ///
899    /// Two rejected alternatives, for the next person who wants tiers on an
900    /// aggregate. **Pricing lifetime totals at the tier their sum lands in** is
901    /// the bug above. **Pricing everything at the highest declared tier** does
902    /// yield a true ceiling, but a useless one — it doubles the figure for a
903    /// user whose prompts never approached the threshold, which is the same
904    /// confident wrongness in the other direction. Resolving tiers properly
905    /// needs per-request prompt sizes, which means [`crate::ModelProfile`]
906    /// would have to accumulate per-tier token buckets at record time; that is
907    /// a real feature with a persisted-schema change, not something to fake
908    /// here from data that has already been summed away.
909    ///
910    /// Returns `None` when no defensible number exists — either the model
911    /// declares no rate card at all, or a bucket carrying tokens has no rate
912    /// and no usable substitute. Output deliberately has no input-rate
913    /// fallback: it runs 1.5x–8x input across this catalog, far enough out of
914    /// range that no marker could rescue the number. Which buckets substitute,
915    /// and which way each substitution can be wrong, is decided in
916    /// [`bucket_rates`](Self::bucket_rates) from observed provider pricing —
917    /// notably cache *reads* and cache *writes* do not share a direction.
918    /// `None` means unpriced, and a caller must render it as such, not as free.
919    pub fn estimated_usd_bounded(
920        &self,
921        tier_prompt_tokens: Option<usize>,
922        uncached_input_tokens: usize,
923        output_tokens: usize,
924        cache_read_tokens: usize,
925        cache_write_tokens: usize,
926    ) -> Option<ApproxCost> {
927        // `prices_for(0)` is the base sheet: no tier threshold is <= 0 in a
928        // catalog whose thresholds are positive, so nothing overrides.
929        let prices = self.prices_for(tier_prompt_tokens.unwrap_or(0));
930        if prices.input_per_mtok.is_none() && prices.output_per_mtok.is_none() {
931            // No rate card. Not free — unknown.
932            return None;
933        }
934
935        let tokens = [
936            uncached_input_tokens,
937            output_tokens,
938            cache_read_tokens,
939            cache_write_tokens,
940        ];
941        let rates = Self::bucket_rates(&prices);
942        let mut usd = 0.0;
943        let mut may_overstate = false;
944        let mut may_understate = false;
945        for (count, (rate, substitute_high, substitute_low)) in tokens.into_iter().zip(rates) {
946            if count == 0 {
947                continue;
948            }
949            // Charged, rate unknown, no usable substitute — admit we can't.
950            let rate = rate?;
951            // Direction comes from the bucket, not from one blanket rule: a
952            // substituted cache-READ rate can only be high, a substituted
953            // cache-WRITE rate can land either side.
954            may_overstate |= substitute_high;
955            may_understate |= substitute_low;
956            usd += count as f64 * rate;
957        }
958
959        // Unresolvable tiers: say which way the base sheet can be wrong rather
960        // than assuming tiers always cost more. Compare the rate each bucket
961        // would actually pay at every declared threshold against the base.
962        if tier_prompt_tokens.is_none() {
963            for tier in &self.pricing_tiers {
964                let at_tier = Self::bucket_rates(&self.prices_for(tier.min_prompt_tokens));
965                for (count, ((base_rate, _, _), (tier_rate, _, _))) in
966                    tokens.into_iter().zip(rates.iter().zip(at_tier))
967                {
968                    if count == 0 {
969                        continue;
970                    }
971                    if let (Some(base), Some(tiered)) = (base_rate, tier_rate) {
972                        may_understate |= tiered > *base;
973                        may_overstate |= tiered < *base;
974                    }
975                }
976            }
977        }
978
979        Some(ApproxCost {
980            usd: usd / 1_000_000.0,
981            may_overstate,
982            may_understate,
983        })
984    }
985}
986
987/// A cost figure plus which way it can be wrong.
988///
989/// Presenting an estimate as an exact price is the same failure as billing an
990/// unrated bucket at zero, one step later — so the direction travels with the
991/// number instead of being re-derived (or forgotten) at each display site.
992#[derive(Debug, Clone, Copy, PartialEq)]
993pub struct ApproxCost {
994    /// USD.
995    pub usd: f64,
996    /// The true cost may be **lower** — a bucket was priced at a substitute
997    /// rate that can only be too high (a cache read at the uncached-input
998    /// rate), or a pricing tier is cheaper than the base sheet used.
999    pub may_overstate: bool,
1000    /// The true cost may be **higher** — a pricing tier dearer than the base
1001    /// sheet could not be resolved from the tokens the caller had, or a cache
1002    /// *write* was priced at the uncached-input rate and the provider charges
1003    /// a surcharge for it (Anthropic 1.25x, OpenRouter's 1h TTL 2x).
1004    pub may_understate: bool,
1005}
1006
1007impl ApproxCost {
1008    /// Every rate applied exactly; the figure is the price.
1009    pub fn is_exact(&self) -> bool {
1010        !self.may_overstate && !self.may_understate
1011    }
1012
1013    /// Prefix for the figure: `≤` a ceiling, `≥` a floor, `~` neither bound
1014    /// holds, empty when exact. Rendering the number without this is the
1015    /// defect the type exists to prevent.
1016    pub fn marker(&self) -> &'static str {
1017        match (self.may_overstate, self.may_understate) {
1018            (false, false) => "",
1019            (true, false) => "≤",
1020            (false, true) => "≥",
1021            (true, true) => "~",
1022        }
1023    }
1024}
1025
1026/// A score on a public benchmark from a published source (model card,
1027/// paper, leaderboard). The schema is deliberately permissive — no enum
1028/// of benchmark names — so the catalog can carry whichever benchmarks
1029/// the upstream provider chose to publish, and new ones can be added
1030/// without a code change. Scores are stored on a 0.0–1.0 scale (e.g.
1031/// 73.5% accuracy → 0.735) so they compare cleanly across benchmarks
1032/// and so `routing_ext::apply_benchmark_priors` can consume them
1033/// directly when wired in later.
1034#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
1035pub struct BenchmarkScore {
1036    /// Benchmark name as published (e.g., "MMLU-Pro", "GPQA-Diamond",
1037    /// "SWE-bench-Verified", "HumanEval", "MATH").
1038    pub name: String,
1039    /// Score on a 0.0–1.0 scale.
1040    pub score: f64,
1041    /// Evaluation harness or setup label (e.g., "5-shot", "0-shot CoT",
1042    /// "agentic", "pass@1"). Optional but strongly recommended — the
1043    /// same benchmark name can mean different things under different
1044    /// harnesses.
1045    #[serde(default)]
1046    pub harness: Option<String>,
1047    /// Where the score came from (model card URL, paper, leaderboard
1048    /// snapshot). Empty when the source is the upstream provider's
1049    /// announcement and a stable URL is not yet known.
1050    #[serde(default)]
1051    pub source_url: Option<String>,
1052    /// ISO 8601 date of the score snapshot (e.g., "2025-08-12"). Lets
1053    /// downstream code judge how stale a number is.
1054    #[serde(default)]
1055    pub measured_at: Option<String>,
1056}
1057
1058/// The full declarative schema for a model.
1059///
1060/// Analogous to `ToolSchema` — describes what a model is, what it can do,
1061/// and how to access it. The router uses this for constraint-based filtering
1062/// and cold-start scoring before observed performance data is available.
1063#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
1064pub struct ModelSchema {
1065    /// Unique identifier: "provider/model-name:variant" (e.g., "qwen/qwen3-4b:q4_k_m").
1066    pub id: String,
1067    /// Human-readable display name.
1068    pub name: String,
1069    /// Provider (qwen, openai, anthropic, google, meta, ollama, custom).
1070    pub provider: String,
1071    /// Model family for grouping (qwen3, gpt-4, claude-4, llama-3).
1072    pub family: String,
1073    /// Semantic version or checkpoint label.
1074    #[serde(default)]
1075    pub version: String,
1076    /// What this model can do — ordered by primary capability first.
1077    pub capabilities: Vec<ModelCapability>,
1078    /// Context window in tokens.
1079    pub context_length: usize,
1080    /// Per-model maximum OUTPUT tokens the provider will return in one
1081    /// response. None = unknown; callers fall back to
1082    /// effective_max_output() which derives a fraction of context_length.
1083    #[serde(default)]
1084    pub max_output_tokens: Option<usize>,
1085    /// Parameter count as human-readable string (e.g., "4B", "30B (3B active)").
1086    #[serde(default)]
1087    pub param_count: String,
1088    /// How the weights are quantized, if at all. `None` for remote models and
1089    /// for local ones whose source declared nothing. See [`Quantization`] —
1090    /// this accepts the legacy bare-string form on the wire.
1091    #[serde(default)]
1092    #[schemars(with = "Option<QuantizationWireSchema>")]
1093    pub quantization: Option<Quantization>,
1094    /// Declared performance envelope (initial estimate, overridden by observed data).
1095    #[serde(default)]
1096    pub performance: PerformanceEnvelope,
1097    /// Cost structure.
1098    #[serde(default)]
1099    pub cost: CostModel,
1100    /// How to access this model.
1101    pub source: ModelSource,
1102    /// Free-form tags for filtering (e.g., "fast", "multilingual", "moe").
1103    #[serde(default)]
1104    pub tags: Vec<String>,
1105    /// Supported generation parameters. The inference layer strips any parameter
1106    /// not in this set before sending to the API. Empty = all supported.
1107    #[serde(default)]
1108    pub supported_params: Vec<GenerateParam>,
1109    /// Public benchmark scores as published by the model provider or
1110    /// reproduced on a public leaderboard (MMLU-Pro, GPQA-Diamond,
1111    /// SWE-bench, HumanEval, etc.). The built-in catalog ships this
1112    /// empty — population is a curation step, not a code change. See
1113    /// `BenchmarkScore` for the field shape and the 0.0–1.0 scoring
1114    /// convention.
1115    #[serde(default)]
1116    pub public_benchmarks: Vec<BenchmarkScore>,
1117    /// How much the project vouches for this model. The built-in catalog is
1118    /// `Curated`. Deserialization retains the legacy `Curated` default, so
1119    /// every user-controlled ingestion boundary must call
1120    /// [`Self::mark_user_registered`] before persistence or registration.
1121    /// Gates auto-apply (task #8) and this is surfaced in recommendation
1122    /// rationale.
1123    #[serde(default)]
1124    pub trust_tier: TrustTier,
1125    /// Superseded models stay listed if installed but are excluded from
1126    /// fresh recommendations. `#[serde(default)]` → not deprecated.
1127    #[serde(default)]
1128    pub deprecated: bool,
1129    /// Whether this model is currently available (downloaded / reachable).
1130    /// Not serialized — computed at runtime.
1131    #[serde(skip)]
1132    pub available: bool,
1133    /// Whether this model can be used **right now, without a download**.
1134    ///
1135    /// Deliberately narrower than [`Self::available`], which for a local MLX
1136    /// model is true as soon as an `hf_repo` is declared — `ensure_local()`
1137    /// lazy-downloads on first use, so a declared repo is "functionally
1138    /// available" (see #164). That is the right default for open-ended work and
1139    /// wrong for work on a deadline: a step with a bounded budget that picks a
1140    /// model it must first fetch spends the whole budget downloading and fails.
1141    /// That is exactly how `car code`'s 120s contract derivation became
1142    /// unusable on a machine with no local weights (Parslee-ai/car#638).
1143    ///
1144    /// Callers express the requirement with [`crate::IntentHint::require_ready`];
1145    /// this is the per-candidate fact that hint filters on. Recomputed on every
1146    /// registration, so a cached schema can't carry a stale value.
1147    #[serde(skip)]
1148    pub weights_ready: bool,
1149}
1150
1151impl ModelSchema {
1152    /// Mark a schema as user-controlled rather than project-vetted.
1153    ///
1154    /// This is intentionally separate from serde's legacy default: old built-in
1155    /// and test fixtures omit `trust_tier` and must continue to deserialize,
1156    /// while `models.json`, CLI imports, and daemon `models.register` must never
1157    /// inherit `Curated` merely because a caller omitted the field or supplied
1158    /// a forged value.
1159    pub fn mark_user_registered(&mut self) {
1160        self.trust_tier = TrustTier::Community;
1161    }
1162
1163    /// Check if this model has a given capability.
1164    pub fn has_capability(&self, cap: ModelCapability) -> bool {
1165        self.capabilities.contains(&cap)
1166    }
1167
1168    /// Live availability for credential-backed providers. The catalog field is
1169    /// a startup snapshot; Settings/OAuth changes must affect the next list and
1170    /// route without a daemon restart.
1171    pub fn available_now(&self) -> bool {
1172        match &self.source {
1173            ModelSource::RemoteApi {
1174                protocol: ApiProtocol::OpenRouter,
1175                ..
1176            } => self.available && crate::openrouter::credential_source().is_some(),
1177            ModelSource::CodexCli { .. } => crate::backend::codex_cli::is_available(),
1178            _ => self.available,
1179        }
1180    }
1181
1182    /// Prompt-cache economics for this model, derived from its remote
1183    /// protocol. Local / non-remote models have no remote prompt cache, so
1184    /// their cache rates are inert ([`CacheRates::NONE`](crate::outcome::CacheRates::NONE)).
1185    pub fn cache_rates(&self) -> crate::outcome::CacheRates {
1186        match &self.source {
1187            ModelSource::RemoteApi {
1188                protocol: ApiProtocol::OpenRouter,
1189                ..
1190            } => {
1191                let input = self.cost.input_per_mtok.unwrap_or(0.0);
1192                if input > 0.0 {
1193                    crate::outcome::CacheRates {
1194                        read_mult: self.cost.cache_read_input_per_mtok.unwrap_or(0.0) / input,
1195                        write_mult: self.cost.cache_write_input_per_mtok.unwrap_or(0.0) / input,
1196                    }
1197                } else {
1198                    crate::outcome::CacheRates::NONE
1199                }
1200            }
1201            ModelSource::RemoteApi { protocol, .. } => protocol.cache_rates(),
1202            _ => crate::outcome::CacheRates::NONE,
1203        }
1204    }
1205
1206    /// The organization whose model this is, when that can be honestly known.
1207    ///
1208    /// Answers one question — could two models be expected to fail the same
1209    /// way? — so it is deliberately conservative. `None` means UNKNOWABLE, not
1210    /// "none", and callers must treat it as "cannot tell" rather than folding
1211    /// it into a count of distinct vendors.
1212    ///
1213    /// NOT [`Self::provider`], which means four different things depending on
1214    /// which path built the schema:
1215    ///
1216    /// * curated remote rows — the real vendor (`openai`, `anthropic`);
1217    /// * OpenRouter and the Parslee gateway — the AGGREGATOR, so three vendors
1218    ///   behind one gateway all report `openrouter`/`parslee` and one vendor
1219    ///   reached two ways reports as two;
1220    /// * a HuggingFace-derived row — the repo ORG that uploaded it, so
1221    ///   `unsloth/Qwen3` and `mlx-community/Qwen3` are the same weights under
1222    ///   two "vendors";
1223    /// * a discovered local-server row — a guess from the model name.
1224    ///
1225    /// Only the first is a vendor, so only the first is reported. NOT `family`
1226    /// either — that is the model line, so `claude-4.6` and `claude-4.8` read as
1227    /// different and are both Anthropic.
1228    pub fn vendor(&self) -> Option<&str> {
1229        // The aggregator case: the curated table knows which upstream a gateway
1230        // alias resolves to, which is the only place that survives an id
1231        // carrying no trace of its vendor.
1232        if self.provider.eq_ignore_ascii_case("openrouter")
1233            || self.provider.eq_ignore_ascii_case("parslee")
1234        {
1235            return crate::openrouter::curated_vendor(&self.id);
1236        }
1237        // A local model is served by the operator's own machine. Whoever
1238        // uploaded the weights is not an organization that could fail
1239        // independently of the process running beside it.
1240        if self.is_local() {
1241            return None;
1242        }
1243        // Only a project-vetted row's `provider` was assigned deliberately. On a
1244        // community row it is the uploader or a guess, and claiming it here is
1245        // how two repacks of one checkpoint would pass as two vendors.
1246        if self.trust_tier != TrustTier::Curated {
1247            return None;
1248        }
1249        (!self.provider.is_empty()).then_some(self.provider.as_str())
1250    }
1251
1252    /// Check if this model is local (runs on-device).
1253    pub fn is_local(&self) -> bool {
1254        matches!(
1255            self.source,
1256            ModelSource::Local { .. }
1257                | ModelSource::Mlx { .. }
1258                | ModelSource::WhisperCpp { .. }
1259                | ModelSource::WindowsSpeech { .. }
1260                | ModelSource::ManagedVllmMlx { .. }
1261                | ModelSource::AppleFoundationModels { .. }
1262        )
1263    }
1264
1265    /// Whether this model has weights CAR fetches to disk before it can be
1266    /// used — i.e. whether "is it installed?" is a question with an answer.
1267    ///
1268    /// Three predicates in this area are easy to conflate, and conflating them
1269    /// is what Parslee-ai/car#894 was about:
1270    ///
1271    /// - [`is_local`](Self::is_local) — *owned on this machine*. True for
1272    ///   `WindowsSpeech` (the OS owns the voices), `AppleFoundationModels`
1273    ///   (the OS owns the weights), and CAR-managed sources. An external
1274    ///   `VllmMlx` endpoint is remote even when its URL happens to be loopback.
1275    /// - [`weights_ready`](Self::weights_ready) — *the weights are on disk
1276    ///   now*. Only meaningful when this predicate is true; for everything
1277    ///   else the registry sets it to `true` as a "nothing blocks an attempt"
1278    ///   sentinel, which reads as "installed" if taken literally.
1279    /// - `downloads_weights` (this one) — *there is something to install at
1280    ///   all*. Use it to decide whether an install/download status should be
1281    ///   reported, then use `weights_ready` for the status itself.
1282    ///
1283    /// Rendering `weights_ready` without this gate is what made
1284    /// `windows/speech-synthesis:os` claim `INSTALLED yes` while `car doctor`
1285    /// said `Models: none installed`, and made `apple/foundation:default` and
1286    /// the `vllm-mlx/*` rows claim `INSTALLED no` for models that install
1287    /// nothing.
1288    ///
1289    /// Written as an exhaustive `match` rather than `matches!` so that adding
1290    /// a `ModelSource` variant is a compile error here instead of a silently
1291    /// wrong answer in the CLI.
1292    pub fn downloads_weights(&self) -> bool {
1293        match self.source {
1294            // CAR fetches these to disk itself: a GGUF file, an MLX
1295            // safetensors repo, a whisper.cpp ggml `.bin`.
1296            ModelSource::Local { .. }
1297            | ModelSource::Mlx { .. }
1298            | ModelSource::WhisperCpp { .. }
1299            | ModelSource::ManagedVllmMlx { .. } => true,
1300            // The OS owns the voices / the weights — nothing to download.
1301            ModelSource::WindowsSpeech {} | ModelSource::AppleFoundationModels { .. } => false,
1302            // Someone else holds the weights: a local server (vLLM-MLX,
1303            // Ollama), a remote API, or a host-registered runner.
1304            ModelSource::VllmMlx { .. }
1305            | ModelSource::Ollama { .. }
1306            | ModelSource::RemoteApi { .. }
1307            | ModelSource::CodexCli { .. }
1308            | ModelSource::Proprietary { .. }
1309            | ModelSource::Delegated { .. } => false,
1310        }
1311    }
1312
1313    /// Whether a CAR-downloadable artifact is physically present now.
1314    /// General runtime availability and OS/server-owned weights are not an
1315    /// installation claim.
1316    pub fn has_installed_weights(&self) -> bool {
1317        self.downloads_weights() && self.weights_ready
1318    }
1319
1320    /// Whether CAR decodes this model **in its own process**, token by token,
1321    /// through the shared decode loop.
1322    ///
1323    /// Narrower than [`is_local`](Self::is_local) on purpose. `is_local` also
1324    /// covers CAR-managed vLLM-MLX and the speech backends. Those do not spend
1325    /// an output-token budget as this process's wall clock, so they must keep
1326    /// the remote treatment. Getting that distinction wrong takes the
1327    /// anti-truncation budget away from vLLM-MLX, which is the documented way
1328    /// to get structured tool calls out of a local model. (car#851)
1329    pub fn decodes_in_process(&self) -> bool {
1330        matches!(
1331            self.source,
1332            ModelSource::Local { .. } | ModelSource::Mlx { .. }
1333        )
1334    }
1335
1336    /// Check if this model delegates inference to a host-registered
1337    /// runner (closes Parslee-ai/car-releases#24).
1338    pub fn is_delegated(&self) -> bool {
1339        matches!(self.source, ModelSource::Delegated { .. })
1340    }
1341
1342    /// Check if this model runs one tool-free turn through `codex exec`.
1343    pub fn is_codex_cli(&self) -> bool {
1344        matches!(self.source, ModelSource::CodexCli { .. })
1345    }
1346
1347    /// Check if this model uses the MLX backend.
1348    pub fn is_mlx(&self) -> bool {
1349        matches!(self.source, ModelSource::Mlx { .. })
1350    }
1351
1352    /// Check if this model routes to Apple's on-device FoundationModels
1353    /// framework. True only for `ModelSource::AppleFoundationModels`;
1354    /// callers must still verify runtime availability before dispatch
1355    /// (the schema can describe the model on any host, but execution
1356    /// requires macOS 26+ on Apple Silicon).
1357    pub fn is_foundation_models(&self) -> bool {
1358        matches!(self.source, ModelSource::AppleFoundationModels { .. })
1359    }
1360
1361    /// Whether the operating system owns the implementation and its memory.
1362    /// These rows have no CAR-downloadable artifact and no local-model memory
1363    /// figure to compare with the admission budget.
1364    pub fn is_os_provided(&self) -> bool {
1365        matches!(
1366            self.source,
1367            ModelSource::WindowsSpeech {} | ModelSource::AppleFoundationModels { .. }
1368        )
1369    }
1370
1371    /// Check if this model uses vLLM-MLX backend.
1372    pub fn is_vllm_mlx(&self) -> bool {
1373        matches!(
1374            self.source,
1375            ModelSource::VllmMlx { .. } | ModelSource::ManagedVllmMlx { .. }
1376        )
1377    }
1378
1379    /// Whether CAR, rather than an independently managed endpoint, owns the
1380    /// vLLM-MLX child process and its physical weight allocation. The existing
1381    /// HTTP `VllmMlx` contract remains external/server-owned. A supervised
1382    /// source must opt in explicitly and provide an already-installed local
1383    /// model path in `model_name`; it is then replaced with a loopback HTTP
1384    /// endpoint only after admission and a successful readiness ACK.
1385    pub fn is_car_managed_vllm_mlx(&self) -> bool {
1386        matches!(self.source, ModelSource::ManagedVllmMlx { .. })
1387    }
1388
1389    /// Whether a CAR/OS-owned model can only run on Apple Silicon (Metal).
1390    /// External vLLM-MLX endpoints own their hardware and are not constrained
1391    /// by the client machine's accelerator.
1392    pub fn requires_apple_silicon(&self) -> bool {
1393        self.is_mlx() || self.is_car_managed_vllm_mlx() || self.is_foundation_models()
1394    }
1395
1396    /// Check if this model is remote (requires API call).
1397    pub fn is_remote(&self) -> bool {
1398        matches!(
1399            self.source,
1400            ModelSource::RemoteApi { .. }
1401                | ModelSource::CodexCli { .. }
1402                | ModelSource::Proprietary { .. }
1403                | ModelSource::VllmMlx { .. }
1404        )
1405    }
1406
1407    /// Collect all API key env var names for this model (primary + extras).
1408    /// Returns empty vec for non-remote models.
1409    pub fn all_api_key_envs(&self) -> Vec<String> {
1410        match &self.source {
1411            ModelSource::RemoteApi {
1412                api_key_env,
1413                api_key_envs,
1414                ..
1415            } => {
1416                let mut all = vec![api_key_env.clone()];
1417                all.extend(api_key_envs.iter().cloned());
1418                all
1419            }
1420            ModelSource::Proprietary {
1421                auth: ProprietaryAuth::ApiKeyEnv { env_var },
1422                ..
1423            }
1424            | ModelSource::Proprietary {
1425                auth: ProprietaryAuth::BearerTokenEnv { env_var },
1426                ..
1427            } => vec![env_var.clone()],
1428            _ => vec![],
1429        }
1430    }
1431
1432    /// Get the size in MB (from cost model or 0 if unknown).
1433    pub fn size_mb(&self) -> u64 {
1434        self.cost.size_mb.unwrap_or(0)
1435    }
1436
1437    /// Get the RAM requirement in MB (from cost model, falls back to size_mb).
1438    pub fn ram_mb(&self) -> u64 {
1439        self.cost.ram_mb.unwrap_or_else(|| self.size_mb())
1440    }
1441
1442    /// Estimated cost per 1K output tokens in USD. Returns 0.0 for local models.
1443    pub fn cost_per_1k_output(&self) -> f64 {
1444        self.cost.output_per_mtok.map(|c| c / 1000.0).unwrap_or(0.0)
1445    }
1446
1447    /// The per-turn output-token ceiling to use when the caller didn't
1448    /// specify one. Prefers the registry-declared `max_output_tokens`;
1449    /// otherwise derives a quarter of the context window, clamped to a
1450    /// sane [4096, 32768] band so a 1M-context model doesn't request a
1451    /// 250K-token response the API rejects and a tiny 8K model doesn't
1452    /// get an absurdly small ceiling. (Registry value first, computed
1453    /// fallback second — mirrors a provider lookup with a derived default.)
1454    pub fn effective_max_output(&self) -> usize {
1455        self.max_output_tokens
1456            .unwrap_or_else(|| (self.context_length / 4).clamp(4096, 32_768))
1457    }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462    use super::*;
1463
1464    fn sample_local() -> ModelSchema {
1465        ModelSchema {
1466            id: "qwen/qwen3-4b:q4_k_m".into(),
1467            name: "Qwen3-4B".into(),
1468            provider: "qwen".into(),
1469            family: "qwen3".into(),
1470            version: "1.0".into(),
1471            capabilities: vec![ModelCapability::Generate, ModelCapability::Code],
1472            context_length: 32768,
1473            max_output_tokens: None,
1474            param_count: "4B".into(),
1475            quantization: Some(Quantization::parse("Q4_K_M")),
1476            performance: PerformanceEnvelope {
1477                tokens_per_second: Some(45.0),
1478                ..Default::default()
1479            },
1480            cost: CostModel {
1481                size_mb: Some(2500),
1482                ram_mb: Some(2500),
1483                ..Default::default()
1484            },
1485            source: ModelSource::Local {
1486                hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
1487                hf_filename: "Qwen3-4B-Q4_K_M.gguf".into(),
1488                tokenizer_repo: "Qwen/Qwen3-4B".into(),
1489            },
1490            tags: vec!["code".into(), "fast".into()],
1491            supported_params: vec![],
1492            public_benchmarks: vec![],
1493            trust_tier: TrustTier::Curated,
1494            deprecated: false,
1495            available: false,
1496            weights_ready: false,
1497        }
1498    }
1499
1500    fn sample_remote() -> ModelSchema {
1501        ModelSchema {
1502            id: "anthropic/claude-sonnet-4-6:latest".into(),
1503            name: "Claude Sonnet 4.6".into(),
1504            provider: "anthropic".into(),
1505            family: "claude-4".into(),
1506            version: "latest".into(),
1507            capabilities: vec![
1508                ModelCapability::Generate,
1509                ModelCapability::Code,
1510                ModelCapability::Reasoning,
1511                ModelCapability::ToolUse,
1512                ModelCapability::Vision,
1513            ],
1514            context_length: 200000,
1515            max_output_tokens: None,
1516            param_count: String::new(),
1517            quantization: None,
1518            performance: PerformanceEnvelope {
1519                latency_p50_ms: Some(2000),
1520                latency_p99_ms: Some(8000),
1521                tokens_per_second: Some(80.0),
1522            },
1523            cost: CostModel {
1524                input_per_mtok: Some(3.0),
1525                output_per_mtok: Some(15.0),
1526                ..Default::default()
1527            },
1528            source: ModelSource::RemoteApi {
1529                endpoint: "https://api.anthropic.com/v1/messages".into(),
1530                api_key_env: "ANTHROPIC_API_KEY".into(),
1531                api_key_envs: vec![],
1532                api_version: Some("2023-06-01".into()),
1533                protocol: ApiProtocol::Anthropic,
1534            },
1535            tags: vec!["reasoning".into(), "tool_use".into()],
1536            supported_params: vec![],
1537            public_benchmarks: vec![],
1538            trust_tier: TrustTier::Curated,
1539            deprecated: false,
1540            available: false,
1541            weights_ready: false,
1542        }
1543    }
1544
1545    #[test]
1546    fn capabilities() {
1547        let m = sample_local();
1548        assert!(m.has_capability(ModelCapability::Code));
1549        assert!(!m.has_capability(ModelCapability::Vision));
1550    }
1551
1552    #[test]
1553    fn local_vs_remote() {
1554        assert!(sample_local().is_local());
1555        assert!(!sample_local().is_remote());
1556        assert!(sample_remote().is_remote());
1557        assert!(!sample_remote().is_local());
1558        let codex = ModelSchema {
1559            source: ModelSource::CodexCli {
1560                model: "gpt-5.6-sol:high".into(),
1561            },
1562            ..sample_local()
1563        };
1564        assert!(codex.is_remote());
1565        assert!(!codex.is_local());
1566    }
1567
1568    #[test]
1569    fn vllm_ownership_drives_local_remote_and_apple_predicates() {
1570        let external = ModelSchema {
1571            source: ModelSource::VllmMlx {
1572                endpoint: "https://gpu-owner.example/v1".into(),
1573                model_name: "owner/runtime-model".into(),
1574            },
1575            ..sample_local()
1576        };
1577        assert!(!external.is_local());
1578        assert!(external.is_remote());
1579        assert!(!external.requires_apple_silicon());
1580
1581        let managed = ModelSchema {
1582            source: ModelSource::ManagedVllmMlx {
1583                hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
1584                hf_weight_file: None,
1585            },
1586            ..sample_local()
1587        };
1588        assert!(managed.is_local());
1589        assert!(!managed.is_remote());
1590        assert!(managed.requires_apple_silicon());
1591    }
1592
1593    #[test]
1594    fn cost() {
1595        let local = sample_local();
1596        assert_eq!(local.cost_per_1k_output(), 0.0);
1597
1598        let remote = sample_remote();
1599        assert!(remote.cost_per_1k_output() > 0.0);
1600    }
1601
1602    #[test]
1603    fn serde_roundtrip() {
1604        let local = sample_local();
1605        let json = serde_json::to_string(&local).unwrap();
1606        let parsed: ModelSchema = serde_json::from_str(&json).unwrap();
1607        assert_eq!(parsed.id, local.id);
1608        assert_eq!(parsed.capabilities, local.capabilities);
1609
1610        let remote = sample_remote();
1611        let json = serde_json::to_string(&remote).unwrap();
1612        let parsed: ModelSchema = serde_json::from_str(&json).unwrap();
1613        assert_eq!(parsed.id, remote.id);
1614        // available is skip-serialized, defaults to false
1615        assert!(!parsed.available);
1616    }
1617
1618    #[test]
1619    fn managed_vllm_source_is_versioned_without_reinterpreting_legacy_vllm_json() {
1620        let legacy: ModelSource = serde_json::from_str(
1621            r#"{"type":"vllm_mlx","endpoint":"http://localhost:8000","model_name":"legacy"}"#,
1622        )
1623        .unwrap();
1624        assert!(matches!(
1625            legacy,
1626            ModelSource::VllmMlx {
1627                endpoint,
1628                model_name
1629            } if endpoint == "http://localhost:8000" && model_name == "legacy"
1630        ));
1631
1632        let managed = ModelSource::ManagedVllmMlx {
1633            hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
1634            hf_weight_file: Some("model.safetensors".into()),
1635        };
1636        let encoded = serde_json::to_string(&managed).unwrap();
1637        assert!(encoded.contains(r#""type":"managed_vllm_mlx""#));
1638        assert!(matches!(
1639            serde_json::from_str::<ModelSource>(&encoded).unwrap(),
1640            ModelSource::ManagedVllmMlx { .. }
1641        ));
1642    }
1643
1644    #[test]
1645    fn vendor_refuses_to_claim_a_repackager_or_a_guess() {
1646        // A HuggingFace-derived row's `provider` is the repo ORG that uploaded
1647        // it, so `unsloth/Qwen3` and `mlx-community/Qwen3` — one checkpoint,
1648        // two repacks — would otherwise read as two independent vendors. That
1649        // is a false independence claim produced silently, which is the exact
1650        // failure a vendor check exists to prevent.
1651        let mut m = sample_remote();
1652        m.provider = "mlx-community".into();
1653        m.trust_tier = TrustTier::Community;
1654        assert_eq!(m.vendor(), None);
1655
1656        // A project-vetted remote row IS authoritative.
1657        m.provider = "openai".into();
1658        m.trust_tier = TrustTier::Curated;
1659        assert_eq!(m.vendor(), Some("openai"));
1660
1661        // A local model is served by the operator's own machine; whoever
1662        // uploaded the weights is not an independently-failing organization.
1663        assert_eq!(sample_local().vendor(), None);
1664    }
1665
1666    #[test]
1667    fn trust_tier_and_deprecated_default_when_absent() {
1668        // Pre-existing ~/.car/models.json configs omit the new fields.
1669        // They must deserialize to Curated / not-deprecated, not error.
1670        let json = serde_json::to_string(&sample_local()).unwrap();
1671        let stripped = json
1672            .replace(",\"trust_tier\":\"curated\"", "")
1673            .replace(",\"deprecated\":false", "");
1674        let parsed: ModelSchema = serde_json::from_str(&stripped).unwrap();
1675        assert_eq!(parsed.trust_tier, TrustTier::Curated);
1676        assert!(!parsed.deprecated);
1677    }
1678
1679    #[test]
1680    fn trust_tier_serializes_snake_case() {
1681        assert_eq!(
1682            serde_json::to_string(&TrustTier::Community).unwrap(),
1683            "\"community\""
1684        );
1685        assert_eq!(TrustTier::default(), TrustTier::Curated);
1686    }
1687
1688    #[test]
1689    fn requires_apple_silicon_only_for_metal_backends() {
1690        // GGUF/Candle local and remote models run anywhere CAR builds for.
1691        assert!(!sample_local().requires_apple_silicon());
1692        assert!(!sample_remote().requires_apple_silicon());
1693
1694        let mlx = ModelSchema {
1695            source: ModelSource::Mlx {
1696                hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
1697                hf_weight_file: None,
1698            },
1699            ..sample_local()
1700        };
1701        assert!(mlx.requires_apple_silicon());
1702
1703        // Only CAR-managed vLLM-MLX and Apple FoundationModels are Metal-bound.
1704        // A raw endpoint is external and its owner's hardware is opaque to CAR.
1705        let managed_vllm = ModelSchema {
1706            source: ModelSource::ManagedVllmMlx {
1707                hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
1708                hf_weight_file: None,
1709            },
1710            ..sample_local()
1711        };
1712        assert!(managed_vllm.requires_apple_silicon());
1713
1714        let external_vllm = ModelSchema {
1715            source: ModelSource::VllmMlx {
1716                endpoint: "https://gpu-owner.example/v1".into(),
1717                model_name: "mlx-community/Qwen3-4B-4bit".into(),
1718            },
1719            ..sample_local()
1720        };
1721        assert!(!external_vllm.requires_apple_silicon());
1722
1723        let foundation = ModelSchema {
1724            source: ModelSource::AppleFoundationModels { use_case: None },
1725            ..sample_local()
1726        };
1727        assert!(foundation.requires_apple_silicon());
1728    }
1729
1730    fn priced(
1731        input: Option<f64>,
1732        output: Option<f64>,
1733        cache_read: Option<f64>,
1734        cache_write: Option<f64>,
1735    ) -> CostModel {
1736        CostModel {
1737            input_per_mtok: input,
1738            output_per_mtok: output,
1739            cache_read_input_per_mtok: cache_read,
1740            cache_write_input_per_mtok: cache_write,
1741            ..Default::default()
1742        }
1743    }
1744
1745    #[test]
1746    fn an_undeclared_cache_rate_is_bounded_by_the_input_rate_not_billed_at_zero() {
1747        // The real shape this exists for: qwen3.5-plus declares input and
1748        // output but no cache-read rate. 1M cache-read tokens must not be free.
1749        let cost = priced(Some(0.26), Some(1.56), None, None);
1750        let bounded = cost
1751            .estimated_usd_bounded(Some(1_000_000), 0, 0, 1_000_000, 0)
1752            .expect("a model with input+output rates is priceable");
1753        assert!(bounded.may_overstate, "substituted rate can only be high");
1754        assert!(!bounded.may_understate);
1755        assert_eq!(bounded.marker(), "≤");
1756        assert!(
1757            (bounded.usd - 0.26).abs() < 1e-9,
1758            "cache reads fall back to the $0.26/MTok input rate, got {}",
1759            bounded.usd
1760        );
1761
1762        // The routing-score path still zero-fills, deliberately and untouched.
1763        assert_eq!(cost.estimated_usd(1_000_000, 0, 1_000_000, 0), 0.0);
1764    }
1765
1766    #[test]
1767    fn an_undeclared_cache_write_rate_claims_no_bound_it_cannot_keep() {
1768        // The shipped `claude-opus-4.8` shape with its cache-write rate
1769        // omitted. Anthropic's cache write is a 1.25x SURCHARGE (6.25 against
1770        // 5.0 input), and OpenRouter's 1h-TTL rate is 2x — so pricing the
1771        // bucket at the input rate is a FLOOR, and the `≤` a blanket
1772        // "cache is always cheaper" rule would have produced is a false
1773        // ceiling over a true cost of $6.25, or $10.00 at the 1h rate.
1774        let cost = priced(Some(5.0), Some(25.0), Some(0.5), None);
1775        let bounded = cost
1776            .estimated_usd_bounded(Some(1_000_000), 0, 0, 0, 1_000_000)
1777            .expect("input and output rates are published");
1778        assert!((bounded.usd - 5.0).abs() < 1e-9, "got {}", bounded.usd);
1779        assert!(
1780            bounded.may_understate,
1781            "a cache-write surcharge can exceed the input rate"
1782        );
1783        assert_ne!(bounded.marker(), "≤", "must not claim a ceiling it fails");
1784        assert_eq!(bounded.marker(), "~", "sign is unknown, so neither bound");
1785        // Both real cache-write rates this shape could carry sit ABOVE the
1786        // substituted figure — which is exactly why `≤` would have been a
1787        // false ceiling. Assert the tighter of the two; it implies the looser,
1788        // and spelling out both comparisons is a `redundant_comparisons` lint.
1789        let at_anthropics_1_25x: f64 = 1_000_000.0 * 6.25 / 1e6;
1790        let at_openrouters_1h_2x: f64 = 1_000_000.0 * 10.0 / 1e6;
1791        assert!(bounded.usd < at_anthropics_1_25x.min(at_openrouters_1h_2x));
1792
1793        // Declaring the rate makes it exact — the flag tracks substitution,
1794        // not the mere presence of cache-write tokens.
1795        let declared = priced(Some(5.0), Some(25.0), Some(0.5), Some(6.25))
1796            .estimated_usd_bounded(Some(1_000_000), 0, 0, 0, 1_000_000)
1797            .unwrap();
1798        assert!(declared.is_exact());
1799        assert!((declared.usd - 6.25).abs() < 1e-9);
1800
1801        // The same omission on the cache READ side DOES keep its ceiling —
1802        // the two directions are not shared. Note the rate must be missing for
1803        // a substitution to happen at all.
1804        let read = priced(Some(5.0), Some(25.0), None, Some(6.25))
1805            .estimated_usd_bounded(Some(1_000_000), 0, 0, 1_000_000, 0)
1806            .unwrap();
1807        assert_eq!(read.marker(), "≤");
1808        assert!((read.usd - 5.0).abs() < 1e-9);
1809        // A real cache-read rate is a discount, so the ceiling holds.
1810        assert!(read.usd > 1_000_000.0 * 0.5 / 1e6);
1811    }
1812
1813    #[test]
1814    fn a_declared_cache_rate_is_exact_and_never_flagged() {
1815        let cost = priced(Some(5.0), Some(25.0), Some(0.5), Some(6.25));
1816        let bounded = cost
1817            .estimated_usd_bounded(Some(1_600_000), 1_000_000, 200_000, 500_000, 100_000)
1818            .expect("fully rated");
1819        assert!(bounded.is_exact(), "nothing was substituted or unresolved");
1820        assert_eq!(bounded.marker(), "");
1821        // 1M uncached x 5 + 200k x 25 + 500k x 0.5 + 100k x 6.25, per MTok.
1822        assert!((bounded.usd - 10.875).abs() < 1e-9, "got {}", bounded.usd);
1823        // Identical to the router's figure when every rate is published.
1824        assert!(
1825            (bounded.usd - cost.estimated_usd(1_600_000, 200_000, 500_000, 100_000)).abs() < 1e-9
1826        );
1827    }
1828
1829    #[test]
1830    fn an_unbounded_bucket_refuses_rather_than_understating() {
1831        // Output has no safe substitute — it is normally the dearer side, so
1832        // pricing it at the input rate would UNDER-state. Refuse instead.
1833        let cost = priced(Some(1.0), None, None, None);
1834        assert_eq!(
1835            cost.estimated_usd_bounded(Some(1_000), 1_000, 1_000, 0, 0),
1836            None
1837        );
1838        // With no output tokens the same model is priceable and exact.
1839        let bounded = cost
1840            .estimated_usd_bounded(Some(1_000), 1_000, 0, 0, 0)
1841            .unwrap();
1842        assert!(bounded.is_exact());
1843    }
1844
1845    #[test]
1846    fn no_rate_card_is_unpriced_rather_than_free() {
1847        let cost = CostModel::default();
1848        assert_eq!(
1849            cost.estimated_usd_bounded(Some(1_000), 1_000, 1_000, 0, 0),
1850            None
1851        );
1852        // And a zero-usage priced model is genuinely free, not unpriced.
1853        let free = priced(Some(0.0), Some(0.0), None, None)
1854            .estimated_usd_bounded(Some(0), 0, 0, 0, 0)
1855            .expect("a declared zero rate card is priced");
1856        assert_eq!(free.usd, 0.0);
1857        assert!(free.is_exact());
1858    }
1859
1860    fn tiered() -> CostModel {
1861        CostModel {
1862            input_per_mtok: Some(2.5),
1863            output_per_mtok: Some(15.0),
1864            cache_read_input_per_mtok: Some(0.25),
1865            pricing_tiers: vec![TokenPricingTier {
1866                min_prompt_tokens: 272_000,
1867                prices: TokenPrices {
1868                    input_per_mtok: Some(5.0),
1869                    output_per_mtok: Some(22.5),
1870                    cache_read_input_per_mtok: Some(0.5),
1871                    cache_write_input_per_mtok: None,
1872                },
1873            }],
1874            ..Default::default()
1875        }
1876    }
1877
1878    #[test]
1879    fn a_known_prompt_size_resolves_the_tier_exactly() {
1880        let cost = tiered();
1881        let below = cost
1882            .estimated_usd_bounded(Some(271_999), 271_999, 0, 0, 0)
1883            .unwrap();
1884        assert!(below.is_exact());
1885        assert!((below.usd - 271_999.0 * 2.5 / 1e6).abs() < 1e-9);
1886
1887        let above = cost
1888            .estimated_usd_bounded(Some(272_000), 272_000, 0, 0, 0)
1889            .unwrap();
1890        assert!(above.is_exact());
1891        assert!((above.usd - 272_000.0 * 5.0 / 1e6).abs() < 1e-9);
1892    }
1893
1894    #[test]
1895    fn a_lifetime_aggregate_uses_base_rates_and_admits_it_may_be_low() {
1896        // Thirty 10K-token requests. Their SUM crosses the 272K threshold that
1897        // no single request came near — the double-charging bug. `None` says
1898        // "boundaries lost", so base rates apply and the figure is marked.
1899        let cost = tiered();
1900        let aggregate = cost.estimated_usd_bounded(None, 300_000, 0, 0, 0).unwrap();
1901        assert!(
1902            (aggregate.usd - 300_000.0 * 2.5 / 1e6).abs() < 1e-9,
1903            "must use the $2.50 base rate, got {}",
1904            aggregate.usd
1905        );
1906        assert!(aggregate.may_understate, "a dearer tier may apply");
1907        assert!(!aggregate.may_overstate);
1908        assert_eq!(aggregate.marker(), "≥");
1909
1910        // What the bug looked like: summed tokens passed as a real prompt size
1911        // price at the high-context rate — exactly double, and unmarked.
1912        let bug = cost
1913            .estimated_usd_bounded(Some(300_000), 300_000, 0, 0, 0)
1914            .unwrap();
1915        assert!((bug.usd - 2.0 * aggregate.usd).abs() < 1e-9);
1916        assert!(bug.is_exact(), "and it would have claimed to be exact");
1917    }
1918
1919    #[test]
1920    fn a_cheaper_tier_flags_the_aggregate_as_possibly_high_instead() {
1921        // Direction is derived from the tiers, not assumed. A volume DISCOUNT
1922        // makes the base-rate figure too high, not too low.
1923        let cost = CostModel {
1924            input_per_mtok: Some(2.0),
1925            output_per_mtok: Some(10.0),
1926            pricing_tiers: vec![TokenPricingTier {
1927                min_prompt_tokens: 100_000,
1928                prices: TokenPrices {
1929                    input_per_mtok: Some(1.0),
1930                    ..Default::default()
1931                },
1932            }],
1933            ..Default::default()
1934        };
1935        let aggregate = cost.estimated_usd_bounded(None, 500_000, 0, 0, 0).unwrap();
1936        assert!(aggregate.may_overstate);
1937        assert!(!aggregate.may_understate);
1938        assert_eq!(aggregate.marker(), "≤");
1939    }
1940
1941    #[test]
1942    fn both_directions_at_once_claims_neither_bound() {
1943        // Unrated cache bucket (can be high) plus an unresolved dearer tier
1944        // (can be low). Neither bound survives, so the figure is just an
1945        // estimate and must not wear a `≤` it cannot honour.
1946        let cost = CostModel {
1947            input_per_mtok: Some(2.0),
1948            output_per_mtok: Some(10.0),
1949            pricing_tiers: vec![TokenPricingTier {
1950                min_prompt_tokens: 100_000,
1951                prices: TokenPrices {
1952                    input_per_mtok: Some(4.0),
1953                    ..Default::default()
1954                },
1955            }],
1956            ..Default::default()
1957        };
1958        let aggregate = cost.estimated_usd_bounded(None, 0, 0, 500_000, 0).unwrap();
1959        assert!(aggregate.may_overstate && aggregate.may_understate);
1960        assert_eq!(aggregate.marker(), "~");
1961    }
1962
1963    /// Parslee-ai/car#894 (follow-up): "runs here" and "has weights to fetch"
1964    /// are different questions, and `is_local` answers only the first. Three
1965    /// `is_local` sources download nothing — the CLI must not offer an
1966    /// install status for them.
1967    #[test]
1968    fn downloads_weights_is_true_only_for_sources_car_fetches() {
1969        let mut schema = sample_local();
1970
1971        // CAR downloads these itself.
1972        schema.source = ModelSource::Local {
1973            hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
1974            hf_filename: "Qwen3-4B-Q4_K_M.gguf".into(),
1975            tokenizer_repo: "Qwen/Qwen3-4B".into(),
1976        };
1977        assert!(schema.downloads_weights(), "GGUF weights are downloaded");
1978
1979        schema.source = ModelSource::Mlx {
1980            hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
1981            hf_weight_file: None,
1982        };
1983        assert!(schema.downloads_weights(), "MLX weights are downloaded");
1984
1985        schema.source = ModelSource::WhisperCpp {
1986            model: "large-v3-turbo-q5_0".into(),
1987        };
1988        assert!(
1989            schema.downloads_weights(),
1990            "the whisper.cpp ggml bin is downloaded"
1991        );
1992
1993        // The OS owns these — there is nothing to install. `WindowsSpeech` is
1994        // the row that rendered `INSTALLED yes` against `car doctor`'s
1995        // `Models: none installed`.
1996        schema.source = ModelSource::WindowsSpeech {};
1997        assert!(
1998            !schema.downloads_weights(),
1999            "WinRT speech synthesis has no weights to download"
2000        );
2001
2002        schema.source = ModelSource::AppleFoundationModels { use_case: None };
2003        assert!(
2004            !schema.downloads_weights(),
2005            "Apple FoundationModels weights belong to the OS"
2006        );
2007
2008        // Someone else holds the weights.
2009        schema.source = ModelSource::VllmMlx {
2010            endpoint: "http://localhost:8000".into(),
2011            model_name: "mlx-community/Qwen3-4B-4bit".into(),
2012        };
2013        assert!(
2014            !schema.downloads_weights(),
2015            "the vLLM-MLX server owns its own weights"
2016        );
2017
2018        schema.source = ModelSource::ManagedVllmMlx {
2019            hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
2020            hf_weight_file: None,
2021        };
2022        assert!(
2023            schema.downloads_weights(),
2024            "the explicitly managed vLLM-MLX contract is CAR-owned"
2025        );
2026
2027        schema.source = ModelSource::Ollama {
2028            model_tag: "qwen3:4b".into(),
2029            host: default_ollama_host(),
2030        };
2031        assert!(!schema.downloads_weights(), "Ollama owns its own weights");
2032
2033        schema.source = sample_remote().source;
2034        assert!(
2035            !schema.downloads_weights(),
2036            "a remote API has no weights on this machine"
2037        );
2038
2039        schema.source = ModelSource::Delegated { hint: None };
2040        assert!(
2041            !schema.downloads_weights(),
2042            "a host-registered runner owns its own weights"
2043        );
2044    }
2045
2046    /// `is_local` is the predicate the CLI used to reach for. These OS-owned
2047    /// sources are where the two answers diverge, which is why a
2048    /// separate predicate exists rather than a reuse of `is_local`.
2049    #[test]
2050    fn downloads_weights_differs_from_is_local_on_os_owned_sources() {
2051        let mut schema = sample_local();
2052        for source in [
2053            ModelSource::WindowsSpeech {},
2054            ModelSource::AppleFoundationModels { use_case: None },
2055        ] {
2056            schema.source = source;
2057            assert!(
2058                schema.is_local(),
2059                "this source runs on-device: {:?}",
2060                schema.source
2061            );
2062            assert!(
2063                !schema.downloads_weights(),
2064                "…but CAR downloads nothing for it: {:?}",
2065                schema.source
2066            );
2067        }
2068    }
2069}
2070
2071#[cfg(test)]
2072mod quantization_tests {
2073    use super::*;
2074
2075    /// Every distinct label the built-in catalog ships, the hyphenated form
2076    /// `registry.rs` writes into auto-discovered entries, and the formats that
2077    /// review found misclassified.
2078    #[test]
2079    fn classifies_known_labels() {
2080        let cases: &[(&str, Option<u8>, QuantScheme)] = &[
2081            ("4bit", Some(4), QuantScheme::AffineGroupInt),
2082            ("6bit", Some(6), QuantScheme::AffineGroupInt),
2083            ("3bit", Some(3), QuantScheme::AffineGroupInt),
2084            ("5bit", Some(5), QuantScheme::AffineGroupInt),
2085            // registry.rs emits the hyphen; users already have it on disk.
2086            ("4-bit", Some(4), QuantScheme::AffineGroupInt),
2087            ("mxfp8", Some(8), QuantScheme::BlockScaledFloat),
2088            ("mxfp4", Some(4), QuantScheme::BlockScaledFloat),
2089            ("Q4_K_M", Some(4), QuantScheme::KQuantMixed),
2090            ("Q5_K_S", Some(5), QuantScheme::KQuantMixed),
2091            ("IQ4_XS", Some(4), QuantScheme::KQuantMixed),
2092            ("TQ1_0", Some(1), QuantScheme::KQuantMixed),
2093            ("Q8_0", Some(8), QuantScheme::RtnBlock),
2094            ("q5_0", Some(5), QuantScheme::RtnBlock),
2095            // aarch64 repack quants: the suffix is a prefix match, not equality.
2096            ("Q4_0_4_4", Some(4), QuantScheme::RtnBlock),
2097            ("Q4_0_8_8", Some(4), QuantScheme::RtnBlock),
2098            // A zero-padded width must slice by digits consumed, not by the
2099            // decimal length of the parsed number.
2100            ("q08_0", Some(8), QuantScheme::RtnBlock),
2101            ("bf16", Some(16), QuantScheme::Unquantized),
2102            ("F16", Some(16), QuantScheme::Unquantized),
2103            ("f32", Some(32), QuantScheme::Unquantized),
2104            // Spelled like a quant, but affine group quant has no such width.
2105            ("16bit", Some(16), QuantScheme::Unquantized),
2106            ("32bit", Some(32), QuantScheme::Unquantized),
2107            // An 8-bit float IS quantized — just not attributable from this
2108            // label alone. Calling it full precision was the bug.
2109            ("fp8", Some(8), QuantScheme::Unknown),
2110            // Names a width and no producer.
2111            ("Q4", Some(4), QuantScheme::Unknown),
2112        ];
2113        for (label, bits, scheme) in cases {
2114            let q = Quantization::parse(label);
2115            assert_eq!(q.bits, *bits, "bits for {label}");
2116            assert_eq!(q.scheme, *scheme, "scheme for {label}");
2117            assert_eq!(q.label, *label, "label must survive verbatim");
2118        }
2119    }
2120
2121    /// Labels that identify nothing must say so rather than guess.
2122    #[test]
2123    fn refuses_to_guess() {
2124        for label in [
2125            "",                 // nobody said; not a claim of full precision
2126            "mxfp",             // MX family, no width
2127            "Q256_K",           // width overflows u8
2128            "q0_0",             // a zero-bit quantization is not a thing
2129            "awq-marlin-w4a16", // real format, unknown to this parser
2130        ] {
2131            let q = Quantization::parse(label);
2132            assert_eq!(q.scheme, QuantScheme::Unknown, "scheme for {label:?}");
2133            assert_eq!(q.bits, None, "bits for {label:?}");
2134            assert_eq!(q.label, label, "label for {label:?}");
2135        }
2136    }
2137
2138    /// The distinction the free-text field could not express: same width,
2139    /// different algorithm, different loader.
2140    #[test]
2141    fn same_width_different_scheme() {
2142        let affine = Quantization::parse("4bit");
2143        let kquant = Quantization::parse("Q4_K_M");
2144        let mx = Quantization::parse("mxfp4");
2145        assert_eq!(affine.bits, kquant.bits);
2146        assert_eq!(affine.bits, mx.bits);
2147        assert_ne!(affine.scheme, kquant.scheme);
2148        assert_ne!(affine.scheme, mx.scheme);
2149        // And within GGUF, k-quant is not round-to-nearest.
2150        assert_ne!(
2151            Quantization::parse("Q5_K_S").scheme,
2152            Quantization::parse("q5_0").scheme
2153        );
2154    }
2155
2156    /// The scheme names a numeric format, never a container or an engine.
2157    /// whisper.cpp ships `q5_0` ggml checkpoints that no GGUF text path can
2158    /// load; a container-named variant would assert otherwise.
2159    #[test]
2160    fn scheme_does_not_imply_an_engine() {
2161        let whisper = Quantization::parse("q5_0");
2162        let llama = Quantization::parse("Q5_0");
2163        assert_eq!(whisper.scheme, llama.scheme);
2164        assert_eq!(whisper.scheme, QuantScheme::RtnBlock);
2165    }
2166
2167    #[test]
2168    fn mlx_config_block_survives_ingest() {
2169        let q = Quantization::from_mlx_config(Some(8), Some(32), Some("mxfp8")).unwrap();
2170        assert_eq!(q.bits, Some(8));
2171        assert_eq!(q.group_size, Some(32));
2172        assert_eq!(q.scheme, QuantScheme::BlockScaledFloat);
2173
2174        // Absent `mode` means affine — MLX's default, not unknown.
2175        let affine = Quantization::from_mlx_config(Some(4), Some(64), None).unwrap();
2176        assert_eq!(affine.scheme, QuantScheme::AffineGroupInt);
2177        assert_eq!(affine.group_size, Some(64));
2178        assert_eq!(affine.label, "4bit");
2179    }
2180
2181    /// An empty or absent block must not become a claim of affine group quant.
2182    /// The field it replaced returned nothing here, and asserting a scheme is
2183    /// exactly the error a router acting on `scheme` would inherit.
2184    #[test]
2185    fn empty_mlx_block_asserts_nothing() {
2186        assert!(Quantization::from_mlx_config(None, None, None).is_none());
2187    }
2188
2189    #[test]
2190    fn deserializes_legacy_bare_string() {
2191        let q: Quantization = serde_json::from_str(r#""Q4_K_M""#).unwrap();
2192        assert_eq!(q.scheme, QuantScheme::KQuantMixed);
2193        assert_eq!(q.bits, Some(4));
2194        assert_eq!(q.label, "Q4_K_M");
2195    }
2196
2197    #[test]
2198    fn deserializes_structured_object() {
2199        let q: Quantization = serde_json::from_str(
2200            r#"{"bits":4,"scheme":"affine_group_int","group_size":64,"label":"4bit"}"#,
2201        )
2202        .unwrap();
2203        assert_eq!(q.group_size, Some(64));
2204        assert_eq!(q.scheme, QuantScheme::AffineGroupInt);
2205    }
2206
2207    /// A partial object recovers from its label rather than defaulting to
2208    /// Unknown — the same reason the bare string is still accepted.
2209    #[test]
2210    fn partial_object_recovers_from_label() {
2211        let q: Quantization = serde_json::from_str(r#"{"label":"Q8_0"}"#).unwrap();
2212        assert_eq!(q.scheme, QuantScheme::RtnBlock);
2213        assert_eq!(q.bits, Some(8));
2214    }
2215
2216    /// A malformed object must be an error, not a row that silently claims
2217    /// `Unknown`. Under `#[serde(untagged)]` every one of these deserialized
2218    /// successfully into a fabricated descriptor.
2219    #[test]
2220    fn malformed_objects_are_rejected() {
2221        for bad in [
2222            r#"{"btis":4,"scehme":"k_quant_mixed","labl":"Q4_K_M"}"#, // typos
2223            r#"{"quantization":{"bits":4}}"#,                         // double-nested
2224            r#"{}"#,                                                  // nothing at all
2225            r#"{"group_size":64}"#,                                   // no label
2226            r#"{"bits":4,"scheme":"k_quant_mixed"}"#,                 // no label
2227        ] {
2228            let parsed: Result<Quantization, _> = serde_json::from_str(bad);
2229            assert!(parsed.is_err(), "should have rejected {bad}");
2230        }
2231    }
2232
2233    /// The error must name what was wrong. `#[serde(untagged)]` reported only
2234    /// "data did not match any variant", for a `models.json` that fails whole.
2235    #[test]
2236    fn rejection_names_the_offending_field() {
2237        let err = serde_json::from_str::<Quantization>(
2238            r#"{"bits":4,"scheme":"affine_grp_int","label":"4bit"}"#,
2239        )
2240        .unwrap_err()
2241        .to_string();
2242        assert!(
2243            err.contains("affine_grp_int") || err.contains("scheme"),
2244            "unhelpful error: {err}"
2245        );
2246    }
2247
2248    #[test]
2249    fn round_trips_through_the_object_form() {
2250        for label in ["Q4_K_M", "4bit", "mxfp8", "bf16", "weird-vendor-format"] {
2251            let q = Quantization::parse(label);
2252            let round: Quantization =
2253                serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
2254            assert_eq!(q, round, "round trip for {label}");
2255        }
2256    }
2257
2258    /// The label alone is the wire form whenever it is lossless. This is what
2259    /// keeps `row_digest` stable for rows that gained no new information.
2260    #[test]
2261    fn serializes_as_a_bare_label_when_lossless() {
2262        for label in [
2263            "Q4_K_M",
2264            "4bit",
2265            "mxfp8",
2266            "bf16",
2267            "q5_0",
2268            "weird-vendor-format",
2269        ] {
2270            let json = serde_json::to_string(&Quantization::parse(label)).unwrap();
2271            assert_eq!(json, format!("\"{label}\""), "should stay a bare string");
2272        }
2273    }
2274
2275    /// ...and the object form appears exactly where the label would lose
2276    /// something: a group size, or a scheme the label cannot express.
2277    #[test]
2278    fn serializes_as_an_object_only_when_it_adds_information() {
2279        let with_group = Quantization::from_mlx_config(Some(4), Some(64), None).unwrap();
2280        let json = serde_json::to_value(&with_group).unwrap();
2281        assert_eq!(json["group_size"], 64, "group_size must survive the write");
2282        assert_eq!(json["label"], "4bit");
2283
2284        // A bare `Q4` parses as Unknown, so an explicit scheme is real
2285        // information and has to be written out.
2286        let disambiguated = Quantization {
2287            bits: Some(4),
2288            scheme: QuantScheme::AffineGroupInt,
2289            group_size: None,
2290            label: "Q4".into(),
2291        };
2292        let json = serde_json::to_value(&disambiguated).unwrap();
2293        assert_eq!(json["scheme"], "affine_group_int");
2294        assert!(json.get("group_size").is_none(), "no null padding");
2295    }
2296
2297    /// Both write forms must read back identically, or the minimal-write rule
2298    /// would trade a digest change for silent data loss.
2299    #[test]
2300    fn every_write_form_round_trips() {
2301        let cases = [
2302            Quantization::parse("Q4_K_M"),
2303            Quantization::parse("bf16"),
2304            Quantization::parse("unattributable-format"),
2305            Quantization::from_mlx_config(Some(8), Some(32), Some("mxfp8")).unwrap(),
2306            Quantization::from_mlx_config(Some(4), Some(64), None).unwrap(),
2307            Quantization {
2308                bits: Some(4),
2309                scheme: QuantScheme::AffineGroupInt,
2310                group_size: None,
2311                label: "Q4".into(),
2312            },
2313        ];
2314        for q in cases {
2315            let round: Quantization =
2316                serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
2317            assert_eq!(q, round, "round trip for {q:?}");
2318        }
2319    }
2320
2321    /// The digest guard. `catalog_identity::row_digest` is a SHA-256 over the
2322    /// serialized schema and clients pin it through
2323    /// `expected_catalog_revision`, so a row whose quantization gained no new
2324    /// information must re-serialize to the byte-identical JSON it was read
2325    /// from. Only rows that genuinely changed may move.
2326    #[test]
2327    fn catalog_quantizations_reserialize_unchanged() {
2328        let raw: Vec<serde_json::Value> =
2329            serde_json::from_str(include_str!("builtin_catalog.json")).unwrap();
2330        let parsed: Vec<ModelSchema> =
2331            serde_json::from_str(include_str!("builtin_catalog.json")).unwrap();
2332        let mut moved = Vec::new();
2333        for (raw_row, model) in raw.iter().zip(&parsed) {
2334            let before = raw_row
2335                .get("quantization")
2336                .cloned()
2337                .unwrap_or(serde_json::Value::Null);
2338            let after = serde_json::to_value(&model.quantization).unwrap();
2339            if before != after {
2340                moved.push(format!("{}: {before} -> {after}", model.id));
2341            }
2342        }
2343        assert!(
2344            moved.is_empty(),
2345            "these rows would change catalog digest: {moved:#?}"
2346        );
2347    }
2348
2349    /// Every quantization the built-in catalog ships must classify. An entry
2350    /// may carry an explicit `scheme` only where its label is genuinely
2351    /// ambiguous — the guard is that an explicit scheme never *contradicts* a
2352    /// label the parser can already read, which is how catalog data stops
2353    /// being a place to make a failing test pass.
2354    #[test]
2355    fn builtin_catalog_quantizations_are_coherent() {
2356        let catalog: Vec<ModelSchema> =
2357            serde_json::from_str(include_str!("builtin_catalog.json")).unwrap();
2358        let mut problems = Vec::new();
2359        for model in &catalog {
2360            let Some(q) = &model.quantization else {
2361                continue;
2362            };
2363            if q.scheme == QuantScheme::Unknown {
2364                problems.push(format!("{}: unclassified label {:?}", model.id, q.label));
2365                continue;
2366            }
2367            let from_label = Quantization::parse(&q.label).scheme;
2368            if from_label != QuantScheme::Unknown && from_label != q.scheme {
2369                problems.push(format!(
2370                    "{}: label {:?} parses as {:?} but the row claims {:?}",
2371                    model.id, q.label, from_label, q.scheme
2372                ));
2373            }
2374        }
2375        assert!(
2376            problems.is_empty(),
2377            "incoherent catalog rows: {problems:#?}"
2378        );
2379    }
2380}