Skip to main content

harness/openai_compatible/
profile.rs

1//! How much prompt a run can afford, and how much guidance its model needs.
2//!
3//! These pull in opposite directions. A small local model needs the read-only
4//! rules spelled out — a 1B model handed the full tool surface recites the
5//! schemas back as prose instead of calling one — while the small context
6//! window it usually comes with is exactly what cannot afford that surface.
7//!
8//! So the two profiles differ in *where* the tokens go, not only in how many.
9//! [`PromptProfile::Compact`] withholds the optional tools and spends part of
10//! what it saves on plainer instructions; [`PromptProfile::Full`] offers
11//! everything and trusts the model to infer the rest.
12//!
13//! Selection keys on facts the backend reports — [`ModelFacts`] — not on the
14//! model's name. A name is a guess needing per-vendor upkeep; a window and a
15//! parameter count are measured, and between them they answer both halves of
16//! the question. Neither alone is enough: `llama3.2:1b` advertises a 131k
17//! window, and a 70B model can still be served on a 4k one.
18
19/// Context windows at or below this get [`PromptProfile::Compact`].
20///
21/// A full surface costs roughly 1.5k tokens before the conversation starts.
22/// At 16k that is affordable; at 8k — Ollama's fallback, and near what a
23/// `llama-server` is typically started with — it is most of the budget.
24pub const COMPACT_AT_OR_BELOW_TOKENS: u64 = 16_384;
25
26/// Models at or below this many billion parameters get [`PromptProfile::Compact`].
27///
28/// A separate question from the window, and the reason both are needed: context
29/// says what a run can *afford* to send, parameters say what the model can be
30/// trusted to *do* with it. `llama3.2:1b` advertises a 131k window and would
31/// pass the context test comfortably, yet handed eleven tool schemas it recites
32/// them back as prose and loops to the turn limit inventing tools. Reliable
33/// tool-calling starts around 7B.
34pub const COMPACT_AT_OR_BELOW_PARAMS_B: f64 = 7.0;
35
36/// What a backend was able to tell us about a model and how it is served.
37///
38/// Both measurements are independently optional: no backend reports both, and
39/// a bare `llama-server` reports neither. `served_locally` is always known, and
40/// is what decides the case where the measurements are absent.
41#[derive(Clone, Copy, Debug, Default, PartialEq)]
42pub struct ModelFacts {
43    /// Usable context window in tokens. Ollama reports it via `/api/show`,
44    /// OpenRouter via `context_length`; a bare `llama-server` does not.
45    pub context_tokens: Option<u64>,
46    /// Parameter count in billions. Ollama reports it; hosted providers
47    /// generally do not.
48    pub parameters_b: Option<f64>,
49    /// Whether the endpoint is a model served on this machine or the local
50    /// network. Decides the profile when neither measurement is available: what
51    /// people run locally is small and configured modestly, and the observed
52    /// failure there is a hard refusal rather than a slightly narrower run.
53    pub served_locally: bool,
54}
55
56/// Whether `base_url` points at a locally served model — this machine or the
57/// local network.
58///
59/// Not a security boundary; it only picks a default. A private address is
60/// included because "Ollama on the box under the desk" is the same situation as
61/// Ollama on this one: a self-hosted model, modestly configured, with no
62/// catalog to ask about it.
63pub fn is_local_endpoint(base_url: &str) -> bool {
64    is_local_host(host_of(base_url))
65}
66
67/// The host part of a URL: after the scheme, before the path, without the port
68/// or IPv6 brackets.
69fn host_of(base_url: &str) -> &str {
70    let after_scheme = base_url.split_once("//").map_or(base_url, |(_, rest)| rest);
71    let authority = after_scheme.split('/').next().unwrap_or("");
72    // An IPv6 literal is bracketed and full of colons, so unwrap it before
73    // trying to strip a port.
74    match authority.strip_prefix('[') {
75        Some(rest) => rest.split(']').next().unwrap_or(rest),
76        None => authority.rsplit_once(':').map_or(authority, |(host, _)| host),
77    }
78}
79
80fn is_local_host(host: &str) -> bool {
81    if matches!(host, "localhost" | "::1" | "0.0.0.0") || host.ends_with(".local") {
82        return true;
83    }
84    // Every label must be an octet, or this is a name that merely looks numeric
85    // (`172.1.2.3.example.com` is somebody's public host).
86    let Some(octets) = host.split('.').map(|label| label.parse::<u8>().ok()).collect::<Option<Vec<_>>>()
87    else {
88        return false;
89    };
90    match octets[..] {
91        [127, ..] | [10, _, _, _] | [192, 168, _, _] => true,
92        [172, second, _, _] => (16..=31).contains(&second),
93        _ => false,
94    }
95}
96
97/// Which prompt and tool surface a run gets.
98#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
99pub enum PromptProfile {
100    /// Decide from [`ModelFacts`], falling back to [`Self::Full`] when the
101    /// backend reported nothing useful.
102    #[default]
103    Auto,
104    /// Every tool, terse instructions.
105    Full,
106    /// Core tools only, explicit instructions.
107    Compact,
108}
109
110/// The tools a [`PromptProfile::Compact`] run keeps. Everything else is
111/// withheld: each costs schema tokens in every request, and a model small
112/// enough to need this profile does worse the more choices it is given.
113///
114/// `read`/`list`/`glob`/`grep` are how a run finds and inspects files;
115/// `write`/`edit`/`bash` are how it changes them (already withheld in
116/// [`crate::RunMode::Ask`]). Nothing here is optional to a coding task.
117const CORE_TOOLS: &[&str] = &["read", "list", "glob", "grep", "write", "edit", "bash"];
118
119impl PromptProfile {
120    /// How many bytes of skills catalog this profile will carry inline.
121    ///
122    /// The catalog is one line per skill and it is paid on every request. Twenty
123    /// skills is roughly 9 KB — fine against a 128k window, most of a 4k one.
124    /// Past the budget the catalog moves out of the prompt and the `skill` tool
125    /// serves it to the one request that asks, which is the same progressive
126    /// disclosure already used for skill bodies, applied one level up.
127    pub(crate) fn catalog_budget_bytes(self) -> usize {
128        match self {
129            Self::Compact => 1_024,
130            _ => 8_192,
131        }
132    }
133
134    /// The profile to actually use, resolving [`Self::Auto`] against what the
135    /// backend reported.
136    ///
137    /// Either measurement alone is enough to choose [`Self::Compact`]: a small
138    /// window cannot fit the full surface, and a small model cannot use it.
139    ///
140    /// When neither is available the endpoint decides. A hosted one gets
141    /// [`Self::Full`] — withholding tools from a frontier model narrows the run
142    /// silently, which is the harder error to notice. A local one gets
143    /// [`Self::Compact`], because a self-hosted model is usually small and
144    /// started on a modest context, and there the error is loud: `llama-server`
145    /// refuses the whole request rather than answering a little worse.
146    ///
147    /// A host that knows better overrides with an explicit profile.
148    pub fn resolve(self, facts: ModelFacts) -> Self {
149        let Self::Auto = self else { return self };
150        let cramped = facts.context_tokens.is_some_and(|t| t <= COMPACT_AT_OR_BELOW_TOKENS);
151        let small = facts.parameters_b.is_some_and(|p| p <= COMPACT_AT_OR_BELOW_PARAMS_B);
152        let unmeasured = facts.context_tokens.is_none() && facts.parameters_b.is_none();
153        if cramped || small || (unmeasured && facts.served_locally) {
154            Self::Compact
155        } else {
156            Self::Full
157        }
158    }
159
160    /// Tool ids this profile withholds, on top of whatever the host disabled.
161    /// Empty for [`Self::Full`].
162    pub(crate) fn withheld_tools(self, all: &[String]) -> Vec<String> {
163        match self {
164            Self::Compact => {
165                all.iter().filter(|id| !CORE_TOOLS.contains(&id.as_str())).cloned().collect()
166            }
167            _ => Vec::new(),
168        }
169    }
170
171    /// The base system prompt for this profile.
172    pub(crate) fn system_prompt(self) -> &'static str {
173        match self {
174            Self::Compact => COMPACT_SYSTEM_PROMPT,
175            _ => FULL_SYSTEM_PROMPT,
176        }
177    }
178}
179
180/// The default base prompt.
181///
182/// The text lives in a file rather than a `const` with backslash
183/// continuations: continuations silently swallow the next line's indentation,
184/// which made a stray double space a real and recurring defect, and a diff of
185/// the prose is unreadable when every line ends in `\`. Codex and OpenCode both
186/// keep their prompts as files for the same reason.
187pub(crate) const FULL_SYSTEM_PROMPT: &str = include_str!("prompts/full.md");
188
189/// The base prompt for a small model on a small context.
190///
191/// Shorter than [`FULL_SYSTEM_PROMPT`] but not by trimming the rules — the
192/// rules are what a weak model gets wrong. What goes is the prose: every line
193/// is one imperative, because a model that cannot reliably call a tool also
194/// cannot reliably parse a paragraph about when to.
195pub(crate) const COMPACT_SYSTEM_PROMPT: &str = include_str!("prompts/compact.md");
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn all_tools() -> Vec<String> {
202        ["read", "glob", "grep", "list", "webfetch", "todowrite", "question", "skill", "summarize",
203         "websearch", "write", "edit", "bash", "applypatch", "task"]
204            .iter()
205            .map(|s| (*s).to_owned())
206            .collect()
207    }
208
209    fn window(tokens: u64) -> ModelFacts {
210        ModelFacts { context_tokens: Some(tokens), ..ModelFacts::default() }
211    }
212
213    #[test]
214    fn a_cramped_window_picks_compact() {
215        assert_eq!(PromptProfile::Auto.resolve(window(4_096)), PromptProfile::Compact);
216        assert_eq!(PromptProfile::Auto.resolve(window(8_192)), PromptProfile::Compact);
217        assert_eq!(
218            PromptProfile::Auto.resolve(window(COMPACT_AT_OR_BELOW_TOKENS)),
219            PromptProfile::Compact
220        );
221        assert_eq!(PromptProfile::Auto.resolve(window(32_768)), PromptProfile::Full);
222    }
223
224    #[test]
225    fn a_small_model_picks_compact_however_large_its_window() {
226        // The case the window alone gets wrong, and the reason both facts are
227        // read: llama3.2:1b advertises 131k and cannot use eleven tools.
228        let tiny_but_roomy =
229            ModelFacts { context_tokens: Some(131_072), parameters_b: Some(1.2), ..Default::default() };
230        assert_eq!(PromptProfile::Auto.resolve(tiny_but_roomy), PromptProfile::Compact);
231
232        let big_model =
233            ModelFacts { context_tokens: Some(131_072), parameters_b: Some(24.0), ..Default::default() };
234        assert_eq!(PromptProfile::Auto.resolve(big_model), PromptProfile::Full);
235    }
236
237    #[test]
238    fn a_big_model_on_a_cramped_window_still_picks_compact() {
239        // The mirror case: capable model, no room. Either fact alone decides.
240        let squeezed =
241            ModelFacts { context_tokens: Some(4_096), parameters_b: Some(70.0), ..Default::default() };
242        assert_eq!(PromptProfile::Auto.resolve(squeezed), PromptProfile::Compact);
243    }
244
245    #[test]
246    fn one_measurement_is_enough_to_stop_guessing_from_the_endpoint() {
247        // `unmeasured` needs BOTH facts missing. Requiring only one would change
248        // the answer for the shape llama.cpp actually has: a local server that
249        // reports its window via /props and has no parameter count to report.
250        // A roomy one would drop to Compact purely for being local.
251        let llama_cpp = ModelFacts {
252            context_tokens: Some(32_768),
253            parameters_b: None,
254            served_locally: true,
255        };
256        assert_eq!(
257            PromptProfile::Auto.resolve(llama_cpp),
258            PromptProfile::Full,
259            "a measured window is an answer; the endpoint only decides when nothing is known"
260        );
261
262        // The mirror: a known-large model on a local endpoint whose window we
263        // could not read.
264        let known_model = ModelFacts {
265            context_tokens: None,
266            parameters_b: Some(24.0),
267            served_locally: true,
268        };
269        assert_eq!(PromptProfile::Auto.resolve(known_model), PromptProfile::Full);
270
271        // And a small measurement still wins over the endpoint being local.
272        let small_local = ModelFacts {
273            context_tokens: Some(4_096),
274            parameters_b: None,
275            served_locally: true,
276        };
277        assert_eq!(PromptProfile::Auto.resolve(small_local), PromptProfile::Compact);
278    }
279
280    #[test]
281    fn the_catalog_budget_differs_by_profile_and_is_never_nothing() {
282        // A budget of 0 defers every catalog, however small; a budget the same
283        // for both profiles makes the profile pointless here. Neither shows up
284        // as a failure anywhere else.
285        let full = PromptProfile::Full.catalog_budget_bytes();
286        let compact = PromptProfile::Compact.catalog_budget_bytes();
287
288        assert!(compact > 0 && full > 0, "zero would defer a one-line catalog");
289        assert!(compact < full, "the compact profile carries less, not the same: {compact} vs {full}");
290        assert_eq!(full, 8 * 1024);
291        assert_eq!(compact, 1024);
292    }
293
294    #[test]
295    fn nothing_reported_from_a_hosted_endpoint_keeps_the_full_surface() {
296        // Withholding tools from a frontier model narrows the run silently, so
297        // an absent measurement must not by itself trigger the smaller profile.
298        assert_eq!(PromptProfile::Auto.resolve(ModelFacts::default()), PromptProfile::Full);
299    }
300
301    #[test]
302    fn nothing_reported_from_a_local_endpoint_gets_guidance() {
303        // The llama-server case: it reports no window, and guessing Full there
304        // produced a 400 for the whole request rather than a worse answer.
305        let local = ModelFacts { served_locally: true, ..Default::default() };
306        assert_eq!(PromptProfile::Auto.resolve(local), PromptProfile::Compact);
307
308        // A measurement still wins over the location.
309        let roomy_local =
310            ModelFacts { context_tokens: Some(131_072), parameters_b: Some(24.0), served_locally: true };
311        assert_eq!(PromptProfile::Auto.resolve(roomy_local), PromptProfile::Full);
312    }
313
314    #[test]
315    fn local_endpoints_are_recognised_by_address() {
316        for local in [
317            "http://localhost:11434",
318            "http://127.0.0.1:8080",
319            "http://[::1]:8080",
320            "http://192.168.1.14:11434",
321            "http://10.0.0.5:8080",
322            "http://172.16.4.2:8080",
323            "http://studio.local:1234",
324        ] {
325            assert!(is_local_endpoint(local), "{local} should read as local");
326        }
327        for hosted in [
328            "https://openrouter.ai/api",
329            "https://api.deepseek.com",
330            "https://172.1.2.3.example.com",
331            "https://api.together.xyz/v1",
332        ] {
333            assert!(!is_local_endpoint(hosted), "{hosted} should read as hosted");
334        }
335    }
336
337    #[test]
338    fn an_explicit_profile_ignores_every_fact() {
339        assert_eq!(PromptProfile::Full.resolve(window(2_048)), PromptProfile::Full);
340        assert_eq!(PromptProfile::Compact.resolve(window(200_000)), PromptProfile::Compact);
341    }
342
343    #[test]
344    fn compact_keeps_the_core_and_withholds_the_rest() {
345        let withheld = PromptProfile::Compact.withheld_tools(&all_tools());
346
347        for core in CORE_TOOLS {
348            assert!(!withheld.contains(&(*core).to_owned()), "{core} must survive");
349        }
350        for optional in ["webfetch", "websearch", "todowrite", "summarize", "task", "skill"] {
351            assert!(withheld.contains(&optional.to_owned()), "{optional} must be withheld");
352        }
353    }
354
355    #[test]
356    fn full_withholds_nothing() {
357        assert!(PromptProfile::Full.withheld_tools(&all_tools()).is_empty());
358    }
359
360    #[test]
361    fn the_compact_prompt_is_smaller_but_keeps_every_rule() {
362        let full = PromptProfile::Full.system_prompt();
363        let compact = PromptProfile::Compact.system_prompt();
364        assert!(compact.len() < full.len(), "compact: {} full: {}", compact.len(), full.len());
365
366        // The rules a small model gets wrong are exactly the ones that must
367        // survive the cut — this is the whole reason the profile exists.
368        assert!(compact.contains("READ-ONLY"));
369        assert!(compact.contains("do NOT try again"), "no retry after a read-only refusal");
370        assert!(compact.contains("Never guess"), "no inventing file names");
371        assert!(compact.contains("stop calling tools"), "must know when to finish");
372    }
373}
374