Skip to main content

car_inference/tasks/
generate.rs

1//! Text generation with sampling.
2
3#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4use candle_core::Tensor;
5use serde::{Deserialize, Serialize};
6
7#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
8use crate::backend::CandleBackend;
9#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
10use crate::InferenceError;
11
12/// How latency-sensitive this generation request is.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum RoutingWorkload {
16    /// User-facing, interactive request where latency matters.
17    #[default]
18    Interactive,
19    /// Batch job where latency matters somewhat, but quality/cost matter more.
20    Batch,
21    /// Background or offline work where latency is a weak concern.
22    Background,
23    /// Caller explicitly prefers on-device models. Distinct from
24    /// `Background` (which is "this is a background job, latency
25    /// barely matters"). The caller may be doing latency-sensitive
26    /// interactive work but wants the privacy / cost / offline
27    /// properties of local inference. Same `local_bonus` as
28    /// `Background` plus a slightly more quality-aware weight profile
29    /// — the caller chose local for a reason, not because the work is
30    /// throwaway.
31    LocalPreferred,
32    /// Aggressive latency bias for time-to-first-token. Voice turns
33    /// (specifically the fast track in the two-track sidecar pattern)
34    /// pick this. Quality and cost are heavily downweighted; the
35    /// router prefers whichever model produces a first token soonest.
36    /// On macOS 26+ this typically resolves to `apple/foundation:default`
37    /// via the Foundation Models system-LLM bonus. Reached via the
38    /// `IntentHint::prefer_fast` flag (or `RoutingWorkload::Fastest`
39    /// directly when callers know they want it).
40    Fastest,
41    /// Quality-critical, infrequent work where the BEST available model
42    /// matters far more than latency or cost — e.g. building/verifying an
43    /// agent, deriving a contract, structured extraction that a weak model
44    /// gets wrong. The mirror image of `Fastest`: quality dominates, latency
45    /// and cost are near-floor, so the most capable candidate wins decisively
46    /// instead of the cheapest one squeaking past on cost. Reached via the
47    /// `IntentHint::prefer_quality` flag.
48    Quality,
49}
50
51impl RoutingWorkload {
52    pub fn is_latency_sensitive(self) -> bool {
53        matches!(
54            self,
55            RoutingWorkload::Interactive
56                | RoutingWorkload::LocalPreferred
57                | RoutingWorkload::Fastest,
58        )
59    }
60
61    pub fn weights(self) -> (f64, f64, f64) {
62        // Tuple is `(quality, latency, cost)` — destructured at the
63        // single use site `adaptive_router.rs:892`.
64        match self {
65            RoutingWorkload::Interactive => (0.45, 0.40, 0.15),
66            RoutingWorkload::Batch => (0.60, 0.15, 0.25),
67            RoutingWorkload::Background => (0.65, 0.05, 0.30),
68            // Quality-aware (closer to Interactive) but tolerant of
69            // some latency hit since the caller chose local. Cost
70            // weight matches Batch.
71            RoutingWorkload::LocalPreferred => (0.55, 0.20, 0.25),
72            // Voice fast track: latency is everything. Quality and
73            // cost are deliberately near-floor — first audio in
74            // <500ms beats any quality gain that takes another
75            // round-trip. Sums to 1.0 like every other variant.
76            RoutingWorkload::Fastest => (0.10, 0.85, 0.05),
77            // Quality dominates; latency and cost near-floor so the most
78            // capable model wins outright (the inverse of Fastest). Sums to 1.0.
79            RoutingWorkload::Quality => (0.85, 0.05, 0.10),
80        }
81    }
82
83    pub fn local_bonus(self) -> f64 {
84        match self {
85            RoutingWorkload::Interactive => 0.0,
86            RoutingWorkload::Batch => 0.08,
87            RoutingWorkload::Background => 0.15,
88            // Stronger push than Background — "prefer local" should
89            // win ties decisively, otherwise the hint is ineffective.
90            RoutingWorkload::LocalPreferred => 0.20,
91            // Local inference avoids network round-trips entirely —
92            // the single biggest latency win available. On macOS the
93            // Foundation Models `system_llm_bonus` stacks on top of
94            // this for `apple/foundation:default`. Match
95            // LocalPreferred's bonus so cloud-streamed fast paths
96            // (e.g. gpt-4o-mini) can still win when locally there's
97            // no model loaded; the weight profile already strongly
98            // favours latency.
99            RoutingWorkload::Fastest => 0.20,
100            // Neutral: quality-first picks the best model wherever it lives.
101            // A strong remote should win when available; on a local-only host
102            // the biggest capable local wins on quality alone (no local bias
103            // needed — or wanted, since it could mask a better cloud model).
104            RoutingWorkload::Quality => 0.0,
105        }
106    }
107}
108
109/// Qwen3 hybrid thinking control. Qwen3 models were trained with both a
110/// "thinking" (chain-of-thought inside `<think>...</think>`) and a
111/// non-thinking mode. Upstream defaults thinking ON; `/no_think` and
112/// `/think` are the documented per-turn overrides in the chat template.
113///
114/// Scope: applies to the *single-turn* local Qwen3 path driven by
115/// [`apply_chat_template`]. The multi-turn `messages: Vec<Message>`
116/// field on [`GenerateRequest`] is consumed by remote protocol
117/// handlers (OpenAI/Anthropic/Google) which pass through user-supplied
118/// system messages verbatim; this flag is not injected there. If you
119/// need Qwen3 thinking control over a remote API, include `/think` or
120/// `/no_think` explicitly in your own system message.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
122#[serde(rename_all = "snake_case")]
123pub enum ThinkingMode {
124    /// Let the model decide. No explicit `/think` or `/no_think` directive
125    /// is injected into the system prompt, so Qwen3's trained default
126    /// (thinking ON) applies. `<think>...</think>` output is stripped
127    /// from the returned text.
128    #[default]
129    Auto,
130    /// Inject `/think` into the system prompt to explicitly request the
131    /// thinking phase. Useful when callers want to force reasoning even
132    /// on short prompts the model would normally answer directly.
133    On,
134    /// Inject `/no_think` into the system prompt to suppress the
135    /// thinking phase for faster, more direct responses. This was the
136    /// prior hard-coded behavior; callers now opt into it explicitly.
137    Off,
138}
139
140impl ThinkingMode {
141    /// Return the directive marker to append to the system prompt, or
142    /// `None` when `Auto` (don't inject anything — trust model default).
143    pub fn directive(self) -> Option<&'static str> {
144        match self {
145            ThinkingMode::Auto => None,
146            ThinkingMode::On => Some("/think"),
147            ThinkingMode::Off => Some("/no_think"),
148        }
149    }
150}
151
152/// Parameters controlling generation behavior.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct GenerateParams {
155    /// Sampling temperature (0.0 = greedy, 1.0 = full distribution).
156    #[serde(default = "default_temperature")]
157    pub temperature: f64,
158    /// Hard-pin: when true, do NOT silently degrade a remote-only model chain to
159    /// an installed on-device model as a last resort (see
160    /// `generate_tracked_inner`). A caller that pinned a specific backbone — the
161    /// coder's `--model`, or an A/B arm whose validity depends on the exact model
162    /// — wants a hard error on a remote outage, not a silent swap to a weaker
163    /// local model that manufactures fake results. Defaults false, so every other
164    /// caller keeps the resilient degrade-to-local behavior. Lives on
165    /// `GenerateParams` (not `GenerateRequest`) so the many explicit
166    /// `GenerateRequest { .. }` initializers inherit it via `params: Default`.
167    #[serde(default)]
168    pub strict_model: bool,
169    /// Top-p (nucleus) sampling threshold.
170    #[serde(default = "default_top_p")]
171    pub top_p: f64,
172    /// Top-k sampling (0 = disabled).
173    #[serde(default)]
174    pub top_k: usize,
175    /// Maximum tokens to generate.
176    #[serde(default = "default_max_tokens")]
177    pub max_tokens: usize,
178    /// Stop sequences — generation halts when any is produced.
179    #[serde(default)]
180    pub stop: Vec<String>,
181    /// Extended thinking budget (tokens). When > 0, enables the model's
182    /// internal reasoning/planning phase before responding. Only supported
183    /// by models with the ExtendedThinking capability (e.g., Claude).
184    #[serde(default)]
185    pub budget_tokens: usize,
186    /// Routing workload class. Interactive requests bias toward lower latency,
187    /// while batch/background work can tolerate slower high-quality local models.
188    #[serde(default)]
189    pub workload: RoutingWorkload,
190    /// Tool choice mode, honored per-provider (see `protocol.rs`):
191    /// * `"auto"` (default when tools present) / `"required"` / `"none"` — the
192    ///   portable subset. Works on OpenAI-family, Anthropic, and Google
193    ///   (`"required"` maps to Anthropic `any` / Google `ANY`).
194    /// * `"any"` or a specific tool NAME — forcing modes honored on Anthropic
195    ///   (`{"type":"any"}` / `{"type":"tool","name":…}`), Google (`ANY` +
196    ///   `allowedFunctionNames`), and Bedrock (named only). **OpenAI-family
197    ///   passes the string through verbatim**, so `"any"`/a bare name 400s
198    ///   there — use `"required"` for portable forcing.
199    /// * Caveats: Bedrock Converse has no `none` mode (a `"none"` string falls
200    ///   through to a forced tool literally named "none"); `tool_choice` is only
201    ///   emitted when `tools` are present. On Anthropic a forcing choice
202    ///   (`any`/named) additionally disables extended thinking for that request.
203    #[serde(default)]
204    pub tool_choice: Option<String>,
205    /// OpenAI-compatible parallel tool call control.
206    #[serde(default)]
207    pub parallel_tool_calls: Option<bool>,
208    /// Qwen3 hybrid thinking mode control. `Auto` (default) leaves the
209    /// model at its trained default (thinking on). `On`/`Off` inject
210    /// the documented `/think` or `/no_think` directive into the chat
211    /// template. Ignored by non-Qwen3 models.
212    #[serde(default)]
213    pub thinking: ThinkingMode,
214    /// TTL for Anthropic prompt-cache entries, applied to every cache
215    /// breakpoint this request emits (only when `cache_control` is enabled on
216    /// the [`GenerateRequest`]). Defaults to the 5-minute ephemeral cache.
217    /// Set [`CacheTtl::OneHour`] for agentic flows where minutes can pass
218    /// between calls (tool execution, HITL approval gates) so the cached
219    /// prefix survives instead of silently expiring into a full re-bill.
220    #[serde(default)]
221    pub cache_ttl: CacheTtl,
222    /// Caller estimate of prompt/input tokens expected to be served from a
223    /// provider cache. This is routing metadata only: it does not change the
224    /// provider request or claim that a cache hit occurred. Defaults to zero
225    /// because CAR cannot know a hit before the provider reports usage.
226    #[serde(default)]
227    pub estimated_cache_read_input_tokens: usize,
228    /// Caller estimate of prompt/input tokens expected to be written into a
229    /// provider cache. This is routing metadata only and defaults to zero;
230    /// enabling `cache_control` alone never invents a cache-write estimate.
231    #[serde(default)]
232    pub estimated_cache_write_input_tokens: usize,
233}
234
235/// Prompt-cache time-to-live for Anthropic `cache_control` breakpoints.
236///
237/// The runtime applies one TTL uniformly to all breakpoints in a request, so
238/// there is never a mix of 5-minute and 1-hour entries (Anthropic requires
239/// 1-hour entries to precede 5-minute ones — a uniform TTL sidesteps the
240/// ordering rule entirely).
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243pub enum CacheTtl {
244    /// Default 5-minute ephemeral cache. Write billed at ~1.25× base input.
245    #[default]
246    FiveMinutes,
247    /// Extended 1-hour cache. Write billed at ~2× base input. Best when the
248    /// cached prefix is reused less often than every 5 minutes but more than
249    /// hourly — typical of long agentic loops.
250    OneHour,
251}
252
253impl CacheTtl {
254    /// The value Anthropic expects in `cache_control.ttl`, or `None` for the
255    /// default 5-minute cache (which omits the field entirely).
256    pub fn anthropic_ttl(self) -> Option<&'static str> {
257        match self {
258            CacheTtl::FiveMinutes => None,
259            CacheTtl::OneHour => Some("1h"),
260        }
261    }
262}
263
264fn default_temperature() -> f64 {
265    0.7
266}
267fn default_top_p() -> f64 {
268    0.9
269}
270/// The library default per-turn output budget. Used as the "unset" sentinel
271/// by [`crate::InferenceEngine::generate_tracked`]: when a request still carries
272/// this value, the engine substitutes the resolved model's real output ceiling
273/// (`ModelSchema::effective_max_output()`) so long-horizon tool_use JSON isn't
274/// truncated at 4096.
275pub const DEFAULT_MAX_TOKENS: usize = 4096;
276fn default_max_tokens() -> usize {
277    DEFAULT_MAX_TOKENS
278}
279
280impl Default for GenerateParams {
281    fn default() -> Self {
282        Self {
283            temperature: default_temperature(),
284            strict_model: false,
285            top_p: default_top_p(),
286            top_k: 0,
287            max_tokens: default_max_tokens(),
288            stop: Vec::new(),
289            budget_tokens: 0,
290            workload: RoutingWorkload::Interactive,
291            tool_choice: None,
292            parallel_tool_calls: None,
293            thinking: ThinkingMode::default(),
294            cache_ttl: CacheTtl::default(),
295            estimated_cache_read_input_tokens: 0,
296            estimated_cache_write_input_tokens: 0,
297        }
298    }
299}
300
301// `Message`, `ToolCall`, and `ContentBlock` now live in the dependency-light
302// `car-inference-types` crate so a consumer (car-sync's transcript-resume
303// projection) can build the REAL types without the Candle/MLX stack — drift is
304// a compile error there, not a runtime `from_value::<Message>` break. Re-exported
305// here so every car-inference caller (and `crate::tasks::generate::Message`) is
306// unchanged.
307pub use car_inference_types::{ContentBlock, Message, Provenance, ThinkingBlock, ToolCall};
308
309/// Constraint on the model's response shape. Distinct from `tools` —
310/// tools are a side-channel for action invocation; `response_format`
311/// constrains the *primary* text output to be parseable JSON, optionally
312/// against a caller-supplied schema.
313///
314/// Provider mapping (handled in `protocol.rs`):
315/// * **OpenAI / Azure / OpenAI-compatible**: `response_format: {type: "json_schema", json_schema: {schema, strict, name}}`
316///   (or `{type: "json_object"}` for the looser variant). Strict mode rejects
317///   any deviation from the schema.
318/// * **Google (Gemini)**: `response_mime_type: "application/json"` plus
319///   optional `response_schema`.
320/// * **Anthropic**: not wired for a provider-enforced response-format field
321///   under CAR's pinned `anthropic-version`. Both variants are rejected up
322///   front with `UnsupportedMode` rather than silently weakening the caller's
323///   output contract. Callers needing structured output on Claude should use
324///   the `tools` + `tool_choice="required"` coercion idiom.
325///
326/// `JsonObject` is the looser variant — tells the provider "emit valid
327/// JSON, no schema check required". Use when the schema is too dynamic
328/// to spell out but the parse contract still matters.
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
330#[serde(tag = "type", rename_all = "snake_case")]
331pub enum ResponseFormat {
332    /// JSON output validated against the provided schema. `strict: true`
333    /// asks the provider to reject any deviation; `false` makes the
334    /// schema a best-effort hint. `name` is OpenAI-specific (the
335    /// `json_schema.name` field, max 64 chars, alphanumerics + `-_`);
336    /// other providers ignore it.
337    JsonSchema {
338        schema: serde_json::Value,
339        #[serde(default)]
340        strict: bool,
341        #[serde(default, skip_serializing_if = "Option::is_none")]
342        name: Option<String>,
343    },
344    /// Plain JSON-mode output — the provider emits valid JSON without
345    /// schema enforcement.
346    JsonObject,
347}
348
349/// A text generation request.
350///
351/// `Default` is derived so call sites can mutate just the fields
352/// they care about: `GenerateRequest { prompt: "...".into(), ..Default::default() }`.
353/// The default `prompt = ""` is useless on its own — callers always
354/// override it — but the `..Default::default()` shorthand stops the
355/// per-call-site mechanical churn every time a new optional field
356/// lands (closes #109).
357#[derive(Debug, Clone, Default, Serialize, Deserialize)]
358pub struct GenerateRequest {
359    /// The prompt to complete (first user message for single-turn).
360    pub prompt: String,
361    /// Optional model override.
362    pub model: Option<String>,
363    /// Generation parameters.
364    #[serde(default)]
365    pub params: GenerateParams,
366    /// Optional memory context to prepend to the prompt.
367    /// When provided, this is injected as a system-level context block
368    /// before the user prompt, grounding the model's response.
369    #[serde(default)]
370    pub context: Option<String>,
371    /// Optional cache-breakpoint hint: the leading prefix of `context` that is
372    /// stable across queries (Identity + Constraints, from the memory engine's
373    /// `ContextSplit`). When set with `cache_control` on an Anthropic request,
374    /// the system prompt is split into a cached stable block + an uncached
375    /// volatile block so the stable prefix actually hits the cache. `None`
376    /// keeps the whole-system single-block behavior. Must be a genuine prefix
377    /// of `context` or it is ignored (the handler validates with `starts_with`).
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub context_stable_prefix: Option<String>,
380    /// Optional tool definitions for structured tool_use.
381    /// When provided, the model may return tool_calls instead of text.
382    /// Each tool is a JSON object with: name, description, parameters (JSON Schema).
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub tools: Option<Vec<serde_json::Value>>,
385    /// Optional images for vision models.
386    /// When provided with a single-turn prompt, these are included as image content blocks
387    /// in the user message. For multi-turn with `messages`, use `UserMultimodal` variants instead.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub images: Option<Vec<ContentBlock>>,
390    /// Optional multi-turn conversation history.
391    /// When provided, the backend builds a proper multi-turn message array
392    /// instead of a single user message. The `prompt` field is ignored when
393    /// messages are present.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub messages: Option<Vec<Message>>,
396    /// Enable prompt caching for Anthropic API.
397    /// When true, system prompt and tools are marked with cache_control breakpoints,
398    /// enabling cache reuse across parent/child agent calls sharing the same prefix.
399    #[serde(default)]
400    pub cache_control: bool,
401    /// Constrain output to JSON (optionally schema-validated). See
402    /// [`ResponseFormat`] for the per-provider mapping. Defaults to
403    /// `None` — free-form text.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub response_format: Option<ResponseFormat>,
406    /// Caller-supplied routing intent. None preserves the existing
407    /// adaptive vs. pinned-model behavior. When `Some`, the adaptive
408    /// router uses the hint to filter candidates (hard `require`),
409    /// override task selection, and bias the score profile
410    /// (`prefer_local`). See [`crate::intent::IntentHint`].
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub intent: Option<crate::intent::IntentHint>,
413    /// Opaque caller-supplied correlation token, echoed VERBATIM in the
414    /// `inference.runner.invoke` payload and otherwise ignored by CAR.
415    ///
416    /// Exists because a delegated-inference host with more than one call in
417    /// flight had no way to map an invoke back to its own request state
418    /// (Parslee-ai/car-releases#78). `call_id` is minted by the daemon *after*
419    /// the host called `infer`, and `GenerateRequest` is a typed struct, so
420    /// extra fields were dropped on the serde round-trip — leaving hosts to
421    /// smuggle a UUID through `prompt`. That worked only because delegated
422    /// models ignore `prompt`, and would break silently the moment one stopped
423    /// ignoring it.
424    ///
425    /// CAR never reads, routes on, or logs this as anything but an opaque
426    /// string: it is the host's own identifier, not a CAR one. Not a
427    /// substitute for `call_id`, which remains the key for
428    /// `inference.runner.event` / `.complete` / `.fail`.
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub client_ref: Option<String>,
431    /// Require the resolved immutable catalog row to match this SHA-256
432    /// digest. A mismatch fails before any provider, runner, or local backend
433    /// is dispatched and is retained across routed retries and fallbacks.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub expected_row_digest: Option<String>,
436    /// Optionally require the route to use this exact catalog revision. This
437    /// protects a caller that selected a row from a previously read snapshot
438    /// from silently running against a different catalog generation.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub expected_catalog_revision: Option<String>,
441    /// Who originated this request, set IN-PROCESS by the daemon and never
442    /// carried on the wire (`#[serde(skip)]`, so it is absent from the
443    /// `inference.runner.invoke` payload and from any persisted request).
444    ///
445    /// Exists so a delegated call can be dispatched to the runner registered by
446    /// the session that made it, instead of to whichever runner registered most
447    /// recently (Parslee-ai/car-releases#77). The runner slot used to be a
448    /// process-global that the last registrant overwrote, so two hosts sharing
449    /// one daemon stole each other's calls.
450    ///
451    /// Distinct from [`Self::client_ref`], which is the HOST's own opaque token
452    /// and is echoed to the host; this is CAR's own routing key and is never
453    /// echoed. A caller that leaves it `None` gets the global-fallback runner,
454    /// which is the pre-existing behaviour.
455    #[serde(skip)]
456    pub caller: Option<String>,
457}
458
459/// Wrap a raw prompt in Qwen3 chat format if it's not already formatted.
460///
461/// Thinking behavior follows the caller-supplied [`ThinkingMode`]:
462/// * `Auto` — no directive injected; Qwen3's trained default (thinking
463///   on) applies.
464/// * `On` — the documented `/think` directive is appended to the system
465///   message on its own line, and the model is allowed to emit a full
466///   `<think>...</think>` block before its answer.
467/// * `Off` — `/no_think` is appended to the system message *and* an
468///   empty `<think>\n\n</think>` block is pre-filled after the assistant
469///   marker, matching upstream Qwen3's `enable_thinking=False` jinja
470///   template. The pre-filled closed tags structurally prevent the
471///   model from emitting a thinking block even if the directive is
472///   contradicted later in the prompt.
473///
474/// When context is provided it is injected into the system message to
475/// ground the model's response with memory. The directive always
476/// appears *after* the context blob so user-supplied memory cannot
477/// nudge the directive's parse position.
478pub fn apply_chat_template(prompt: &str, context: Option<&str>, thinking: ThinkingMode) -> String {
479    if prompt.contains("<|im_start|>") {
480        return prompt.to_string();
481    }
482    // Directive goes on its own line at the end of the system message
483    // (never concatenated onto prose) so Qwen3's chat template parser
484    // sees `/think`/`/no_think` as a standalone token, not as part of
485    // "assistant. /no_think".
486    let directive_line = match thinking.directive() {
487        Some(d) => format!("\n{d}"),
488        None => String::new(),
489    };
490    // For Off, pre-fill a closed empty thinking block after the
491    // assistant marker. This mirrors upstream Qwen3's jinja behavior
492    // when `enable_thinking=False` and is the hard-switch (structural)
493    // form of the mode, whereas `/no_think` alone is a soft directive.
494    let thinking_prefill = match thinking {
495        ThinkingMode::Off => "<think>\n\n</think>\n\n",
496        _ => "",
497    };
498    match context {
499        Some(ctx) => format!(
500            "<|im_start|>system\nYou are a helpful assistant. Use the following context to inform your response.\n\n{ctx}{directive_line}<|im_end|>\n\
501             <|im_start|>user\n{prompt}<|im_end|>\n\
502             <|im_start|>assistant\n{thinking_prefill}"
503        ),
504        None => format!(
505            "<|im_start|>system\nYou are a helpful assistant.{directive_line}<|im_end|>\n\
506             <|im_start|>user\n{prompt}<|im_end|>\n\
507             <|im_start|>assistant\n{thinking_prefill}"
508        ),
509    }
510}
511
512/// Render a Qwen3 chat-format prompt from a multi-turn `messages` array
513/// and/or `tools`, for the in-process MLX/candle backends — which, unlike the
514/// remote/OpenAI-compatible path, have no native chat-template or tool API.
515///
516/// Mirrors Qwen3's documented (Hermes-style) tool convention: tool signatures
517/// go in a `<tools></tools>` block inside the system turn, and the model is
518/// instructed to emit `<tool_call>{"name":…,"arguments":…}</tool_call>`, which
519/// [`parse_tool_calls`] extracts back out of the completion.
520///
521/// When neither `messages` nor `tools` is present this delegates verbatim to
522/// [`apply_chat_template`], so the common single-turn text path is byte-for-byte
523/// unchanged. The returned string ends in `<|im_start|>assistant\n` (plus the
524/// thinking prefill, if any) ready for completion.
525pub fn render_chat_prompt(req: &GenerateRequest) -> String {
526    let has_msgs = req.messages.as_ref().is_some_and(|m| !m.is_empty());
527    let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
528    if !has_msgs && !has_tools {
529        // Identical behavior to the legacy path — zero risk for non-tool calls.
530        return apply_chat_template(&req.prompt, req.context.as_deref(), req.params.thinking);
531    }
532    // Already pre-formatted (e.g. rendered once upstream)? Pass through.
533    if req.messages.is_none() && req.prompt.contains("<|im_start|>") {
534        return req.prompt.clone();
535    }
536
537    let thinking = req.params.thinking;
538    let directive_line = match thinking.directive() {
539        Some(d) => format!("\n{d}"),
540        None => String::new(),
541    };
542    let thinking_prefill = match thinking {
543        ThinkingMode::Off => "<think>\n\n</think>\n\n",
544        _ => "",
545    };
546
547    // --- system turn (folds any System message(s) + context + tools) ---
548    let mut system_text = String::new();
549    let mut had_system = false;
550    if let Some(msgs) = &req.messages {
551        for m in msgs {
552            if let Message::System { content } = m {
553                if had_system {
554                    system_text.push_str("\n\n");
555                }
556                system_text.push_str(content);
557                had_system = true;
558            }
559        }
560    }
561    if !had_system {
562        system_text.push_str("You are a helpful assistant.");
563    }
564    if let Some(ctx) = &req.context {
565        system_text.push_str("\n\n");
566        system_text.push_str(ctx);
567    }
568
569    let mut out = String::new();
570    out.push_str("<|im_start|>system\n");
571    out.push_str(&system_text);
572    if let Some(tools) = &req.tools {
573        if !tools.is_empty() {
574            out.push_str(&render_tools_block(tools));
575        }
576    }
577    out.push_str(&directive_line);
578    out.push_str("<|im_end|>\n");
579
580    // --- conversation turns ---
581    match &req.messages {
582        Some(msgs) => {
583            for m in msgs {
584                match m {
585                    // System already folded into the system turn above.
586                    Message::System { .. } => {}
587                    Message::User { content } => {
588                        out.push_str("<|im_start|>user\n");
589                        out.push_str(content);
590                        out.push_str("<|im_end|>\n");
591                    }
592                    Message::UserMultimodal { content } => {
593                        // The in-process text tower can't consume image/video/
594                        // audio blocks (those requests reject upstream); render
595                        // the text blocks so a mixed message still threads.
596                        let text = content
597                            .iter()
598                            .filter_map(|b| match b {
599                                ContentBlock::Text { text } => Some(text.as_str()),
600                                _ => None,
601                            })
602                            .collect::<Vec<_>>()
603                            .join("\n");
604                        out.push_str("<|im_start|>user\n");
605                        out.push_str(&text);
606                        out.push_str("<|im_end|>\n");
607                    }
608                    Message::Assistant {
609                        content,
610                        tool_calls,
611                        .. // local Qwen template has no thinking-block concept
612                    } => {
613                        out.push_str("<|im_start|>assistant\n");
614                        out.push_str(content);
615                        for tc in tool_calls {
616                            let args = serde_json::to_string(&tc.arguments)
617                                .unwrap_or_else(|_| "{}".to_string());
618                            out.push_str(&format!(
619                                "\n<tool_call>\n{{\"name\": \"{}\", \"arguments\": {}}}\n</tool_call>",
620                                tc.name, args
621                            ));
622                        }
623                        out.push_str("<|im_end|>\n");
624                    }
625                    Message::ToolResult { content, .. } => {
626                        // Qwen wraps tool results in a user turn with
627                        // <tool_response> tags.
628                        out.push_str("<|im_start|>user\n<tool_response>\n");
629                        out.push_str(content);
630                        out.push_str("\n</tool_response><|im_end|>\n");
631                    }
632                    // Provider-specific opaque items don't round-trip to a
633                    // local model — drop them (matches the builder contract).
634                    Message::ProviderOutputItems { .. } => {}
635                }
636            }
637        }
638        None => {
639            // Single-turn with tools: the prompt is the user message.
640            out.push_str("<|im_start|>user\n");
641            out.push_str(&req.prompt);
642            out.push_str("<|im_end|>\n");
643        }
644    }
645
646    out.push_str("<|im_start|>assistant\n");
647    out.push_str(thinking_prefill);
648    out
649}
650
651/// Render the Qwen3 `<tools>` system-prompt block from tool definitions. Each
652/// tool is normalized to `{"type":"function","function":{…}}` (Qwen's expected
653/// shape); a definition already in that shape is passed through unchanged.
654fn render_tools_block(tools: &[serde_json::Value]) -> String {
655    let mut s = String::from(
656        "\n\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\n\
657         You are provided with function signatures within <tools></tools> XML tags:\n<tools>",
658    );
659    for t in tools {
660        let is_wrapped = t.get("type").and_then(|v| v.as_str()) == Some("function")
661            && t.get("function").is_some();
662        let func = if is_wrapped {
663            t.clone()
664        } else {
665            serde_json::json!({ "type": "function", "function": t })
666        };
667        s.push('\n');
668        s.push_str(&serde_json::to_string(&func).unwrap_or_default());
669    }
670    s.push_str(
671        "\n</tools>\n\nFor each function call, return a json object with function name and \
672         arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n\
673         {\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call>",
674    );
675    s
676}
677
678/// Extract Qwen3 `<tool_call>{json}</tool_call>` blocks from a completion.
679///
680/// Returns the text with those blocks removed (trimmed) plus the parsed calls.
681/// Each block is `{"name": …, "arguments": {…}}`; ids are minted locally
682/// (`call_0`, `call_1`, …) since a local model emits none. A malformed or
683/// unterminated block is left in the text and skipped, so a stray `<tool_call>`
684/// literal never yields a bogus call. This is the local counterpart to the
685/// structured `tool_calls` the remote `protocol.rs` path parses.
686/// Strip a leaked model reasoning-channel prefix from answer text.
687///
688/// Table-stakes chat UIs (Claude Code, Codex, ChatGPT) never show the model's
689/// internal reasoning label as the answer. Some models leak one: gemma-4 renders
690/// reasoning as `<|channel>thought…<channel|>`, and with `skip_special_tokens`
691/// the marker tokens drop but the plain channel name `thought` survives as a
692/// leading token (observed `"thought\n<answer>"` and `"thought <answer>"`).
693/// This runs on the finalized completion in BOTH the streaming (`parse_tool_calls`)
694/// and non-streaming (backend `parse_tool_calls`) paths, so the chat surface,
695/// the FFI harnesses, and memory all see clean text. Conservative: only a
696/// *leading* bare `thought` marker followed by whitespace is removed, so a normal
697/// answer that merely contains the word is untouched.
698pub fn strip_leaked_reasoning(text: &str) -> String {
699    // Delimited span, if the marker tokens survived decoding.
700    if let Some(start) = text.find("<|channel>thought") {
701        if let Some(rel) = text[start..].find("<channel|>") {
702            let end = start + rel + "<channel|>".len();
703            let joined = format!("{}{}", &text[..start], &text[end..]);
704            return strip_leaked_reasoning(joined.trim());
705        }
706    }
707    // Leading bare `thought` marker (delimiter tokens stripped by the decoder),
708    // followed by any whitespace (newline OR space, both observed).
709    let trimmed = text.trim_start();
710    if let Some(rest) = trimmed.strip_prefix("thought") {
711        if rest.starts_with(char::is_whitespace) {
712            return rest.trim_start().to_string();
713        }
714    }
715    text.to_string()
716}
717
718pub fn parse_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
719    const OPEN: &str = "<tool_call>";
720    const CLOSE: &str = "</tool_call>";
721    if !text.contains(OPEN) {
722        return (strip_leaked_reasoning(text), Vec::new());
723    }
724    let mut calls = Vec::new();
725    let mut clean = String::new();
726    let mut rest = text;
727    let mut idx = 0usize;
728    while let Some(start) = rest.find(OPEN) {
729        let after_open = start + OPEN.len();
730        if let Some(end_rel) = rest[after_open..].find(CLOSE) {
731            let json_end = after_open + end_rel;
732            let block = rest[after_open..json_end].trim();
733            if let Some(tc) = parse_one_tool_call(block, &mut idx) {
734                clean.push_str(&rest[..start]);
735                calls.push(tc);
736                rest = &rest[json_end + CLOSE.len()..];
737                continue;
738            }
739        }
740        // Unterminated or unparseable — keep the opener literal, advance past it.
741        clean.push_str(&rest[..after_open]);
742        rest = &rest[after_open..];
743    }
744    clean.push_str(rest);
745    (strip_leaked_reasoning(clean.trim()), calls)
746}
747
748fn parse_one_tool_call(block: &str, idx: &mut usize) -> Option<ToolCall> {
749    let v: serde_json::Value = serde_json::from_str(block).ok()?;
750    let name = v.get("name")?.as_str()?.to_string();
751    let arguments = match v.get("arguments") {
752        Some(serde_json::Value::Object(m)) => m.clone().into_iter().collect(),
753        // Some models emit arguments as a JSON-encoded string.
754        Some(serde_json::Value::String(s)) => serde_json::from_str(s).unwrap_or_default(),
755        _ => std::collections::HashMap::new(),
756    };
757    let id = Some(format!("call_{}", *idx));
758    *idx += 1;
759    Some(ToolCall {
760        id,
761        name,
762        arguments,
763    })
764}
765
766/// Strip Qwen3 `<think>...</think>` blocks from model output, honoring
767/// the caller's requested [`ThinkingMode`]:
768///
769/// * `On` — the caller explicitly asked for reasoning; return the raw
770///   text verbatim so `<think>...</think>` is visible.
771/// * `Auto` / `Off` — strip the thinking block and return only the
772///   post-thinking answer. If the output contains an opening `<think>`
773///   without a closing tag (truncation or stop before the model
774///   finished thinking) return an empty string rather than leaking a
775///   dangling tag to the caller.
776/// Cut `text` at the earliest occurrence of any stop sequence (exclusive).
777/// The generation loops detect a stop sequence by substring match and then
778/// `break`, but the matched tokens are already in `generated`, so the decoded
779/// text still contained the stop string — standard completion semantics
780/// exclude it. Empty stop entries are ignored. No-op when nothing matches.
781pub fn truncate_at_stop(text: &str, stops: &[String]) -> String {
782    let mut cut = text.len();
783    for s in stops {
784        if s.is_empty() {
785            continue;
786        }
787        if let Some(idx) = text.find(s.as_str()) {
788            cut = cut.min(idx);
789        }
790    }
791    text[..cut].to_string()
792}
793
794pub fn strip_thinking(text: &str, thinking: ThinkingMode) -> String {
795    if matches!(thinking, ThinkingMode::On) {
796        return text.to_string();
797    }
798    strip_thinking_block(text)
799}
800
801/// Remove a leading `<think>...</think>` block unconditionally.
802/// Returns "" if `<think>` opens but never closes (incomplete output).
803///
804/// When that "opened but never closed" branch fires, log a warn line
805/// — the caller is about to receive an empty string for what was
806/// almost certainly a budget-truncation. Surfaces issue #168's root
807/// cause without changing the return contract: callers (e.g. car-cli)
808/// that look at stderr can tell users to either bump
809/// `--max-tokens` or pass `--thinking off`. The decision lives in
810/// the strip helper because every text-completion path funnels
811/// through it; logging at the call sites would be a lot of
812/// duplication.
813fn strip_thinking_block(text: &str) -> String {
814    if let Some(end) = text.find("</think>") {
815        text[end + 8..].trim_start().to_string()
816    } else if text.contains("<think>") {
817        tracing::warn!(
818            target: "car_inference::tasks::generate",
819            raw_len = text.len(),
820            "model output opened <think> but never closed it — \
821             likely truncated by max_tokens; returning empty text. \
822             Increase max_tokens, or set thinking=off to suppress \
823             the reasoning phase."
824        );
825        String::new()
826    } else {
827        text.to_string()
828    }
829}
830
831/// Callback for FLARE-style re-retrieval during generation.
832/// Called with partial generation text, returns additional context or None.
833pub type RetrievalCallback = Box<dyn Fn(&str) -> Option<String> + Send>;
834
835#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
836/// Generate text from a prompt using the loaded model.
837///
838/// Returns `(text, time_to_first_token_ms, prompt_tokens, completion_tokens)`.
839/// TTFT is measured from function entry through prefill to the moment the first
840/// generated token has been sampled — the user-visible "did anything happen
841/// yet" gate. `None` only when the prompt encodes to zero tokens (degenerate
842/// input).
843///
844/// The token counts are reported so this path can populate `TokenUsage` like
845/// the MLX and remote paths do. It previously returned only `(text, ttft)`, and
846/// the caller hardcoded `usage: None` — so on every non-Apple-Silicon platform
847/// (Linux, Windows, and macOS built with `car_skip_mlx`) EVERY local inference
848/// reported no usage at all. A consumer summing `usage.total_tokens` read a
849/// silent zero, which is worse than an error because it looks like a valid
850/// answer (Parslee-ai/car#795). `prompt_tokens` is post-truncation — what the
851/// model actually saw, not what the caller sent.
852pub async fn generate(
853    backend: &mut CandleBackend,
854    req: GenerateRequest,
855) -> Result<(String, Option<u64>, usize, usize), InferenceError> {
856    let start = std::time::Instant::now();
857
858    // Reset KV cache so each generation starts fresh (prevents cross-call state bleed)
859    backend.clear_kv_cache();
860
861    let formatted = apply_chat_template(&req.prompt, req.context.as_deref(), req.params.thinking);
862    let tokens = backend.encode(&formatted)?;
863    let eos = backend.eos_token_id();
864    let eos_alt = backend.token_id("<|im_end|>");
865    let params = &req.params;
866
867    if tokens.is_empty() {
868        return Ok((String::new(), None, 0, 0));
869    }
870
871    // Truncate to model's max context length minus generation headroom.
872    // This prevents KV cache overflow on long prompts.
873    let max_ctx = backend.context_length().unwrap_or(32768);
874    let headroom = params.max_tokens.min(max_ctx / 4);
875    let max_prompt = max_ctx.saturating_sub(headroom);
876    let tokens = if tokens.len() > max_prompt {
877        eprintln!(
878            "[car-inference] truncating prompt from {} to {} tokens (context_length={})",
879            tokens.len(),
880            max_prompt,
881            max_ctx
882        );
883        tokens[tokens.len() - max_prompt..].to_vec()
884    } else {
885        tokens
886    };
887
888    let mut generated = Vec::new();
889
890    // Prefill: process all prompt tokens, sample first generated token from prefill logits
891    let logits = backend.forward(&tokens, 0)?;
892    let mut next_token = sample_token(&logits, params)?;
893    let ttft_ms = Some(start.elapsed().as_millis() as u64);
894
895    for _i in 0..params.max_tokens {
896        // Check EOS
897        if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
898            break;
899        }
900
901        generated.push(next_token);
902
903        // Check stop sequences
904        if !params.stop.is_empty() {
905            let text_so_far = backend.decode(&generated)?;
906            if params.stop.iter().any(|s| text_so_far.contains(s)) {
907                break;
908            }
909        }
910
911        // Generate next token
912        let pos = tokens.len() + generated.len() - 1;
913        let logits = backend.forward(&[next_token], pos)?;
914        next_token = sample_token(&logits, params)?;
915    }
916
917    let text = truncate_at_stop(&backend.decode(&generated)?, &params.stop);
918    Ok((
919        strip_thinking(&text, params.thinking),
920        ttft_ms,
921        tokens.len(),
922        generated.len(),
923    ))
924}
925
926#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
927/// Generate with FLARE-style confidence-triggered re-retrieval.
928///
929/// Monitors token logit confidence during generation. When a window of
930/// low-confidence tokens is detected, pauses, re-queries memory with the
931/// partial generation, and resumes with augmented context.
932pub async fn generate_with_retrieval(
933    backend: &mut CandleBackend,
934    mut req: GenerateRequest,
935    retrieval_cb: RetrievalCallback,
936) -> Result<String, InferenceError> {
937    // First pass: generate normally
938    backend.clear_kv_cache();
939    let formatted = apply_chat_template(&req.prompt, req.context.as_deref(), req.params.thinking);
940    let tokens = backend.encode(&formatted)?;
941    let eos = backend.eos_token_id();
942    let eos_alt = backend.token_id("<|im_end|>");
943    let params = req.params.clone();
944
945    if tokens.is_empty() {
946        return Ok(String::new());
947    }
948
949    let mut generated = Vec::new();
950    let mut low_confidence_count = 0u32;
951    let mut retrieval_attempts = 0u32;
952    let max_retrievals = 2;
953    let confidence_threshold = 0.4f32;
954    let low_confidence_window = 3u32;
955
956    let logits = backend.forward(&tokens, 0)?;
957    let mut next_token = sample_token(&logits, &params)?;
958
959    for _i in 0..params.max_tokens {
960        if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
961            break;
962        }
963
964        generated.push(next_token);
965
966        // Generate next token and check confidence
967        let pos = tokens.len() + generated.len() - 1;
968        let logits = backend.forward(&[next_token], pos)?;
969
970        // Check max logit probability for confidence
971        let logits_f32: Vec<f32> = logits
972            .squeeze(0)
973            .unwrap_or(logits.clone())
974            .to_dtype(candle_core::DType::F32)
975            .map_err(|e| InferenceError::InferenceFailed(format!("dtype: {e}")))?
976            .to_vec1()
977            .unwrap_or_default();
978
979        if !logits_f32.is_empty() {
980            // Compute softmax max probability
981            let max_logit = logits_f32.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
982            let exp_sum: f32 = logits_f32.iter().map(|&v| (v - max_logit).exp()).sum();
983            let max_prob = 1.0 / exp_sum; // probability of the top token
984
985            if max_prob < confidence_threshold {
986                low_confidence_count += 1;
987            } else {
988                low_confidence_count = 0;
989            }
990
991            // Trigger re-retrieval after sustained low confidence
992            if low_confidence_count >= low_confidence_window && retrieval_attempts < max_retrievals
993            {
994                retrieval_attempts += 1;
995                low_confidence_count = 0;
996
997                // Use partial generation as re-retrieval query
998                let partial = backend.decode(&generated)?;
999                if let Some(new_context) = retrieval_cb(&partial) {
1000                    // Restart generation with augmented context
1001                    let combined_context = match req.context.take() {
1002                        Some(old) => format!("{}\n\n{}", old, new_context),
1003                        None => new_context,
1004                    };
1005                    req.context = Some(combined_context);
1006
1007                    // Re-encode and restart
1008                    backend.clear_kv_cache();
1009                    let new_formatted = apply_chat_template(
1010                        &req.prompt,
1011                        req.context.as_deref(),
1012                        req.params.thinking,
1013                    );
1014                    let new_tokens = backend.encode(&new_formatted)?;
1015                    generated.clear();
1016
1017                    let logits = backend.forward(&new_tokens, 0)?;
1018                    next_token = sample_token(&logits, &params)?;
1019                    continue;
1020                }
1021            }
1022        }
1023
1024        next_token = sample_token(&logits, &params)?;
1025    }
1026
1027    let text = backend.decode(&generated)?;
1028    Ok(strip_thinking(&text, params.thinking))
1029}
1030
1031#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1032/// Sample a token, suppressing specific token IDs (set to -inf before sampling).
1033pub fn sample_token_suppress(
1034    logits: &Tensor,
1035    params: &GenerateParams,
1036    suppress: &[u32],
1037) -> Result<u32, InferenceError> {
1038    if suppress.is_empty() {
1039        return sample_token(logits, params);
1040    }
1041    // Clone logits and set suppressed tokens to -inf
1042    let mut logits_vec: Vec<f32> = logits
1043        .squeeze(0)
1044        .unwrap_or(logits.clone())
1045        .to_dtype(candle_core::DType::F32)
1046        .map_err(|e| InferenceError::InferenceFailed(format!("dtype: {e}")))?
1047        .to_vec1()
1048        .map_err(|e| InferenceError::InferenceFailed(format!("to_vec: {e}")))?;
1049    // Handle 2D logits (take last row)
1050    let dims = logits.dims();
1051    if dims.len() == 2 {
1052        let vocab = dims[dims.len() - 1];
1053        let start = logits_vec.len() - vocab;
1054        logits_vec = logits_vec[start..].to_vec();
1055    }
1056    for &id in suppress {
1057        if (id as usize) < logits_vec.len() {
1058            logits_vec[id as usize] = f32::NEG_INFINITY;
1059        }
1060    }
1061    let modified = Tensor::from_vec(
1062        logits_vec,
1063        logits.squeeze(0).unwrap_or(logits.clone()).shape(),
1064        logits.device(),
1065    )
1066    .map_err(|e| InferenceError::InferenceFailed(format!("from_vec: {e}")))?;
1067    sample_token(&modified, params)
1068}
1069
1070#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1071/// Sample a token from logits using temperature + top-p + top-k.
1072pub fn sample_token(logits: &Tensor, params: &GenerateParams) -> Result<u32, InferenceError> {
1073    let logits = logits
1074        .squeeze(0)
1075        .map_err(|e| InferenceError::InferenceFailed(format!("squeeze: {e}")))?;
1076    let logits = logits
1077        .to_dtype(candle_core::DType::F32)
1078        .map_err(|e| InferenceError::InferenceFailed(format!("dtype: {e}")))?;
1079
1080    // Get last position's logits
1081    let dim = logits.dims();
1082    let logits = if dim.len() == 2 {
1083        logits
1084            .get(dim[0] - 1)
1085            .map_err(|e| InferenceError::InferenceFailed(format!("get last: {e}")))?
1086    } else {
1087        logits
1088    };
1089
1090    // Greedy decoding
1091    if params.temperature <= 0.0 {
1092        let token = logits
1093            .argmax(0)
1094            .map_err(|e| InferenceError::InferenceFailed(format!("argmax: {e}")))?
1095            .to_scalar::<u32>()
1096            .map_err(|e| InferenceError::InferenceFailed(format!("scalar: {e}")))?;
1097        return Ok(token);
1098    }
1099
1100    // Temperature scaling
1101    let logits = (&logits / params.temperature)
1102        .map_err(|e| InferenceError::InferenceFailed(format!("temp scale: {e}")))?;
1103
1104    let mut logits_vec: Vec<f32> = logits
1105        .to_vec1()
1106        .map_err(|e| InferenceError::InferenceFailed(format!("to_vec: {e}")))?;
1107
1108    // Top-k filtering
1109    if params.top_k > 0 && params.top_k < logits_vec.len() {
1110        let mut indexed: Vec<(usize, f32)> = logits_vec.iter().copied().enumerate().collect();
1111        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1112        let threshold = indexed[params.top_k].1;
1113        for v in &mut logits_vec {
1114            if *v < threshold {
1115                *v = f32::NEG_INFINITY;
1116            }
1117        }
1118    }
1119
1120    // Softmax
1121    let max_logit = logits_vec.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1122    let exp: Vec<f32> = logits_vec.iter().map(|&v| (v - max_logit).exp()).collect();
1123    let sum: f32 = exp.iter().sum();
1124    let mut probs: Vec<f32> = exp.iter().map(|&v| v / sum).collect();
1125
1126    // Top-p (nucleus) filtering
1127    if params.top_p < 1.0 {
1128        let mut sorted_indices: Vec<usize> = (0..probs.len()).collect();
1129        sorted_indices.sort_by(|&a, &b| {
1130            probs[b]
1131                .partial_cmp(&probs[a])
1132                .unwrap_or(std::cmp::Ordering::Equal)
1133        });
1134
1135        let mut cumsum = 0.0f32;
1136        let mut cutoff_idx = sorted_indices.len();
1137        for (i, &idx) in sorted_indices.iter().enumerate() {
1138            cumsum += probs[idx];
1139            if cumsum > params.top_p as f32 {
1140                cutoff_idx = i + 1;
1141                break;
1142            }
1143        }
1144
1145        let keep: std::collections::HashSet<usize> =
1146            sorted_indices[..cutoff_idx].iter().copied().collect();
1147        for (i, p) in probs.iter_mut().enumerate() {
1148            if !keep.contains(&i) {
1149                *p = 0.0;
1150            }
1151        }
1152
1153        // Renormalize
1154        let sum: f32 = probs.iter().sum();
1155        if sum > 0.0 {
1156            for p in &mut probs {
1157                *p /= sum;
1158            }
1159        }
1160    }
1161
1162    // Categorical sample
1163    let r: f32 = rand_f32();
1164    let mut cumsum = 0.0f32;
1165    for (i, &p) in probs.iter().enumerate() {
1166        cumsum += p;
1167        if cumsum >= r {
1168            return Ok(i as u32);
1169        }
1170    }
1171
1172    // Fallback: return highest prob token
1173    Ok(probs
1174        .iter()
1175        .enumerate()
1176        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
1177        .map(|(i, _)| i as u32)
1178        .unwrap_or(0))
1179}
1180
1181#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1182/// Random float in [0, 1) using the rand crate.
1183fn rand_f32() -> f32 {
1184    rand::random::<f32>()
1185}
1186
1187#[cfg(test)]
1188mod tool_format_tests {
1189    use super::*;
1190
1191    fn req_with(
1192        prompt: &str,
1193        tools: Option<Vec<serde_json::Value>>,
1194        messages: Option<Vec<Message>>,
1195    ) -> GenerateRequest {
1196        GenerateRequest {
1197            prompt: prompt.to_string(),
1198            tools,
1199            messages,
1200            ..Default::default()
1201        }
1202    }
1203
1204    #[test]
1205    fn catalog_identity_preconditions_round_trip_additively() {
1206        let request: GenerateRequest = serde_json::from_value(serde_json::json!({
1207            "prompt": "bound route",
1208            "expected_row_digest": "a".repeat(64),
1209            "expected_catalog_revision": "b".repeat(64),
1210        }))
1211        .unwrap();
1212        let encoded = serde_json::to_value(request).unwrap();
1213
1214        assert_eq!(encoded["expected_row_digest"], "a".repeat(64));
1215        assert_eq!(encoded["expected_catalog_revision"], "b".repeat(64));
1216    }
1217
1218    #[test]
1219    fn no_tools_no_messages_delegates_to_apply_chat_template() {
1220        let req = req_with("hello", None, None);
1221        assert_eq!(
1222            render_chat_prompt(&req),
1223            apply_chat_template("hello", None, req.params.thinking)
1224        );
1225    }
1226
1227    #[test]
1228    fn single_turn_with_tools_emits_tools_block_and_user_turn() {
1229        let tools = vec![serde_json::json!({
1230            "name": "get_weather",
1231            "description": "Get weather",
1232            "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
1233        })];
1234        let out = render_chat_prompt(&req_with("weather in Denver?", Some(tools), None));
1235        assert!(out.contains("<tools>"));
1236        assert!(out.contains("</tools>"));
1237        assert!(out.contains("get_weather"));
1238        // tools are normalized to the function-wrapper shape
1239        assert!(out.contains("\"type\":\"function\""));
1240        assert!(out.contains("<|im_start|>user\nweather in Denver?<|im_end|>"));
1241        assert!(out.trim_end().ends_with("<|im_start|>assistant"));
1242    }
1243
1244    #[test]
1245    fn already_wrapped_tool_is_not_double_wrapped() {
1246        let tools = vec![serde_json::json!({
1247            "type": "function",
1248            "function": {"name": "f", "description": "d", "parameters": {}}
1249        })];
1250        let out = render_chat_prompt(&req_with("hi", Some(tools), None));
1251        assert_eq!(out.matches("\"type\":\"function\"").count(), 1);
1252    }
1253
1254    #[test]
1255    fn multi_turn_renders_tool_result_and_assistant_tool_call() {
1256        let mut args = std::collections::HashMap::new();
1257        args.insert("city".to_string(), serde_json::json!("Denver"));
1258        let messages = vec![
1259            Message::System {
1260                content: "You are helpful.".into(),
1261            },
1262            Message::User {
1263                content: "weather?".into(),
1264            },
1265            Message::Assistant {
1266                content: String::new(),
1267                tool_calls: vec![ToolCall {
1268                    id: Some("call_0".into()),
1269                    name: "get_weather".into(),
1270                    arguments: args,
1271                }],
1272                thinking: vec![],
1273                model_id: None,
1274                local_last_resort: false,
1275            },
1276            Message::ToolResult {
1277                tool_use_id: "call_0".into(),
1278                content: "{\"temp\":72}".into(),
1279                provenance: Default::default(),
1280            },
1281        ];
1282        let out = render_chat_prompt(&req_with("", None, Some(messages)));
1283        assert!(out.contains("<|im_start|>system\nYou are helpful."));
1284        assert!(out.contains("<tool_call>"));
1285        assert!(out.contains("get_weather"));
1286        assert!(out.contains("<tool_response>\n{\"temp\":72}\n</tool_response>"));
1287        assert!(out.trim_end().ends_with("<|im_start|>assistant"));
1288    }
1289
1290    #[test]
1291    fn parse_extracts_single_tool_call_and_strips_tags() {
1292        let text = "Let me check.\n<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Denver\"}}\n</tool_call>";
1293        let (clean, calls) = parse_tool_calls(text);
1294        assert_eq!(calls.len(), 1);
1295        assert_eq!(calls[0].name, "get_weather");
1296        assert_eq!(
1297            calls[0].arguments.get("city").unwrap(),
1298            &serde_json::json!("Denver")
1299        );
1300        assert_eq!(calls[0].id.as_deref(), Some("call_0"));
1301        assert!(!clean.contains("<tool_call>"));
1302        assert_eq!(clean, "Let me check.");
1303    }
1304
1305    #[test]
1306    fn parse_extracts_multiple_tool_calls_with_distinct_ids() {
1307        let text = "<tool_call>\n{\"name\": \"a\", \"arguments\": {}}\n</tool_call><tool_call>\n{\"name\": \"b\", \"arguments\": {}}\n</tool_call>";
1308        let (_clean, calls) = parse_tool_calls(text);
1309        assert_eq!(calls.len(), 2);
1310        assert_eq!(calls[0].name, "a");
1311        assert_eq!(calls[1].name, "b");
1312        assert_eq!(calls[0].id.as_deref(), Some("call_0"));
1313        assert_eq!(calls[1].id.as_deref(), Some("call_1"));
1314    }
1315
1316    #[test]
1317    fn parse_plain_text_yields_no_calls() {
1318        let (clean, calls) = parse_tool_calls("just a normal answer");
1319        assert!(calls.is_empty());
1320        assert_eq!(clean, "just a normal answer");
1321    }
1322
1323    #[test]
1324    fn parse_malformed_block_is_left_intact_and_skipped() {
1325        let text = "before <tool_call>\nnot json\n</tool_call> after";
1326        let (clean, calls) = parse_tool_calls(text);
1327        assert!(calls.is_empty());
1328        // The unparseable block is not silently dropped.
1329        assert!(clean.contains("<tool_call>"));
1330    }
1331
1332    #[test]
1333    fn parse_arguments_as_encoded_string() {
1334        let text =
1335            "<tool_call>\n{\"name\": \"f\", \"arguments\": \"{\\\"k\\\": 1}\"}\n</tool_call>";
1336        let (_clean, calls) = parse_tool_calls(text);
1337        assert_eq!(calls.len(), 1);
1338        assert_eq!(calls[0].arguments.get("k").unwrap(), &serde_json::json!(1));
1339    }
1340}
1341
1342#[cfg(test)]
1343mod thinking_tests {
1344    use super::*;
1345
1346    #[test]
1347    fn auto_injects_no_directive_and_no_prefill() {
1348        let out = apply_chat_template("hi", None, ThinkingMode::Auto);
1349        assert!(!out.contains("/no_think"));
1350        assert!(!out.contains("/think"));
1351        assert!(!out.contains("<think>"));
1352        assert!(out.contains("<|im_start|>user\nhi<|im_end|>"));
1353    }
1354
1355    /// The build_context → prompt seam: an assembled context string must reach
1356    /// the rendered prompt (in the system slot, ahead of the user question)
1357    /// alongside the "Use the following context" preamble.
1358    #[test]
1359    fn assembled_context_lands_in_system_slot_before_user_question() {
1360        let out = apply_chat_template(
1361            "user question",
1362            Some("ASSEMBLED-CONTEXT-MARKER"),
1363            ThinkingMode::Auto,
1364        );
1365        assert!(
1366            out.contains("Use the following context"),
1367            "context preamble must be present: {out}"
1368        );
1369        assert!(
1370            out.contains("ASSEMBLED-CONTEXT-MARKER"),
1371            "assembled context must reach the prompt: {out}"
1372        );
1373        let marker_at = out.find("ASSEMBLED-CONTEXT-MARKER").unwrap();
1374        let question_at = out.find("user question").unwrap();
1375        assert!(
1376            marker_at < question_at,
1377            "context must precede the user question: {out}"
1378        );
1379    }
1380
1381    #[test]
1382    fn off_injects_no_think_on_own_line_and_prefills_empty_think() {
1383        let out = apply_chat_template("hi", None, ThinkingMode::Off);
1384        // Directive on its own line, not concatenated onto prose.
1385        assert!(out.contains("\n/no_think<|im_end|>"));
1386        assert!(!out.contains(" /no_think"));
1387        // Closed empty thinking block pre-filled after assistant marker
1388        // — the upstream jinja hard-switch for enable_thinking=False.
1389        assert!(out.contains("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
1390    }
1391
1392    #[test]
1393    fn on_injects_think_and_no_prefill() {
1394        let out = apply_chat_template("hi", None, ThinkingMode::On);
1395        assert!(out.contains("\n/think<|im_end|>"));
1396        assert!(!out.contains("/no_think"));
1397        assert!(!out.contains("<think>"));
1398    }
1399
1400    #[test]
1401    fn pre_formatted_prompt_is_untouched() {
1402        let pre = "<|im_start|>system\ncustom<|im_end|>\n<|im_start|>user\nhi<|im_end|>";
1403        let out = apply_chat_template(pre, None, ThinkingMode::Off);
1404        assert_eq!(out, pre);
1405    }
1406
1407    #[test]
1408    fn directive_appears_after_context_not_before() {
1409        let out = apply_chat_template("q?", Some("some memory"), ThinkingMode::Off);
1410        let ctx_idx = out.find("some memory").unwrap();
1411        let directive_idx = out.find("/no_think").unwrap();
1412        assert!(
1413            directive_idx > ctx_idx,
1414            "directive must appear after context so user memory cannot nudge the parse"
1415        );
1416    }
1417
1418    #[test]
1419    fn default_params_is_auto() {
1420        assert_eq!(GenerateParams::default().thinking, ThinkingMode::Auto);
1421    }
1422
1423    #[test]
1424    fn thinking_mode_serde_snake_case() {
1425        let json = serde_json::to_string(&ThinkingMode::Off).unwrap();
1426        assert_eq!(json, "\"off\"");
1427        let parsed: ThinkingMode = serde_json::from_str("\"on\"").unwrap();
1428        assert_eq!(parsed, ThinkingMode::On);
1429    }
1430
1431    #[test]
1432    fn strip_preserves_thinking_when_on() {
1433        let text = "<think>reasoning here</think>the answer";
1434        let out = strip_thinking(text, ThinkingMode::On);
1435        assert_eq!(
1436            out, text,
1437            "On mode must return raw text with <think> visible"
1438        );
1439    }
1440
1441    #[test]
1442    fn strip_removes_thinking_when_auto_or_off() {
1443        let text = "<think>reasoning</think>the answer";
1444        assert_eq!(strip_thinking(text, ThinkingMode::Auto), "the answer");
1445        assert_eq!(strip_thinking(text, ThinkingMode::Off), "the answer");
1446    }
1447
1448    #[test]
1449    fn strip_returns_empty_on_unterminated_think() {
1450        // Output was cut off mid-thinking — don't leak the dangling tag.
1451        let text = "<think>mid-reasoning, never closed";
1452        assert_eq!(strip_thinking(text, ThinkingMode::Auto), "");
1453        assert_eq!(strip_thinking(text, ThinkingMode::Off), "");
1454        // On mode still returns the raw text — caller asked for it.
1455        assert_eq!(strip_thinking(text, ThinkingMode::On), text);
1456    }
1457
1458    #[test]
1459    fn strip_is_noop_when_no_think_tag() {
1460        let text = "just a plain answer";
1461        assert_eq!(strip_thinking(text, ThinkingMode::Auto), text);
1462        assert_eq!(strip_thinking(text, ThinkingMode::Off), text);
1463        assert_eq!(strip_thinking(text, ThinkingMode::On), text);
1464    }
1465}
1466
1467#[cfg(test)]
1468mod workload_tests {
1469    use super::*;
1470
1471    #[test]
1472    fn all_workload_weights_sum_to_one() {
1473        for w in [
1474            RoutingWorkload::Interactive,
1475            RoutingWorkload::Batch,
1476            RoutingWorkload::Background,
1477            RoutingWorkload::LocalPreferred,
1478            RoutingWorkload::Fastest,
1479        ] {
1480            let (q, l, c) = w.weights();
1481            let sum = q + l + c;
1482            assert!(
1483                (sum - 1.0).abs() < 1e-6,
1484                "weights for {w:?} sum to {sum}, expected 1.0"
1485            );
1486        }
1487    }
1488
1489    #[test]
1490    fn fastest_weights_dominate_on_latency() {
1491        let (q, l, c) = RoutingWorkload::Fastest.weights();
1492        // Latency should be the largest by a wide margin — that's the
1493        // whole point of this workload class.
1494        assert!(l > q && l > c);
1495        assert!(l >= 0.7, "latency weight too small: {l}");
1496    }
1497
1498    #[test]
1499    fn fastest_is_latency_sensitive() {
1500        assert!(RoutingWorkload::Fastest.is_latency_sensitive());
1501    }
1502}