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