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