Skip to main content

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}