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)?, ¶ms.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, ¶ms)?;
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, ¶ms)?;
1019 continue;
1020 }
1021 }
1022 }
1023
1024 next_token = sample_token(&logits, ¶ms)?;
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}