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