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