gglib_core/domain/template_caps.rs
1//! llama-server's per-template capability self-report, and the tri-state
2//! gglib holds it in.
3//!
4//! [ADR 0007]'s observation: the pinned build computes, per loaded chat
5//! template, a `chat_template_caps` structure (`jinja::caps`,
6//! `common/jinja/caps.h`) by **executing the template with instrumented
7//! variable access**, and publishes the result unconditionally on
8//! `GET /props`. gglib reads that self-report rather than building a
9//! detector of its own — a gglib reimplementation could only ever *disagree*
10//! with the renderer it is trying to predict, and every disagreement would be
11//! a bug on gglib's side by construction.
12//!
13//! # A report, not a conservative baseline
14//!
15//! Five of the nine bools default `true` upstream (`caps.h:11-14,23`), so an
16//! absent field must never be read as `false` — which is why every field here
17//! is `Option<bool>` and none carries a `#[serde(default)]`-to-`false`. On
18//! the measured pinned build (`b1-10bf611`) the distinction never arises on
19//! the wire: all nine keys are serialized verbatim with explicit
20//! `true`/`false` on every config, including the no-template fallback. The
21//! `Option` exists for the build where that stops holding.
22//!
23//! # The tri-state is never collapsed
24//!
25//! [`TemplateCapsState`] mirrors `BaselineState` in `gglib-proxy`'s `props`
26//! module: "nobody has read it yet", "the read failed, and here is why", and
27//! "here is what was read" are three different facts, and collapsing the
28//! first two into the third's negative is exactly how unknown starts to gate
29//! (ADR 0007, Consequences). [`reasoning_effort_support`] applies the same
30//! rule one level down: a caps object whose field is absent answers
31//! [`Support::Unknown`], never [`Support::No`].
32//!
33//! [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
34
35use serde::{Deserialize, Serialize};
36
37/// The nine bools of `chat_template_caps`, as `GET /props` reports them.
38///
39/// Field names are byte-for-byte the wire keys measured on the pinned build
40/// (`b1-10bf611`) — `template_caps_tests` pins the full list against a
41/// fixture transcribed from that measurement, so an upstream rename or
42/// addition fails loudly rather than silently reading as absent.
43///
44/// Every field is `Option<bool>`: `None` means the server did not report the
45/// key, which — five defaults being `true` upstream — licenses no conclusion
46/// in either direction. Unknown fields in the body are ignored (no
47/// `deny_unknown_fields`): a future build adding a tenth cap must not make
48/// the nine known ones unreadable.
49#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
50pub struct TemplateCaps {
51 /// Whether the template reads `tools`. Upstream default: `true`.
52 pub supports_tools: Option<bool>,
53 /// Whether the template renders assistant `tool_calls`. Upstream
54 /// default: `true`.
55 pub supports_tool_calls: Option<bool>,
56 /// Whether the template accepts a `system` role. Upstream default:
57 /// `true`.
58 pub supports_system_role: Option<bool>,
59 /// Whether multiple tool calls may appear in one assistant turn.
60 /// Upstream default: `true`.
61 pub supports_parallel_tool_calls: Option<bool>,
62 /// Whether reasoning traces survive in the full history rather than only
63 /// the last assistant message. Upstream default: `false`.
64 pub supports_preserve_reasoning: Option<bool>,
65 /// Whether the template **reads** the `reasoning_effort` variable (or its
66 /// `reasoning_strength` alias — the probe binds both, `caps.cpp:29-32`).
67 /// Upstream default: `false`.
68 ///
69 /// Read, not honoured: `stats.used` says the variable was accessed during
70 /// an instrumented render, not that any particular level changes the
71 /// output (ADR 0007 findings 2 and 3).
72 pub supports_reasoning_effort: Option<bool>,
73 /// Whether message content may be a plain string. Upstream default:
74 /// `true`.
75 pub supports_string_content: Option<bool>,
76 /// Whether message content may be the typed parts array. Upstream
77 /// default: `false`.
78 pub supports_typed_content: Option<bool>,
79 /// Whether tool-call arguments may be a JSON object rather than a
80 /// string. Upstream default: `false`.
81 pub supports_object_arguments: Option<bool>,
82}
83
84/// What gglib currently holds about a model's template caps.
85///
86/// Shaped like `BaselineState` in `gglib-proxy::props`, for the same reason:
87/// an `Option<TemplateCaps>` flattens "nobody has read it yet" and "the read
88/// was attempted and failed" into one `None`, after which the only thing a
89/// surface can say is "not read yet" — a claim about a read that did happen.
90/// ADR 0007's tri-state (supported / not supported / never observed) needs
91/// the distinction held all the way down.
92#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
93#[serde(tag = "state", rename_all = "snake_case")]
94pub enum TemplateCapsState {
95 /// No `/props` read has completed for the running model yet. The
96 /// ordinary state for the first second of a launch.
97 #[default]
98 NotYetRead,
99 /// The read was attempted and produced no caps — the endpoint was
100 /// unreachable, the body unparseable, or (a pre-caps build) the key
101 /// absent.
102 Unreadable {
103 /// Cause, in words a dashboard can show.
104 reason: String,
105 },
106 /// The server reported its template's caps.
107 Read {
108 /// The self-report, verbatim.
109 caps: TemplateCaps,
110 },
111}
112
113impl TemplateCapsState {
114 /// The caps, when they were read.
115 #[must_use]
116 pub const fn caps(&self) -> Option<&TemplateCaps> {
117 match self {
118 Self::Read { caps } => Some(caps),
119 Self::NotYetRead | Self::Unreadable { .. } => None,
120 }
121 }
122}
123
124/// One capability's answer, with unknown kept distinct from no.
125///
126/// The `ModelContext::catalog_resolved` discipline, applied to a self-report:
127/// an observation that failed to arrive must not masquerade as one that
128/// arrived negative (ADR 0007 decision 3 — unknown never gates).
129/// `Deserialize` as well as `Serialize`, unlike the rest of this module: this
130/// one crosses the HTTP boundary in both directions, because `ModelDetailDto`
131/// carries it and the CLI reads that DTO back out of `--json`.
132///
133/// [`Default`] is [`Self::Unknown`], which is the only safe default there is —
134/// a client omitting the field must never be read as a positive "no".
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
137#[serde(rename_all = "snake_case")]
138pub enum Support {
139 /// The observed template positively reads the variable.
140 Yes,
141 /// The observed template positively does not read it.
142 No,
143 /// Never observed, or the observation did not carry this field.
144 #[default]
145 Unknown,
146}
147
148/// Whether a model's template reads `reasoning_effort`, from its recorded
149/// caps.
150///
151/// [`Support::Unknown`] both when no caps were ever recorded (`None` — the
152/// tri-state's "never observed") and when the recorded caps did not carry the
153/// field. Only an explicit `false` answers [`Support::No`] — the arm ADR
154/// 0007's suppression (a later PR) is allowed to act on.
155#[must_use]
156pub fn reasoning_effort_support(caps: &Option<TemplateCaps>) -> Support {
157 match caps.as_ref().and_then(|c| c.supports_reasoning_effort) {
158 Some(true) => Support::Yes,
159 Some(false) => Support::No,
160 None => Support::Unknown,
161 }
162}
163
164#[cfg(test)]
165#[path = "template_caps_tests.rs"]
166mod template_caps_tests;