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