harness/openai_compatible/mod.rs
1//! A **direct-model** harness: it speaks the OpenAI-compatible chat API over
2//! HTTP and runs the agent loop in Rust — owning the read/write tool surface
3//! — instead of wrapping a CLI. One adapter serves every OpenAI-compatible
4//! endpoint (local Ollama, OpenRouter, vLLM, LM Studio, …); the vendor is
5//! configuration, not a type, so [`OpenHarness::ollama`] and
6//! [`OpenHarness::custom`] are constructors, not separate structs.
7//!
8//! Auth: a host that already holds the secret passes it as `api_key`, which
9//! never touches the environment — an exported variable is inherited by every
10//! child this crate spawns, including the `bash` tool, which would put the key
11//! within reach of the model. `api_key_env` names a variable to read instead,
12//! for CI and headless runs. `None` for both means no auth, the local Ollama
13//! case. Edits land on disk directly (`previews_edits: false`), gated
14//! only by [`RunMode`] (Ask = read-only) — review stays in the host, exactly
15//! as for the CLI adapters.
16//!
17//! Sessions persist when a session dir is configured (`with_session_dir`): each
18//! run writes its transcript and `RunRequest.resume` continues a prior session
19//! by id; without one, runs are ephemeral. `with_context_tokens` enables
20//! compaction near the context limit. Responses stream — each token fragment
21//! arrives as a `RunEvent::Text`.
22
23use std::path::PathBuf;
24use std::sync::atomic::AtomicBool;
25use std::sync::Arc;
26
27use serde_json::Value;
28
29// Core agent-harness types this module builds on. It's a submodule of
30// agent-harness (the `openai-compatible` feature), so they come from the crate
31// root: the `Harness` trait it implements + the request/metadata types it uses.
32use crate::{
33 CredentialSpec, Harness, Features, Error, Info, ModelChoice, InstallHint,
34 Readiness, InstalledModel, ModelManagement, PullProgressCallback,
35 RunCallback, RunHandle, RunRequest,
36};
37
38mod chat;
39mod instructions;
40pub use instructions::InstructionSources;
41mod ollama;
42mod openai_models;
43mod profile;
44pub use profile::{ModelFacts, PromptProfile, COMPACT_AT_OR_BELOW_PARAMS_B, COMPACT_AT_OR_BELOW_TOKENS};
45mod run;
46mod session;
47mod skills;
48pub use skills::global_skill_roots;
49mod tools;
50mod wire;
51
52pub use session::SessionRecord;
53pub use tools::mcp::{McpPrompt, McpPromptArg, McpServer, McpTransport, PromptMessage};
54
55/// Cap on the context window we ask Ollama to load (`num_ctx`). `/api/show` may
56/// report a model's full trained context (often 128k+); loading that much KV
57/// cache can exhaust memory, and ~32k is ample for the system prompt + tools + a
58/// working file. The documented sweet spot for local tool-calling is 16–32k.
59const OLLAMA_CTX_CEILING: u64 = 32_768;
60/// Fallback `num_ctx` when a model's context can't be probed — still well above
61/// Ollama's 4096 default, which silently truncates our system prompt.
62const OLLAMA_CTX_DEFAULT: u64 = 8_192;
63
64/// A `llama-server`'s loaded context window, from `/props`. `None` when the
65/// endpoint is not llama.cpp, has no model loaded, or does not answer — every
66/// one of which means "unknown", which the profile already handles.
67fn local_server_context(base_url: &str) -> Option<u64> {
68 let response = ureq::get(&format!("{base_url}/props"))
69 .timeout(std::time::Duration::from_secs(2))
70 .call()
71 .ok()?;
72 let body: Value = response.into_json().ok()?;
73 // Zero is what it reports before a model is loaded — absent, not a window.
74 body.get("default_generation_settings")?
75 .get("n_ctx")?
76 .as_u64()
77 .filter(|n| *n > 0)
78}
79
80/// How a harness instance discovers its model list for [`Harness::list_models`].
81enum Discovery {
82 /// Query Ollama's `/api/tags` live.
83 OllamaTags,
84 /// A fixed list declared up front (any other OpenAI-compatible endpoint).
85 Static(Vec<ModelChoice>),
86 /// The models.dev catalog filtered to a provider id — a cloud endpoint that
87 /// proxies a known provider (`"anthropic"`, `"openai"`, …).
88 ModelsDev(String),
89 /// Query the endpoint's own `/v1/models` — the OpenAI-standard list, which
90 /// is the right default for a server nobody has written an adapter for.
91 /// `fallback` is offered when the endpoint does not serve it, so a picker
92 /// still shows the model the host was configured with.
93 OpenAiModels { fallback: Vec<ModelChoice> },
94}
95
96/// A named subagent the `task` tool can spawn (its own role prompt + optional
97/// model), registered via [`OpenHarness::with_agent`].
98#[derive(Debug, Clone)]
99pub struct AgentDef {
100 /// One-line description shown to the model when it picks a `subagent_type`.
101 pub description: String,
102 /// The subagent's system prompt (replaces the default coding-assistant base);
103 /// `None` keeps the default.
104 pub system_prompt: Option<String>,
105 /// Model override for the subagent; `None` uses the run's model.
106 pub model: Option<String>,
107}
108
109/// Per-token pricing for a model, so a run can attach an estimated cost to
110/// [`crate::RunEvent::Usage`]. Register with
111/// [`OpenHarness::with_model_cost`]. Rates are USD per million tokens.
112#[derive(Debug, Clone, Copy)]
113pub struct ModelCost {
114 /// USD per million input (prompt) tokens.
115 pub input_per_mtok: f64,
116 /// USD per million output (completion) tokens.
117 pub output_per_mtok: f64,
118 /// USD per million cache-read tokens (often ~0.1x input); `None` falls back
119 /// to the input rate.
120 pub cache_read_per_mtok: Option<f64>,
121}
122
123/// Whether a matched [`PermissionRule`] allows or denies the call.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum Permission {
126 Allow,
127 Deny,
128 /// Defer to the host's permission prompt
129 /// ([`OpenHarness::with_permission_prompt`]); treated as `Deny`
130 /// when no prompt is set.
131 Ask,
132}
133
134/// A pre-execution gate on tool calls — the non-interactive subset of OpenCode's
135/// permission system. Rules are checked in order before a tool runs; the first
136/// whose `tool` and `pattern` match decides (allow or deny); no match → allowed.
137/// Use it to deny specific dangerous calls (e.g. `bash` matching `rm -rf`) that
138/// `RunMode`'s coarse read-only/edit gate can't express. (OpenCode's interactive
139/// "ask" isn't modeled — a library has no channel to prompt mid-run; host review
140/// remains the backstop.)
141#[derive(Debug, Clone)]
142pub struct PermissionRule {
143 /// Tool id this applies to (e.g. `"bash"`, `"edit"`); `None` = any tool.
144 pub tool: Option<String>,
145 /// Substring the call's subject must contain to match (for `bash` the
146 /// command, for file tools the path); `None` = any call to the tool.
147 pub pattern: Option<String>,
148 /// Allow or deny when matched.
149 pub effect: Permission,
150}
151
152impl PermissionRule {
153 /// Deny every call to `tool`.
154 pub fn deny(tool: impl Into<String>) -> Self {
155 Self { tool: Some(tool.into()), pattern: None, effect: Permission::Deny }
156 }
157
158 /// Deny calls to `tool` whose subject contains `pattern`.
159 pub fn deny_matching(tool: impl Into<String>, pattern: impl Into<String>) -> Self {
160 Self { tool: Some(tool.into()), pattern: Some(pattern.into()), effect: Permission::Deny }
161 }
162
163 /// Allow calls to `tool` whose subject contains `pattern` (short-circuits
164 /// later deny rules — list specific allows before a broad deny).
165 pub fn allow_matching(tool: impl Into<String>, pattern: impl Into<String>) -> Self {
166 Self { tool: Some(tool.into()), pattern: Some(pattern.into()), effect: Permission::Allow }
167 }
168
169 /// Ask the host's permission prompt for calls to `tool` matching `pattern`.
170 pub fn ask_matching(tool: impl Into<String>, pattern: impl Into<String>) -> Self {
171 Self { tool: Some(tool.into()), pattern: Some(pattern.into()), effect: Permission::Ask }
172 }
173
174 /// Ask the host's permission prompt for every call to `tool`.
175 pub fn ask(tool: impl Into<String>) -> Self {
176 Self { tool: Some(tool.into()), pattern: None, effect: Permission::Ask }
177 }
178}
179
180/// What a permission prompt is asked to decide.
181#[derive(Debug, Clone)]
182pub struct PermissionRequest {
183 /// The tool being called (e.g. `"bash"`).
184 pub tool: String,
185 /// The call's subject — the command for `bash`, the path for file tools —
186 /// when the tool exposes one.
187 pub subject: Option<String>,
188}
189
190/// A host callback deciding whether a [`Permission::Ask`] tool call may proceed
191/// (`true` = allow). Invoked synchronously on the run thread, so a host can block
192/// on its own confirmation UI — the interactive analogue of OpenCode's ask
193/// prompt. Set via [`OpenHarness::with_permission_prompt`].
194pub type PermissionPrompt = std::sync::Arc<dyn Fn(&PermissionRequest) -> bool + Send + Sync>;
195
196/// A direct-model harness over an OpenAI-compatible HTTP endpoint.
197pub struct OpenHarness {
198 id: String,
199 display_name: String,
200 description: String,
201 /// Base URL with no trailing slash; chat is `{base}/v1/chat/completions`.
202 base_url: String,
203 /// Env var the API key is read from; `None` → no auth (local Ollama).
204 api_key: ApiKey,
205 /// Tool ids withheld from this agent — see [`OpenHarnessConfig::disabled_tools`].
206 disabled_tools: Vec<String>,
207 /// Prefix-cache marking — see [`OpenHarnessConfig::prompt_cache`].
208 prompt_cache: PromptCache,
209 /// Instruction-file lookup — see [`OpenHarnessConfig::instruction_sources`].
210 instruction_sources: InstructionSources,
211 /// Extra skill roots — see [`OpenHarnessConfig::global_skill_roots`].
212 global_skill_roots: Vec<std::path::PathBuf>,
213 /// Prompt/tool surface — see [`OpenHarnessConfig::profile`].
214 profile: PromptProfile,
215 discovery: Discovery,
216 /// Used when a run doesn't specify a model via `RunTuning.model`.
217 default_model: Option<String>,
218 /// When set, sessions are persisted here (transcripts + metadata) and runs
219 /// are resumable by id; `None` means ephemeral (no disk writes).
220 session_dir: Option<PathBuf>,
221 /// The model's context-window size in tokens, when known — enables
222 /// compaction (summarize old turns near the limit); `None` disables it.
223 context_tokens: Option<u64>,
224 /// Named subagents the `task` tool can spawn via `subagent_type`.
225 agents: Vec<(String, AgentDef)>,
226 /// MCP servers to launch over stdio and expose their tools to the model.
227 mcp_servers: Vec<McpServer>,
228 /// Per-model pricing for cost estimation on `RunEvent::Usage`.
229 model_costs: Vec<(String, ModelCost)>,
230 /// Pre-execution permission rules gating tool calls.
231 permissions: Vec<PermissionRule>,
232 /// Host callback consulted for `Permission::Ask` decisions.
233 permission_prompt: Option<PermissionPrompt>,
234 /// Inline reasoning tag lifted from streamed output into `Thinking`
235 /// (default `Some("think")`); `None` disables extraction.
236 reasoning_tag: Option<String>,
237}
238
239/// Whether to mark the prompt prefix as cacheable.
240///
241/// Providers split two ways. OpenAI and DeepSeek cache a matching prefix
242/// implicitly and need nothing in the request. Anthropic caches only what the
243/// request marks with `cache_control`, and forwards that field through
244/// OpenAI-compatible gateways such as OpenRouter — so reaching a Claude model
245/// without marking anything re-charges the system prompt and the whole tool
246/// block at full input price on every turn.
247///
248/// Default is [`Self::Implicit`]: an unmarked request is correct everywhere,
249/// where a marked one restructures the system message and is wasted on an
250/// endpoint that ignores it.
251#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
252pub enum PromptCache {
253 /// Send no breakpoints — right for implicit-caching providers and for local
254 /// servers, whose KV cache keys on the prefix bytes rather than a field.
255 #[default]
256 Implicit,
257 /// Mark the tool block and the system message as cacheable.
258 Ephemeral,
259}
260
261/// Where an endpoint's API key comes from, or that it needs none.
262///
263/// One value rather than three fields. The previous shape —
264/// `api_key` + `api_key_env` + `requires_api_key` — could represent eight
265/// combinations for four meanings, and the contradictions were not theoretical:
266/// "needs a key" was once inferred from "names an environment variable", so a
267/// host holding its key in a vault reported that no credential was required
268/// and looked permanently ready. No pair of fields can disagree here.
269#[derive(Clone, Debug, Default, PartialEq, Eq)]
270pub enum ApiKey {
271 /// The endpoint takes no key — a local Ollama or `llama-server`.
272 #[default]
273 NotNeeded,
274 /// A key is required and the host has not supplied one yet. Readiness says
275 /// so and the credential slot is writable, so a host can prompt for it.
276 Required,
277 /// The secret itself. Never enters the environment, so it is not inherited
278 /// by children this crate spawns — including the `bash` tool, which would
279 /// otherwise put the key running the agent within the agent's reach.
280 Value(String),
281 /// The name of an environment variable, read at run time. For CI and
282 /// headless runs where a variable is the natural source.
283 Env(String),
284}
285
286impl ApiKey {
287 /// Whether this endpoint needs a key at all.
288 pub fn is_needed(&self) -> bool {
289 !matches!(self, Self::NotNeeded)
290 }
291
292 /// The variable this key is read from, when it is read from one. Lets a
293 /// host say "set `OPENROUTER_API_KEY`" only when that is actually the
294 /// instruction; naming a variable to someone passing a value is a dead end.
295 pub fn env_var(&self) -> Option<&str> {
296 match self {
297 Self::Env(name) => Some(name),
298 _ => None,
299 }
300 }
301
302 /// The key to send, resolved now — reading the environment for
303 /// [`Self::Env`]. Blank is treated as absent, since an exported-but-empty
304 /// variable is a misconfiguration rather than a credential.
305 pub(crate) fn resolve(&self) -> Option<String> {
306 let raw = match self {
307 Self::Value(key) => Some(key.clone()),
308 Self::Env(name) => std::env::var(name).ok(),
309 Self::NotNeeded | Self::Required => None,
310 };
311 raw.filter(|value| !value.trim().is_empty())
312 }
313}
314
315/// Configuration for [`OpenHarness::custom`] — named fields rather than a long
316/// positional argument list, so a call site reads unambiguously (which string is
317/// the id vs the display name). Derives `Default`, so optional fields can be
318/// omitted with `..Default::default()`.
319#[derive(Clone, Debug, Default)]
320pub struct OpenHarnessConfig {
321 /// Stable id used in the registry / picker (e.g. `"openrouter"`).
322 pub id: String,
323 /// Human-readable name shown in the UI (e.g. `"OpenRouter"`).
324 pub display_name: String,
325 /// Base URL with no trailing slash; chat is `{base}/v1/chat/completions`.
326 pub base_url: String,
327 /// Whether requests mark the prompt prefix as cacheable. See
328 /// [`PromptCache`] — needed for Anthropic models reached through a gateway,
329 /// unnecessary elsewhere.
330 pub prompt_cache: PromptCache,
331 /// Where this endpoint's key comes from, or that it needs none. See
332 /// [`ApiKey`]: one value, so "needs a key" and "reads this variable"
333 /// cannot disagree the way three separate fields could.
334 pub api_key: ApiKey,
335 /// Tool ids to withhold from this agent. Every tool is offered by default;
336 /// name the ones this host does not want.
337 ///
338 /// A denylist is right here and wrong for environment variables, for the
339 /// same reason in reverse: tool ids are a closed set this crate owns, so
340 /// naming one cannot miss a case, while environment names are open-ended
341 /// and a name-shaped guess always will. See
342 /// [`OpenHarness::builtin_tool_names`] for the set.
343 ///
344 /// Withheld at construction, so a disabled tool never reaches the model —
345 /// it costs no schema in the request and cannot be attempted. That is
346 /// different from [`PermissionRule::deny`], which advertises the tool and
347 /// refuses the call.
348 pub disabled_tools: Vec<String>,
349 /// Where `AGENTS.md` / `CLAUDE.md` are read from, and how much of them is
350 /// kept. Defaults to the working tree only — nothing under `$HOME` is read
351 /// until a host asks, via [`InstructionSources::discover_global`] or its
352 /// own paths.
353 pub instruction_sources: InstructionSources,
354 /// Per-user skill directories scanned in addition to the project's. Empty
355 /// by default; [`global_skill_roots`] returns the usual ones
356 /// for a host that wants them.
357 pub global_skill_roots: Vec<std::path::PathBuf>,
358 /// Which prompt and tool surface runs get. [`PromptProfile::Auto`] (the
359 /// default) picks [`PromptProfile::Compact`] for a small context window —
360 /// core tools and plainer instructions, so a small local model has room to
361 /// work in.
362 pub profile: PromptProfile,
363 /// Curated models for the picker; may be empty (free-text ids are allowed,
364 /// or call [`OpenHarness::with_models_dev`] for catalog discovery).
365 pub models: Vec<ModelChoice>,
366}
367
368impl OpenHarness {
369 /// Every tool this harness can offer, for a host building the choice into
370 /// its own settings rather than hardcoding names that drift as tools are
371 /// added. Any of these may go in [`OpenHarnessConfig::disabled_tools`].
372 pub fn builtin_tool_names() -> Vec<String> {
373 tools::ToolSet::builtin_tool_names()
374 }
375
376 /// Local Ollama on its default port, with live `/api/tags` discovery and
377 /// no auth. Chat hits Ollama's **native** `/api/chat` (not `/v1`) so
378 /// `num_ctx` applies, so the model loads the intended context window
379 /// instead of Ollama's truncating 4096 default.
380 pub fn ollama() -> Self {
381 Self::ollama_at("http://localhost:11434")
382 }
383
384 /// Ollama served from somewhere other than the default port — a remote box,
385 /// a container, a second instance. Identical to [`Self::ollama`] in every
386 /// other respect, including the native `/api/chat` path.
387 pub fn ollama_at(base_url: impl Into<String>) -> Self {
388 Self {
389 id: "ollama".to_owned(),
390 display_name: "Ollama".to_owned(),
391 description: "Local models served by Ollama via its OpenAI-compatible API.".to_owned(),
392 base_url: base_url.into(),
393 api_key: ApiKey::NotNeeded, // a local Ollama takes none
394 prompt_cache: PromptCache::default(),
395 disabled_tools: Vec::new(),
396 instruction_sources: InstructionSources::default(),
397 global_skill_roots: Vec::new(),
398 profile: PromptProfile::default(),
399 discovery: Discovery::OllamaTags,
400 default_model: None,
401 session_dir: None,
402 context_tokens: None,
403 agents: Vec::new(),
404 mcp_servers: Vec::new(),
405 model_costs: Vec::new(),
406 permissions: Vec::new(),
407 permission_prompt: None,
408 reasoning_tag: Some("think".to_owned()),
409 }
410 }
411
412 /// Any other OpenAI-compatible endpoint (OpenRouter, vLLM, LM Studio, a
413 /// self-hosted gateway), configured by an [`OpenHarnessConfig`] so each
414 /// argument is named at the call site.
415 pub fn custom(config: OpenHarnessConfig) -> Self {
416 let OpenHarnessConfig {
417 id,
418 display_name,
419 base_url,
420 api_key,
421 prompt_cache,
422 disabled_tools,
423 instruction_sources,
424 global_skill_roots,
425 profile,
426 models,
427 } = config;
428 Self {
429 id,
430 description: format!("{display_name} via its OpenAI-compatible API."),
431 display_name,
432 base_url,
433 api_key,
434 prompt_cache,
435 disabled_tools,
436 instruction_sources,
437 global_skill_roots,
438 profile,
439 default_model: models.first().map(|m| m.value.clone()),
440 discovery: Discovery::Static(models),
441 session_dir: None,
442 context_tokens: None,
443 agents: Vec::new(),
444 mcp_servers: Vec::new(),
445 model_costs: Vec::new(),
446 permissions: Vec::new(),
447 permission_prompt: None,
448 reasoning_tag: Some("think".to_owned()),
449 }
450 }
451
452 /// Discover models from the [models.dev](https://models.dev) catalog for the
453 /// given provider id (`"anthropic"`, `"openai"`, …) instead of a static list
454 /// — for a cloud endpoint that proxies a known provider. Needs the
455 /// `agent-harness/models-dev` feature (which `openai-compatible` enables); with no
456 /// reachable catalog `list_models` falls back to empty (free-text entry).
457 pub fn with_models_dev(mut self, provider: impl Into<String>) -> Self {
458 self.discovery = Discovery::ModelsDev(provider.into());
459 self
460 }
461
462 /// List models by asking the endpoint, via the OpenAI-standard
463 /// `/v1/models`. The right mode for an endpoint configured at runtime — a
464 /// local LM Studio, a llama.cpp server, a gateway — where no adapter knows
465 /// the catalog up front.
466 ///
467 /// Any models already declared become the fallback: a server that does not
468 /// serve `/v1/models` still offers what it was configured with, so the
469 /// picker degrades to today's behaviour instead of to nothing.
470 pub fn with_openai_models(mut self) -> Self {
471 let fallback = match std::mem::replace(&mut self.discovery, Discovery::Static(Vec::new())) {
472 Discovery::Static(models) => models,
473 _ => Vec::new(),
474 };
475 self.discovery = Discovery::OpenAiModels { fallback };
476 self
477 }
478
479 /// Guard the model-management operations: only the Ollama discovery mode
480 /// manages models locally. Returns the same "unsupported" error the trait
481 /// defaults give, so a non-Ollama instance reports cleanly instead of
482 /// hitting a `/api/...` endpoint that isn't there.
483 fn require_ollama_management(&self) -> Result<(), Error> {
484 match &self.discovery {
485 Discovery::OllamaTags => Ok(()),
486 Discovery::Static(_)
487 | Discovery::ModelsDev(_)
488 | Discovery::OpenAiModels { .. } => Err(Error::Other(format!(
489 "{} does not support managing models.",
490 self.display_name
491 ))),
492 }
493 }
494
495 /// Resolve the run's context settings as `(compaction_limit, ollama_num_ctx)`.
496 ///
497 /// For Ollama both are the same *effective* window — the explicit
498 /// `with_context_tokens` override (uncapped, the host's call), else the
499 /// model's probed `/api/show` context capped at [`OLLAMA_CTX_CEILING`], else
500 /// [`OLLAMA_CTX_DEFAULT`]. `ollama_num_ctx` is sent in the native `/api/chat`
501 /// request so Ollama loads that window instead of its 4096 default (which
502 /// would silently truncate the prompt), and compaction targets the same
503 /// number so the two never disagree. Other providers self-manage the window:
504 /// `ollama_num_ctx` is `None` (they use `/v1`) and the compaction limit is
505 /// the explicit override or `None`.
506 fn resolve_context(&self, model: &str) -> (Option<u64>, chat::Dialect, Option<f64>) {
507 match &self.discovery {
508 Discovery::OllamaTags => {
509 // One `/api/show` yields both facts; skip it entirely when the
510 // host already fixed the window and nothing else needs asking.
511 let (probed_window, parameters) = ollama::model_facts(&self.base_url, model);
512 let effective = self
513 .context_tokens
514 .or_else(|| probed_window.map(|n| n.min(OLLAMA_CTX_CEILING)))
515 .unwrap_or(OLLAMA_CTX_DEFAULT);
516 (Some(effective), chat::Dialect::OllamaNative { num_ctx: effective }, parameters)
517 }
518 // A local OpenAI-compatible server the host did not size. llama.cpp
519 // publishes its loaded window on `/props`, and getting it wrong here
520 // is what made the whole request 400 rather than merely answer
521 // worse — so it is worth one cheap call. A hosted endpoint is not
522 // probed: nothing there answers quickly, and models.dev already
523 // carries the limits for those.
524 Discovery::Static(_) | Discovery::ModelsDev(_) | Discovery::OpenAiModels { .. } => {
525 let window = self.context_tokens.or_else(|| {
526 if profile::is_local_endpoint(&self.base_url) {
527 local_server_context(&self.base_url)
528 } else if let Discovery::ModelsDev(provider) = &self.discovery {
529 // The catalog is the only cross-provider source of a
530 // hosted model's window; each vendor publishes its own
531 // shape or none. Cached on disk, so this costs nothing
532 // after the first launch.
533 crate::models_dev::context_limit(provider, model)
534 } else {
535 None
536 }
537 });
538 (window, chat::Dialect::OpenAi, None)
539 }
540 }
541 }
542
543 /// The registered per-token pricing for `model`, if any.
544 fn model_cost_for(&self, model: &str) -> Option<ModelCost> {
545 self.model_costs.iter().find(|(m, _)| m == model).map(|(_, c)| *c)
546 }
547
548 /// Persist sessions under `dir` so runs are resumable: each run writes its
549 /// transcript here and `RunRequest.resume` continues a prior session by id.
550 /// Without this, the harness runs ephemerally (no disk writes).
551 pub fn with_session_dir(mut self, dir: impl Into<PathBuf>) -> Self {
552 self.session_dir = Some(dir.into());
553 self
554 }
555
556 /// Tell the runtime the model's context-window size (in tokens), enabling
557 /// compaction: as the transcript nears the limit, older turns are summarized
558 /// and recent ones kept verbatim. Without it the full transcript is always
559 /// replayed (fine for short sessions).
560 pub fn with_context_tokens(mut self, tokens: u64) -> Self {
561 self.context_tokens = Some(tokens);
562 self
563 }
564
565 /// Register a named subagent the `task` tool can spawn via `subagent_type`
566 /// (e.g. a focused "reviewer" with its own prompt/model). Registration order
567 /// is preserved for the catalog shown to the model.
568 pub fn with_agent(mut self, name: impl Into<String>, def: AgentDef) -> Self {
569 self.agents.push((name.into(), def));
570 self
571 }
572
573 /// Register an MCP server to launch over stdio; its advertised tools are
574 /// offered to the model (namespaced `name_tool`) and dispatched alongside the
575 /// built-ins. Connection is best-effort — a server that fails to start or
576 /// handshake is skipped at run time (with a status line), never fatal.
577 pub fn with_mcp_server(mut self, server: McpServer) -> Self {
578 self.mcp_servers.push(server);
579 self
580 }
581
582 /// Register per-token pricing for a model, so its runs emit an estimated cost
583 /// on [`crate::RunEvent::Usage`]. Rates are USD per million tokens.
584 pub fn with_model_cost(mut self, model: impl Into<String>, cost: ModelCost) -> Self {
585 self.model_costs.push((model.into(), cost));
586 self
587 }
588
589 /// Add a [`PermissionRule`] gating tool calls before execution (deny specific
590 /// dangerous calls, or allow-list specific ones then deny the rest). Rules
591 /// apply in the order added, to the main agent and its subagents.
592 pub fn with_permission_rule(mut self, rule: PermissionRule) -> Self {
593 self.permissions.push(rule);
594 self
595 }
596
597 /// Set the callback that decides [`Permission::Ask`] tool calls (`true` =
598 /// allow). It's invoked synchronously on the run thread, so a host can block
599 /// on its own confirmation UI — the interactive permission channel. Without
600 /// it, `Ask` rules deny.
601 pub fn with_permission_prompt(
602 mut self,
603 prompt: impl Fn(&PermissionRequest) -> bool + Send + Sync + 'static,
604 ) -> Self {
605 self.permission_prompt = Some(std::sync::Arc::new(prompt));
606 self
607 }
608
609 /// Set the inline reasoning tag lifted from streamed output into `Thinking`
610 /// — e.g. `"think"` for `<think>…</think>` (DeepSeek-R1, Qwen3), the default.
611 /// The convention is model-specific, so set it to match your model.
612 pub fn with_reasoning_tag(mut self, tag: impl Into<String>) -> Self {
613 self.reasoning_tag = Some(tag.into());
614 self
615 }
616
617 /// Disable inline reasoning extraction — stream content verbatim. Use for a
618 /// non-reasoning model, or one whose reasoning arrives in a dedicated field
619 /// (handled separately).
620 pub fn without_reasoning_extraction(mut self) -> Self {
621 self.reasoning_tag = None;
622 self
623 }
624
625 /// All persisted sessions for this harness (newest-updated first), or an
626 /// empty list when no session dir is configured. Lets a host render a
627 /// conversations view without driving a run.
628 pub fn sessions(&self) -> Result<Vec<SessionRecord>, Error> {
629 match &self.session_dir {
630 Some(dir) => session::FileStore::new(dir.clone())
631 .list_records()
632 .map_err(Error::Other),
633 None => Ok(Vec::new()),
634 }
635 }
636
637 /// List the prompt templates advertised by the configured MCP servers. Each
638 /// server is connected, queried, and disconnected, so this spawns the server
639 /// processes; a host surfaces the result for the user to pick from, then
640 /// resolves one with [`get_mcp_prompt`](Self::get_mcp_prompt) to seed a run.
641 pub fn mcp_prompts(&self) -> Vec<McpPrompt> {
642 let cwd = std::env::current_dir().unwrap_or_default();
643 tools::mcp::list_prompts(&self.mcp_servers, &cwd)
644 }
645
646 /// Resolve a prompt template (by server + name, with `arguments`) to its
647 /// messages, for a host to seed a run's prompt.
648 pub fn get_mcp_prompt(
649 &self,
650 server: &str,
651 name: &str,
652 arguments: &[(String, String)],
653 ) -> Result<Vec<PromptMessage>, Error> {
654 let cwd = std::env::current_dir().unwrap_or_default();
655 tools::mcp::get_prompt(&self.mcp_servers, server, name, arguments, &cwd).map_err(Error::Other)
656 }
657}
658
659impl Harness for OpenHarness {
660 fn info(&self) -> Info {
661 Info {
662 id: self.id.clone(),
663 display_name: self.display_name.clone(),
664 description: self.description.clone(),
665 // A local server is something the user installs and runs; a hosted
666 // endpoint needs nothing. Readiness reports reachability either way.
667 install_hint: match self.discovery {
668 Discovery::OllamaTags => Some(InstallHint::url("https://ollama.com/download")),
669 // Configured at runtime by whoever added it — there is nowhere
670 // to send them to get it.
671 Discovery::Static(_)
672 | Discovery::ModelsDev(_)
673 | Discovery::OpenAiModels { .. } => None,
674 },
675 }
676 }
677
678 fn features(&self) -> Features {
679 Features {
680 credential_required: self.api_key.is_needed(),
681 // Dynamic discovery surfaces models via list_models(); a
682 // static instance lists them here.
683 models: match &self.discovery {
684 Discovery::Static(m) => m.clone(),
685 // Dynamic — surfaced live via list_models().
686 Discovery::OllamaTags
687 | Discovery::ModelsDev(_)
688 | Discovery::OpenAiModels { .. } => Vec::new(),
689 },
690 custom_model: true,
691 max_turns: true,
692 custom_instructions: true,
693 ..Default::default()
694 }
695 }
696
697 fn readiness(&self) -> Readiness {
698 let base = |ready: bool, error: Option<String>| Readiness {
699 harness_id: self.id.clone(),
700 ready,
701 // A hosted endpoint isn't "installed"; reachability is the signal.
702 installed: true,
703 version: None,
704 auth_configured: ready,
705 error,
706 details: Value::Null,
707 };
708 match &self.discovery {
709 // Reachability doubles as the readiness probe: if `/api/tags`
710 // answers, Ollama is up.
711 Discovery::OllamaTags => match ollama::list_tags(&self.base_url) {
712 Ok(_) => base(true, None),
713 Err(e) => base(
714 false,
715 Some(format!(
716 "Ollama is not reachable at {} — is it running (`ollama serve`)? ({e})",
717 self.base_url
718 )),
719 ),
720 },
721 // An endpoint configured at runtime. A key it needs and lacks is
722 // the first answer; after that, a *local* server is judged by
723 // whether it answers, exactly as Ollama is — "ready" for a
724 // llama.cpp that is not running would fail at the first message,
725 // which is the one place the user cannot act on it. A hosted one is
726 // not probed: nothing there answers quickly enough to sit in a
727 // readiness call.
728 Discovery::OpenAiModels { .. } => {
729 if self.api_key.is_needed() && self.api_key.resolve().is_none() {
730 base(false, Some(format!("Add an API key for {}.", self.display_name)))
731 } else if !profile::is_local_endpoint(&self.base_url) {
732 base(true, None)
733 } else {
734 match openai_models::list_models(&self.base_url, self.api_key.resolve().as_deref())
735 {
736 Ok(_) => base(true, None),
737 Err(e) => base(
738 false,
739 Some(format!(
740 "{} is not reachable at {} — is it running? ({e})",
741 self.display_name, self.base_url
742 )),
743 ),
744 }
745 }
746 }
747 // A cloud endpoint (static list or models.dev catalog) is ready once
748 // its API key (if any) is present.
749 Discovery::Static(_) | Discovery::ModelsDev(_) => {
750 if self.api_key.is_needed() && self.api_key.resolve().is_none() {
751 // Name the variable only when there is one to set; a host
752 // that passes the key as a value has no variable, and
753 // telling its user to export one would be a dead end.
754 let how = match self.api_key.env_var() {
755 Some(env) => format!("Set {env} to use {}.", self.display_name),
756 None => format!("Add an API key for {}.", self.display_name),
757 };
758 base(false, Some(how))
759 } else {
760 base(true, None)
761 }
762 }
763 }
764 }
765
766 fn start(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, Error> {
767 let RunRequest { run_id, prompt, cwd, mode, tuning, resume, attachments } = request;
768 let model = tuning
769 .model
770 .as_deref()
771 .map(str::trim)
772 .filter(|m| !m.is_empty())
773 .map(str::to_owned)
774 .or_else(|| self.default_model.clone())
775 .ok_or_else(|| {
776 Error::Other(format!(
777 "{}: no model selected and no default — set RunTuning.model",
778 self.id
779 ))
780 })?;
781
782 let (context_tokens, dialect, model_parameters_b) = self.resolve_context(&model);
783 let model_cost = self.model_cost_for(&model);
784 // Inline images become base64 data URIs the wire attaches to the prompt.
785 let image_data_uris: Vec<String> =
786 attachments.iter().map(|a| wire::image_data_uri(&a.mime_type, &a.data)).collect();
787 let cfg = run::LoopConfig {
788 run_id,
789 base_url: self.base_url.clone(),
790 api_key: self.api_key.resolve(),
791 disabled_tools: self.disabled_tools.clone(),
792 instruction_sources: self.instruction_sources.clone(),
793 global_skill_roots: self.global_skill_roots.clone(),
794 profile: self.profile,
795 prompt_cache: self.prompt_cache,
796 model_parameters_b,
797 model,
798 prompt,
799 cwd: cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()),
800 mode,
801 max_turns: run::LoopConfig::max_turns_or_default(tuning.max_turns),
802 resume,
803 store: self.session_dir.clone().map(session::FileStore::new),
804 context_tokens,
805 dialect,
806 agents: self.agents.clone(),
807 mcp_servers: self.mcp_servers.clone(),
808 output_schema: tuning.output_schema,
809 model_cost,
810 image_data_uris,
811 permissions: self.permissions.clone(),
812 permission_prompt: self.permission_prompt.clone(),
813 reasoning_tag: self.reasoning_tag.clone(),
814 extra_instructions: tuning.extra_instructions,
815 };
816
817 let cancel = Arc::new(AtomicBool::new(false));
818 let thread_cancel = Arc::clone(&cancel);
819 std::thread::spawn(move || run::drive(cfg, thread_cancel, on_event));
820 Ok(Box::new(run::OpenAiRun::new(cancel)))
821 }
822
823 fn credential(&self) -> CredentialSpec {
824 match self.api_key.is_needed() {
825 // The account name is the env var when there is one, else the id —
826 // a host needs a stable slot name either way.
827 true => CredentialSpec {
828 label: format!("{} API key", self.display_name),
829 keychain_service: self.id.clone(),
830 keychain_account: self.api_key.env_var().map_or_else(|| self.id.clone(), str::to_owned),
831 required: true,
832 },
833 // Local Ollama needs no key.
834 false => CredentialSpec {
835 label: format!("{} (no key required)", self.display_name),
836 keychain_service: self.id.clone(),
837 keychain_account: String::new(),
838 required: false,
839 },
840 }
841 }
842
843 fn list_models(&self) -> Result<Vec<ModelChoice>, Error> {
844 match &self.discovery {
845 Discovery::OllamaTags => ollama::list_tags(&self.base_url).map_err(Error::Other),
846 Discovery::Static(_) => Ok(self.features().models),
847 Discovery::ModelsDev(provider) => Ok(crate::models_dev::provider_models(provider)),
848 // A picker is better served by the configured model than by an
849 // error: the endpoint being unreachable is what `readiness` is for,
850 // and reporting it twice would make an offline server look broken
851 // in a place the user cannot act on.
852 Discovery::OpenAiModels { fallback } => {
853 Ok(openai_models::list_models(&self.base_url, self.api_key.resolve().as_deref())
854 .unwrap_or_else(|_| fallback.clone()))
855 }
856 }
857 }
858
859 // Model management is an Ollama-only capability: it installs/removes models
860 // on the local server. Other OpenAI-compatible endpoints (OpenRouter,
861 // models.dev-backed) host their models remotely, so the trait defaults
862 // (unsupported) stand for them.
863 fn model_management(&self) -> Option<ModelManagement> {
864 match &self.discovery {
865 Discovery::OllamaTags => Some(ModelManagement { base_url: self.base_url.clone() }),
866 // `/v1/models` lists; it does not install or delete. Pulling a model
867 // is Ollama's own API, so a generic endpoint gets no manager.
868 Discovery::Static(_)
869 | Discovery::ModelsDev(_)
870 | Discovery::OpenAiModels { .. } => None,
871 }
872 }
873
874 fn list_installed_models(&self) -> Result<Vec<InstalledModel>, Error> {
875 self.require_ollama_management()?;
876 ollama::list_installed(&self.base_url).map_err(Error::Other)
877 }
878
879 fn pull_model(
880 &self,
881 model: &str,
882 cancel: &std::sync::atomic::AtomicBool,
883 on_progress: PullProgressCallback<'_>,
884 ) -> Result<(), Error> {
885 self.require_ollama_management()?;
886 ollama::pull(&self.base_url, model, cancel, on_progress).map_err(Error::Other)
887 }
888
889 fn delete_model(&self, model: &str) -> Result<(), Error> {
890 self.require_ollama_management()?;
891 ollama::delete(&self.base_url, model).map_err(Error::Other)
892 }
893}
894
895#[cfg(test)]
896mod tests {
897
898 /// The builder promotes any declared models to the fallback, so an endpoint
899 /// that does not serve `/v1/models` still offers what it was configured
900 /// with rather than an empty picker.
901 #[test]
902 fn declared_models_become_the_discovery_fallback() {
903 let harness = OpenHarness::custom(OpenHarnessConfig {
904 id: "custom:x".to_owned(),
905 display_name: "LM Studio".to_owned(),
906 // Unroutable: discovery must fail so the fallback is what shows.
907 base_url: "http://127.0.0.1:9".to_owned(),
908 models: vec![ModelChoice { value: "qwen3:8b".into(), label: "qwen3:8b".into() }],
909 ..Default::default()
910 })
911 .with_openai_models();
912
913 let models = harness.list_models().expect("a failed probe must not be an error");
914 assert_eq!(models.len(), 1);
915 assert_eq!(models[0].value, "qwen3:8b");
916 }
917
918 /// A local endpoint that answers nothing is not ready — reporting it ready
919 /// would move the failure to the user's first message.
920 #[test]
921 fn an_unreachable_local_endpoint_is_not_ready() {
922 let harness = OpenHarness::custom(OpenHarnessConfig {
923 id: "custom:x".to_owned(),
924 display_name: "LM Studio".to_owned(),
925 base_url: "http://127.0.0.1:9".to_owned(),
926 ..Default::default()
927 })
928 .with_openai_models();
929
930 let readiness = harness.readiness();
931 assert!(!readiness.ready);
932 assert!(readiness.error.is_some_and(|e| e.contains("not reachable")));
933 }
934
935 use super::*;
936
937 #[test]
938 fn ollama_is_keyless_dynamic_and_editing() {
939 let h = OpenHarness::ollama();
940 let info = h.info();
941 assert_eq!(info.id, "ollama");
942 // A local server IS something the user installs — the hint is the only
943 // way the picker can say where to get it now that nothing self-installs.
944 assert!(info.install_hint.is_some_and(|h| h.url.contains("ollama.com")));
945 let can = h.features();
946 assert!(!can.credential_required);
947 assert!(!can.previews_edits);
948 assert!(can.custom_model);
949 // Dynamic discovery → no static models declared; list_models() fills it.
950 assert!(can.models.is_empty());
951 assert!(!h.credential().required);
952 }
953
954 #[test]
955 fn only_ollama_exposes_model_management() {
956 let ollama = OpenHarness::ollama();
957 let mgmt = ollama.model_management().expect("Ollama manages models");
958 assert_eq!(mgmt.base_url, "http://localhost:11434");
959
960 // A remote OpenAI-compatible endpoint hosts its models, so management is
961 // unsupported — and the operations report that rather than calling out.
962 let remote = OpenHarness::custom(OpenHarnessConfig {
963 id: "openrouter".to_owned(),
964 display_name: "OpenRouter".to_owned(),
965 base_url: "https://openrouter.ai/api".to_owned(),
966 api_key: ApiKey::Env("OPENROUTER_API_KEY".to_owned()),
967 ..Default::default()
968 });
969 assert!(remote.model_management().is_none());
970 assert!(remote.list_installed_models().is_err());
971 assert!(remote.delete_model("whatever").is_err());
972 let cancel = std::sync::atomic::AtomicBool::new(false);
973 assert!(remote.pull_model("whatever", &cancel, &mut |_| {}).is_err());
974 }
975
976 #[test]
977 fn a_key_passed_as_a_value_needs_no_environment_variable() {
978 // The whole point: a host holding the secret hands it over directly.
979 // Nothing is exported, and no variable name is involved.
980 let h = OpenHarness::custom(OpenHarnessConfig {
981 id: "openrouter".to_owned(),
982 display_name: "OpenRouter".to_owned(),
983 base_url: "https://openrouter.ai/api".to_owned(),
984 api_key: ApiKey::Value("sk-or-v1-example".to_owned()),
985 ..Default::default()
986 });
987
988 // Declares that it needs a key, so a host shows the field for it.
989 assert!(h.features().credential_required);
990 // Has one, so it is ready — no variable was ever set.
991 assert!(h.readiness().ready);
992 // And the credential slot is real, so a host can store into it.
993 let spec = h.credential();
994 assert!(spec.required && !spec.keychain_account.is_empty());
995 }
996
997 #[test]
998 fn a_value_only_provider_without_a_key_says_so_without_naming_a_variable() {
999 // Telling someone to export a variable that does not exist is a dead
1000 // end — this is the message a host with its own key field wants.
1001 let h = OpenHarness::custom(OpenHarnessConfig {
1002 id: "acme".to_owned(),
1003 display_name: "Acme".to_owned(),
1004 base_url: "https://acme.test".to_owned(),
1005 api_key: ApiKey::Required,
1006 ..Default::default()
1007 });
1008 let readiness = h.readiness();
1009 assert!(!readiness.ready);
1010 let error = readiness.error.unwrap_or_default();
1011 assert!(error.contains("Add an API key"), "{error}");
1012 assert!(!error.contains("Set "), "{error}");
1013 }
1014
1015 #[test]
1016 fn naming_a_variable_still_implies_a_key_is_needed() {
1017 // Every config written before the split keeps working untouched.
1018 let h = OpenHarness::custom(OpenHarnessConfig {
1019 id: "openrouter".to_owned(),
1020 display_name: "OpenRouter".to_owned(),
1021 base_url: "https://openrouter.ai/api".to_owned(),
1022 api_key: ApiKey::Env("OPENROUTER_API_KEY".to_owned()),
1023 ..Default::default()
1024 });
1025 assert!(h.features().credential_required);
1026 assert!(h.credential().required);
1027 }
1028
1029 #[test]
1030 fn a_value_wins_over_the_environment() {
1031 // This once tested a precedence rule: a passed-in key had to beat a
1032 // stale variable in the user's shell. `ApiKey` removes the question —
1033 // a key comes from one place or the other, never both — so what is
1034 // left worth asserting is that `Value` does not read the environment.
1035 std::env::set_var("ACME_KEY", "from-the-environment");
1036 let h = OpenHarness::custom(OpenHarnessConfig {
1037 id: "acme".to_owned(),
1038 display_name: "Acme".to_owned(),
1039 base_url: "https://acme.test".to_owned(),
1040 api_key: ApiKey::Value("from-the-host".to_owned()),
1041 ..Default::default()
1042 });
1043 assert_eq!(h.api_key.resolve().as_deref(), Some("from-the-host"));
1044 assert!(h.api_key.env_var().is_none(), "a value names no variable to tell the user about");
1045 std::env::remove_var("ACME_KEY");
1046 }
1047
1048 #[test]
1049 fn every_key_state_agrees_with_itself() {
1050 // The bug this enum exists to prevent: "needs a key" was inferred from
1051 // "names an environment variable", so a host holding its key in a vault
1052 // reported no credential required and looked permanently ready. Each
1053 // state now answers all three questions from one value.
1054 let harness = |key: ApiKey| {
1055 OpenHarness::custom(OpenHarnessConfig {
1056 id: "acme".to_owned(),
1057 display_name: "Acme".to_owned(),
1058 base_url: "https://acme.test".to_owned(),
1059 api_key: key,
1060 ..Default::default()
1061 })
1062 };
1063
1064 let local = harness(ApiKey::NotNeeded);
1065 assert!(!local.features().credential_required);
1066 assert!(!local.credential().required);
1067 assert!(local.readiness().ready, "no key needed means ready");
1068
1069 let vaulted = harness(ApiKey::Value("sk-secret".to_owned()));
1070 assert!(vaulted.features().credential_required, "a value still needs a key");
1071 assert!(vaulted.credential().required, "and the slot stays writable");
1072 assert!(vaulted.readiness().ready, "and it is satisfied");
1073
1074 let awaiting = harness(ApiKey::Required);
1075 assert!(awaiting.features().credential_required);
1076 assert!(!awaiting.readiness().ready, "required but absent is not ready");
1077 let error = awaiting.readiness().error.unwrap_or_default();
1078 assert!(error.contains("Add an API key"), "no variable to name: {error}");
1079
1080 std::env::set_var("ACME_ENV_KEY", "sk-from-env");
1081 let from_env = harness(ApiKey::Env("ACME_ENV_KEY".to_owned()));
1082 assert!(from_env.features().credential_required);
1083 assert!(from_env.readiness().ready);
1084 assert_eq!(from_env.credential().keychain_account, "ACME_ENV_KEY");
1085 std::env::remove_var("ACME_ENV_KEY");
1086 }
1087
1088 #[test]
1089 fn an_exported_but_empty_variable_is_not_a_credential() {
1090 std::env::set_var("ACME_BLANK", " ");
1091 let harness = OpenHarness::custom(OpenHarnessConfig {
1092 id: "acme".to_owned(),
1093 display_name: "Acme".to_owned(),
1094 base_url: "https://acme.test".to_owned(),
1095 api_key: ApiKey::Env("ACME_BLANK".to_owned()),
1096 ..Default::default()
1097 });
1098 assert!(harness.api_key.resolve().is_none(), "blank is a misconfiguration, not a key");
1099 assert!(!harness.readiness().ready);
1100 std::env::remove_var("ACME_BLANK");
1101 }
1102
1103 #[test]
1104 fn custom_requires_its_key_and_lists_static_models() {
1105 let h = OpenHarness::custom(OpenHarnessConfig {
1106 id: "openrouter".to_owned(),
1107 display_name: "OpenRouter".to_owned(),
1108 base_url: "https://openrouter.ai/api".to_owned(),
1109 api_key: ApiKey::Env("OPENROUTER_API_KEY".to_owned()),
1110 models: vec![ModelChoice { value: "x-ai/grok".to_owned(), label: "Grok".to_owned() }],
1111 ..Default::default()
1112 });
1113 assert!(h.features().credential_required);
1114 assert!(h.credential().required);
1115 assert_eq!(h.credential().keychain_account, "OPENROUTER_API_KEY");
1116 // Static discovery → list_models() returns the curated list.
1117 assert_eq!(h.list_models().unwrap().len(), 1);
1118 // The first static model is the default when a run omits one.
1119 assert_eq!(h.default_model.as_deref(), Some("x-ai/grok"));
1120 }
1121}