@ondeinference / ed / commits / 1e0f659

ed-acp: OpenAI-compatible endpoints and hooks for Onde Code

Onde Code can now run on ed-acp without losing features it already has. - llm: a Provider (onde or openai). <PREFIX>_PROVIDER picks it, else ONDE_API_KEY means Onde Cloud and OPENAI_API_KEY a generic endpoint. ONDE_BASE_URL points Onde Cloud at another deployment. Tool calls keep extra_content so Gemini's thought_signature is echoed back. - The stored env file is also read from ~/.config/<dir_name>/env, the old macOS location, and logout removes every copy. - --setup asks for a provider and writes only the variables it sets. - Profile::answer_slash replies to a command without calling the model. - <PREFIX>_COMMAND_TIMEOUT_SECS sets the run_command timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 13:04 UTC 1e0f659264138a5bb84548cef47d7a09db00e730
7 files changed +378 -69
crates/ed-acp/README.md
+1 -1
@@ -4,7 +4,7 @@ An [Agent Client Protocol](https://agentclientprotocol.com) v1 server for Onde a
4
5 It is the part of an ACP agent that is the same for every product: `initialize` and version
6 negotiation, auth, durable sessions, the model picker, prompt content, the turn loop against Onde
7 -Cloud with streaming, tool approval, workspace tools routed through the client, and MCP servers
7 +Cloud (or any OpenAI API compatible endpoint) with streaming, tool approval, workspace tools routed through the client, and MCP servers
8 (stdio and streamable HTTP) through [`ed-mcp`](https://crates.io/crates/ed-mcp). A product
9 supplies a `Profile`: its name, prompt, tools, slash commands and built-in MCP servers.
10
crates/ed-acp/src/agent.rs
+62 -15
@@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet};
6 use std::path::{Path, PathBuf};
7 use std::sync::atomic::{AtomicBool, Ordering};
8 use std::sync::{Arc, Mutex};
9 -use std::time::{SystemTime, UNIX_EPOCH};
9 +use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11 use agent_client_protocol::schema::ProtocolVersion;
12 use agent_client_protocol::schema::v1::{
@@ -31,7 +31,7 @@ use crate::content as prompt;
31 use crate::llm::{Delta, LlmClient, LlmConfig, LlmEnv};
32 use crate::store::{SessionStore, StoredSession};
33 use crate::tools::{self, ToolCtx, ToolOutcome, Toolset, absolutize, normalize};
34 -use crate::{Profile, PromptCtx, SessionCtx};
34 +use crate::{CommandCtx, Profile, PromptCtx, SessionCtx};
35
36 const MAX_TURNS: usize = 50;
37 /// Id of the session config option that selects the model.
@@ -44,12 +44,14 @@ const DEFAULT_CONTEXT_WINDOW: u64 = 128_000;
44 pub const AUTH_METHOD_ID: &str = "terminal-setup";
45 /// Cap on the sessions returned by `session/list`.
46 const MAX_LISTED: usize = 200;
47 +/// How long `run_command` may run when `<PREFIX>_COMMAND_TIMEOUT_SECS` is unset.
48 +const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
49
50 /// The terminal auth method: clients run `<agent> --setup`.
51 pub fn auth_methods() -> Vec<AuthMethod> {
52 vec![AuthMethod::Terminal(
53 AuthMethodTerminal::new(AUTH_METHOD_ID, "Run in terminal")
52 - .description("Interactive setup: store your Onde Inference API key")
54 + .description("Interactive setup: choose a provider and store an API key")
55 .args(vec!["--setup".into()]),
56 )]
57 }
@@ -70,7 +72,7 @@ pub fn supports_terminal_auth(caps: &ClientCapabilities) -> bool {
72
73 pub fn auth_required_error(agent: &str) -> agent_client_protocol::Error {
74 agent_client_protocol::Error::auth_required().data(format!(
73 - "No Onde API key configured. Authenticate with the terminal method (`{agent} --setup`)."
75 + "No API key configured. Authenticate with the terminal method (`{agent} --setup`)."
76 ))
77 }
78
@@ -333,7 +335,10 @@ impl AcpServer {
335 let fresh = LlmConfig::from_env(&self.env);
336 let changed = fresh != *self.llm().config();
337 if changed {
336 - tracing::info!("credentials changed; reloading the Onde client");
338 + tracing::info!(
339 + "credentials changed; reloading provider {}",
340 + fresh.provider.name()
341 + );
342 *self.llm.lock().unwrap() = LlmClient::new(fresh);
343 *self.models.lock().await = None;
344 }
@@ -355,20 +360,26 @@ impl AcpServer {
360 }
361 }
362
358 - /// `logout`: remove the stored key and forget it. A key exported in the agent's own
359 - /// environment can't be removed from here and keeps the agent signed in.
363 + /// `logout`: remove the stored key and forget it. Every file the key may be loaded from
364 + /// goes, or the agent would still be signed in afterwards; one failed removal doesn't stop
365 + /// the others. A key exported in the agent's own environment can't be removed from here
366 + /// and keeps the agent signed in.
367 pub async fn logout(&self) -> agent_client_protocol::Result<LogoutResponse> {
361 - let removed = match self.env.config_file() {
362 - Some(path) => match std::fs::remove_file(&path) {
368 + let failures: Vec<String> = self
369 + .env
370 + .config_file_candidates()
371 + .into_iter()
372 + .filter_map(|path| match std::fs::remove_file(&path) {
373 Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
364 - Err(format!("removing {}: {e}", path.display()))
374 + Some(format!("removing {}: {e}", path.display()))
375 }
366 - _ => Ok(()),
367 - },
368 - None => Ok(()),
369 - };
376 + _ => None,
377 + })
378 + .collect();
379 self.refresh_credentials().await;
371 - removed.map_err(|e| agent_client_protocol::Error::internal_error().data(e))?;
380 + if !failures.is_empty() {
381 + return Err(agent_client_protocol::Error::internal_error().data(failures.join("; ")));
382 + }
383 Ok(LogoutResponse::new())
384 }
385
@@ -1003,6 +1014,15 @@ impl AcpServer {
1014 if let Err(e) = self.restore(&request.session_id).await {
1015 return responder.respond_with_error(e);
1016 }
1017 + if let Some(reply) = self.answer_slash(&request).await {
1018 + connection.send_notification(SessionNotification::new(
1019 + request.session_id.clone(),
1020 + SessionUpdate::AgentMessageChunk(
1021 + ContentChunk::new(reply.into()).message_id(new_message_id()),
1022 + ),
1023 + ))?;
1024 + return responder.respond(PromptResponse::new(StopReason::EndTurn));
1025 + }
1026 match self.run_turn(request, connection).await {
1027 Ok(stop) => responder.respond(PromptResponse::new(stop)),
1028 Err(e) if e.downcast_ref::<UnknownSession>().is_some() => {
@@ -1014,6 +1034,28 @@ impl AcpServer {
1034 }
1035 }
1036
1037 + /// The profile's reply to a slash command it answers without the model, for a prompt that
1038 + /// is a single text block. The reply is not added to the conversation.
1039 + async fn answer_slash(&self, request: &PromptRequest) -> Option<String> {
1040 + let [ContentBlock::Text(text)] = request.prompt.as_slice() else {
1041 + return None;
1042 + };
1043 + if !text.text.trim_start().starts_with('/') {
1044 + return None;
1045 + }
1046 + let (model, _) = self.session_settings(&request.session_id)?;
1047 + let models = self.models().await;
1048 + let mcp = self.session_mcp(&request.session_id);
1049 + self.profile.answer_slash(
1050 + &text.text,
1051 + &CommandCtx {
1052 + mcp: &mcp,
1053 + model: &model,
1054 + models: &models,
1055 + },
1056 + )
1057 + }
1058 +
1059 async fn run_turn(
1060 &self,
1061 request: PromptRequest,
@@ -1093,6 +1135,11 @@ impl AcpServer {
1135 always_allowed,
1136 always_rejected,
1137 mcp,
1138 + command_timeout: std::env::var(self.env.var("COMMAND_TIMEOUT_SECS"))
1139 + .ok()
1140 + .and_then(|v| v.parse().ok())
1141 + .map(Duration::from_secs)
1142 + .unwrap_or(DEFAULT_COMMAND_TIMEOUT),
1143 };
1144 let result = self.turn(&ctx, &model, &mut messages, &blocks).await;
1145
crates/ed-acp/src/cli.rs
+83 -22
@@ -1,12 +1,14 @@
1 -//! Command-line pieces every Onde agent shares: `--setup` (store and verify the Onde key),
2 -//! `--list-models`, flag parsing and logging.
1 +//! Command-line pieces every Onde agent shares: `--setup` (choose a provider, store and verify
2 +//! its key), `--list-models`, flag parsing and logging.
3
4 use std::io::IsTerminal;
5
6 use anyhow::Context;
7
8 use crate::AgentInfo;
9 -use crate::llm::{LlmClient, LlmConfig, LlmEnv};
9 +use crate::llm::{
10 + LlmClient, LlmConfig, LlmEnv, OPENAI_DEFAULT_BASE_URL, OPENAI_DEFAULT_MODEL, Provider,
11 +};
12
13 /// Log to stderr, filtered by `RUST_LOG`. Stdout is reserved for ACP.
14 pub fn init_logging() {
@@ -32,47 +34,98 @@ pub fn valid_onde_key(key: &str) -> bool {
34
35 /// `env` file content with `ONDE_API_KEY` set, keeping any other lines.
36 pub fn with_api_key(existing: &str, key: &str) -> String {
37 + with_vars(existing, &[("ONDE_API_KEY".to_string(), key.to_string())])
38 +}
39 +
40 +/// `env` file content with each of `vars` set, replacing earlier values and keeping any
41 +/// other lines.
42 +pub fn with_vars(existing: &str, vars: &[(String, String)]) -> String {
43 let mut lines: Vec<String> = existing
44 .lines()
45 .filter(|l| {
46 let l = l.trim().strip_prefix("export ").unwrap_or(l.trim());
39 - !l.starts_with("ONDE_API_KEY=")
47 + !vars.iter().any(|(k, _)| l.starts_with(&format!("{k}=")))
48 })
49 .map(String::from)
50 .collect();
43 - lines.push(format!("ONDE_API_KEY={key}"));
51 + lines.extend(vars.iter().map(|(k, v)| format!("{k}={v}")));
52 lines.join("\n") + "\n"
53 }
54
47 -/// Interactive first-run setup (`--setup`): prompt for the Onde key, verify it against Onde
48 -/// Cloud, and store it in the platform config dir (`config_dir()/env`, mode 0600).
55 +/// Interactive first-run setup (`--setup`): choose Onde Inference or an OpenAI API compatible
56 +/// endpoint, prompt for its key, verify it, and store it in the platform config dir
57 +/// (`config_dir()/env`, mode 0600).
58 pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> {
59 use std::io::Write;
60
61 if !std::io::stdin().is_terminal() {
62 anyhow::bail!("--setup needs an interactive terminal");
63 }
55 - println!("{} setup\n", info.name);
56 - println!("Get credentials: sign in at https://ondeinference.com/root/login,");
57 - println!("register an app and assign a model. Your key is \"app-id:app-secret\".\n");
58 - let key = loop {
59 - print!("Paste your ONDE_API_KEY: ");
64 + let prompt_line = |label: &str| -> anyhow::Result<String> {
65 + print!("{label}");
66 std::io::stdout().flush()?;
67 let mut line = String::new();
68 if std::io::stdin().read_line(&mut line)? == 0 {
69 anyhow::bail!("no input");
70 }
65 - let key = line.trim().to_string();
66 - if valid_onde_key(&key) {
67 - break key;
68 - }
69 - println!("Onde credentials look like \"app-id:app-secret\" (exactly one colon).");
71 + Ok(line.trim().to_string())
72 };
73
72 - // Verify before writing anything.
73 - let mut config = LlmConfig::from_env(&info.env);
74 - config.api_key = Some(key.clone());
75 - print!("Verifying the key with Onde Cloud… ");
74 + println!("{} setup\n", info.name);
75 + println!(" 1) Onde Inference (ONDE_API_KEY)");
76 + println!(
77 + " 2) OpenAI API compatible endpoint (OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL)"
78 + );
79 + let provider = loop {
80 + match prompt_line("\nChoose a provider [1-2]: ")?.as_str() {
81 + "" | "1" | "onde" => break Provider::Onde,
82 + "2" | "openai" => break Provider::OpenAi,
83 + _ => println!("Please enter 1 or 2."),
84 + }
85 + };
86 + let mut vars = vec![(info.env.var("PROVIDER"), provider.name().to_string())];
87 + match provider {
88 + Provider::Onde => {
89 + println!("\nGet credentials: sign in at https://ondeinference.com/root/login,");
90 + println!("register an app and assign a model. Your key is \"app-id:app-secret\".\n");
91 + }
92 + // A generic endpoint also needs its URL and a model that supports tool calling.
93 + Provider::OpenAi => {
94 + for (var, label, default) in [
95 + ("OPENAI_BASE_URL", "Base URL", OPENAI_DEFAULT_BASE_URL),
96 + ("OPENAI_MODEL", "Model", OPENAI_DEFAULT_MODEL),
97 + ] {
98 + let value = prompt_line(&format!("{label} [{default}]: "))?;
99 + let value = if value.is_empty() {
100 + default.to_string()
101 + } else {
102 + value
103 + };
104 + vars.push((var.to_string(), value));
105 + }
106 + }
107 + }
108 + let key_var = provider.key_var();
109 + let key = loop {
110 + let key = prompt_line(&format!("Paste your {key_var}: "))?;
111 + match provider {
112 + Provider::Onde if !valid_onde_key(&key) => {
113 + println!("Onde credentials look like \"app-id:app-secret\" (exactly one colon).")
114 + }
115 + _ if key.is_empty() => println!("Key must not be empty."),
116 + _ => break key,
117 + }
118 + };
119 + vars.push((key_var.to_string(), key));
120 +
121 + // Verify before writing anything: the new values win over the environment.
122 + let config = LlmConfig::from_lookup(&info.env, |name| {
123 + vars.iter()
124 + .find(|(k, _)| k == name)
125 + .map(|(_, v)| v.clone())
126 + .or_else(|| std::env::var(name).ok())
127 + });
128 + print!("Verifying the key with {}… ", provider.display_name());
129 std::io::stdout().flush()?;
130 match LlmClient::new(config).check_auth().await {
131 Ok(()) => println!("OK"),
@@ -86,7 +139,7 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> {
139 std::fs::create_dir_all(&dir)?;
140 let path = dir.join("env");
141 let existing = std::fs::read_to_string(&path).unwrap_or_default();
89 - std::fs::write(&path, with_api_key(&existing, &key))?;
142 + std::fs::write(&path, with_vars(&existing, &vars))?;
143 #[cfg(unix)]
144 {
145 use std::os::unix::fs::PermissionsExt;
@@ -137,5 +190,13 @@ mod tests {
190 let out = with_api_key("# hi\nX_MODEL=m\nexport ONDE_API_KEY=old:old\n", "new:key");
191 assert_eq!(out, "# hi\nX_MODEL=m\nONDE_API_KEY=new:key\n");
192 assert_eq!(with_api_key("", "a:b"), "ONDE_API_KEY=a:b\n");
193 + let vars = [
194 + ("X_PROVIDER".to_string(), "openai".to_string()),
195 + ("OPENAI_API_KEY".to_string(), "sk".to_string()),
196 + ];
197 + assert_eq!(
198 + with_vars("X_PROVIDER=onde\nONDE_API_KEY=a:b\n", &vars),
199 + "ONDE_API_KEY=a:b\nX_PROVIDER=openai\nOPENAI_API_KEY=sk\n"
200 + );
201 }
202 }
crates/ed-acp/src/lib.rs
+19 -4
@@ -4,9 +4,9 @@
4 //! version negotiation, terminal auth, `authenticate` and `logout`, durable sessions (`list`,
5 //! `load` with full replay, `resume`, `close`, `delete`), the model picker and auto-approve
6 //! config options, slash-command advertisement, prompt content (embedded and linked files,
7 -//! images, audio), the turn loop against Onde Cloud with streaming, message ids and usage,
8 -//! tool approval, the workspace tools routed through the client's `fs/*` and `terminal/*`, and
9 -//! MCP servers through [`ed_mcp`].
7 +//! images, audio), the turn loop against Onde Cloud (or any OpenAI API compatible endpoint)
8 +//! with streaming, message ids and usage, tool approval, the workspace tools routed through the
9 +//! client's `fs/*` and `terminal/*`, and MCP servers through [`ed_mcp`].
10 //!
11 //! A product supplies a [`Profile`]: its name, prompt, tools, slash commands and built-in MCP
12 //! servers. Everything in the Onde Agent Platform §5.5 conformance table is implemented here
@@ -37,7 +37,7 @@ use serde_json::Value;
37 pub use agent_client_protocol;
38 pub use content::AudioHints;
39 pub use ed_mcp;
40 -pub use llm::{LlmConfig, LlmEnv};
40 +pub use llm::{LlmConfig, LlmEnv, Provider};
41 pub use server::{ServeOptions, serve_stdio};
42 pub use tools::{
43 DescribeCtx, ToolCtx, ToolOutcome, Toolset, absolutize, function_def, normalize, resolve_in,
@@ -74,6 +74,15 @@ pub struct SessionCtx<'a> {
74 pub mcp: &'a McpToolset,
75 }
76
77 +/// What a slash command answered without the model can report.
78 +pub struct CommandCtx<'a> {
79 + pub mcp: &'a McpToolset,
80 + /// The session's model.
81 + pub model: &'a str,
82 + /// The models offered in the model picker.
83 + pub models: &'a [String],
84 +}
85 +
86 /// An MCP call awaiting approval.
87 pub struct McpCall<'a> {
88 pub tool: &'a McpTool,
@@ -106,6 +115,12 @@ pub trait Profile: Send + Sync + 'static {
115 None
116 }
117
118 + /// When `text` is one of this profile's commands that needs no model (`/models`, `/setup`),
119 + /// the reply shown in the thread. It is not added to the conversation.
120 + fn answer_slash(&self, _text: &str, _ctx: &CommandCtx<'_>) -> Option<String> {
121 + None
122 + }
123 +
124 /// Process-wide MCP servers offered to every session unless the client supplies one of
125 /// the same name.
126 fn builtin_mcp_servers(&self) -> Vec<&'static SharedServer> {
crates/ed-acp/src/llm.rs
+207 -20
@@ -1,5 +1,6 @@
1 -//! Streaming client for Onde Cloud's OpenAI-compatible `/chat/completions` endpoint: text and
2 -//! reasoning deltas, tool-call accumulation, token usage, and `GET /models`.
1 +//! Streaming client for OpenAI-compatible `/chat/completions` endpoints (Onde Cloud by default,
2 +//! or any other with the `OPENAI_*` variables): text and reasoning deltas, tool-call
3 +//! accumulation, token usage, and `GET /models`.
4 //!
5 //! Superseded by `onde-backend` once it exists (Onde Agent Platform §5.2); the agent depends
6 //! only on this module's public types so that swap stays local.
@@ -15,6 +16,54 @@ pub const DEFAULT_BASE_URL: &str = "https://cloud.ondeinference.com/v1";
16 pub const DEFAULT_MODEL: &str = "onde-kkk";
17 /// The credential every Onde agent reads: `app-id:app-secret`.
18 pub const API_KEY_VAR: &str = "ONDE_API_KEY";
19 +/// Base URL of a generic OpenAI API compatible endpoint when `OPENAI_BASE_URL` is unset.
20 +pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
21 +/// Model for a generic endpoint when neither `<PREFIX>_MODEL` nor `OPENAI_MODEL` is set.
22 +pub const OPENAI_DEFAULT_MODEL: &str = "gpt-4o-mini";
23 +
24 +/// Where completions come from. Both speak the OpenAI chat completions API.
25 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26 +pub enum Provider {
27 + /// Onde Cloud: `ONDE_API_KEY` (`app-id:app-secret`), `<PREFIX>_BASE_URL` or
28 + /// `ONDE_BASE_URL` for a local or staging deployment.
29 + Onde,
30 + /// Any OpenAI API compatible endpoint: `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL`.
31 + OpenAi,
32 +}
33 +
34 +impl Provider {
35 + /// `onde` or `openai`, as written to `<PREFIX>_PROVIDER`.
36 + pub fn name(self) -> &'static str {
37 + match self {
38 + Self::Onde => "onde",
39 + Self::OpenAi => "openai",
40 + }
41 + }
42 +
43 + pub fn parse(name: &str) -> Option<Self> {
44 + match name.to_ascii_lowercase().as_str() {
45 + "onde" | "onde-cloud" | "ondeinference" => Some(Self::Onde),
46 + "openai" => Some(Self::OpenAi),
47 + _ => None,
48 + }
49 + }
50 +
51 + /// The variable holding this provider's key.
52 + pub fn key_var(self) -> &'static str {
53 + match self {
54 + Self::Onde => API_KEY_VAR,
55 + Self::OpenAi => "OPENAI_API_KEY",
56 + }
57 + }
58 +
59 + /// Name shown to people, e.g. in setup and errors.
60 + pub fn display_name(self) -> &'static str {
61 + match self {
62 + Self::Onde => "Onde Inference",
63 + Self::OpenAi => "the OpenAI API compatible endpoint",
64 + }
65 + }
66 +}
67
68 /// Where an agent's settings come from: `<PREFIX>_BASE_URL`, `<PREFIX>_MODEL` and
69 /// `<PREFIX>_DATA_DIR` in the environment, then `<config dir>/<dir_name>/env`.
@@ -54,6 +103,28 @@ impl LlmEnv {
103 self.config_dir().map(|d| d.join("env"))
104 }
105
106 + /// Where the stored `env` file is looked for, most preferred first: [`Self::config_file`],
107 + /// then `~/.config/<dir_name>/env`, where agents on macOS kept it before they moved to the
108 + /// platform directory. Only the first is used when `<PREFIX>_DATA_DIR` is set.
109 + pub fn config_file_candidates(&self) -> Vec<std::path::PathBuf> {
110 + let mut paths: Vec<std::path::PathBuf> = self.config_file().into_iter().collect();
111 + let overridden = std::env::var_os(self.var("DATA_DIR")).is_some_and(|v| !v.is_empty());
112 + #[cfg(not(target_os = "windows"))]
113 + if !overridden && let Some(dirs) = directories::BaseDirs::new() {
114 + let legacy = dirs
115 + .home_dir()
116 + .join(".config")
117 + .join(self.dir_name)
118 + .join("env");
119 + if !paths.contains(&legacy) {
120 + paths.push(legacy);
121 + }
122 + }
123 + #[cfg(target_os = "windows")]
124 + let _ = overridden;
125 + paths
126 + }
127 +
128 /// Where durable state (sessions, audio scratch) lives. Same directory as the `env` file.
129 pub fn data_dir(&self) -> std::path::PathBuf {
130 self.config_dir()
@@ -63,8 +134,9 @@ impl LlmEnv {
134 fn load_file_vars(&self) -> std::collections::HashMap<String, String> {
135 let mut vars = std::collections::HashMap::new();
136 let content = self
66 - .config_file()
67 - .and_then(|p| std::fs::read_to_string(p).ok());
137 + .config_file_candidates()
138 + .iter()
139 + .find_map(|p| std::fs::read_to_string(p).ok());
140 let Some(content) = content else {
141 return vars;
142 };
@@ -91,15 +163,24 @@ impl LlmEnv {
163
164 #[derive(Debug, Clone, PartialEq, Eq)]
165 pub struct LlmConfig {
166 + pub provider: Provider,
167 pub base_url: String,
95 - /// Onde credentials: `app-id:app-secret`, sent as the bearer token.
168 + /// Sent as the bearer token. For Onde Cloud, `app-id:app-secret`.
169 pub api_key: Option<String>,
170 pub model: String,
171 }
172
173 impl LlmConfig {
101 - /// `ONDE_API_KEY` from the environment, falling back to the stored `env` file.
102 - /// `<PREFIX>_BASE_URL` and `<PREFIX>_MODEL` override the endpoint and default model.
174 + /// Settings from the environment, falling back to the stored `env` file.
175 + ///
176 + /// The provider is `<PREFIX>_PROVIDER` (`onde` or `openai`) when set, else Onde Cloud
177 + /// whenever `ONDE_API_KEY` is set, else a generic endpoint when `OPENAI_API_KEY` is, else
178 + /// Onde Cloud. Onde Cloud reads only `ONDE_API_KEY`, and its own URL unless
179 + /// `<PREFIX>_BASE_URL` or `ONDE_BASE_URL` names another deployment, so stray `OPENAI_*`
180 + /// variables can't redirect it or send it the wrong key. A generic endpoint reads
181 + /// `OPENAI_BASE_URL`, `OPENAI_API_KEY` and `OPENAI_MODEL`. `<PREFIX>_MODEL` picks the
182 + /// model for either.
183 + ///
184 /// Empty values are ignored: GUI launchers (e.g. Zed via launchd) often export
185 /// variables set to "", which must not mask the config file.
186 pub fn from_env(env: &LlmEnv) -> Self {
@@ -115,13 +196,34 @@ impl LlmConfig {
196 /// Build a config from an arbitrary variable lookup (tests, setup).
197 pub fn from_lookup(env: &LlmEnv, lookup: impl Fn(&str) -> Option<String>) -> Self {
198 let get = |name: &str| lookup(name).filter(|v| !v.is_empty());
199 + let provider = match get(&env.var("PROVIDER")) {
200 + Some(name) => Provider::parse(&name).unwrap_or_else(|| {
201 + tracing::warn!("unknown {} {name:?}; using onde", env.var("PROVIDER"));
202 + Provider::Onde
203 + }),
204 + None if get(API_KEY_VAR).is_some() => Provider::Onde,
205 + None if get("OPENAI_API_KEY").is_some() => Provider::OpenAi,
206 + None => Provider::Onde,
207 + };
208 + let (base_url, model) = match provider {
209 + Provider::Onde => (
210 + get(&env.var("BASE_URL"))
211 + .or_else(|| get("ONDE_BASE_URL"))
212 + .unwrap_or_else(|| DEFAULT_BASE_URL.into()),
213 + get(&env.var("MODEL")).unwrap_or_else(|| env.default_model.into()),
214 + ),
215 + Provider::OpenAi => (
216 + get("OPENAI_BASE_URL").unwrap_or_else(|| OPENAI_DEFAULT_BASE_URL.into()),
217 + get(&env.var("MODEL"))
218 + .or_else(|| get("OPENAI_MODEL"))
219 + .unwrap_or_else(|| OPENAI_DEFAULT_MODEL.into()),
220 + ),
221 + };
222 Self {
119 - base_url: get(&env.var("BASE_URL"))
120 - .unwrap_or_else(|| DEFAULT_BASE_URL.into())
121 - .trim_end_matches('/')
122 - .to_string(),
123 - api_key: get(API_KEY_VAR),
124 - model: get(&env.var("MODEL")).unwrap_or_else(|| env.default_model.into()),
223 + provider,
224 + base_url: base_url.trim_end_matches('/').to_string(),
225 + api_key: get(provider.key_var()),
226 + model,
227 }
228 }
229 }
@@ -167,6 +269,75 @@ mod tests {
269 );
270 }
271
272 + #[test]
273 + fn onde_base_url_points_onde_at_another_deployment() {
274 + let c = config(&[
275 + ("ONDE_API_KEY", "app:secret"),
276 + ("ONDE_BASE_URL", "http://localhost:8090/v1/"),
277 + ]);
278 + assert_eq!(c.base_url, "http://localhost:8090/v1");
279 + let both = config(&[
280 + ("ONDE_BASE_URL", "http://a/v1"),
281 + ("SPLITFIRE_BASE_URL", "http://b/v1"),
282 + ]);
283 + assert_eq!(both.base_url, "http://b/v1");
284 + }
285 +
286 + #[test]
287 + fn openai_key_selects_a_generic_endpoint() {
288 + let c = config(&[
289 + ("OPENAI_API_KEY", "sk"),
290 + ("OPENAI_MODEL", "my-model"),
291 + ("OPENAI_BASE_URL", "http://x/v1/"),
292 + ("ONDE_BASE_URL", "http://onde/v1"),
293 + ]);
294 + assert_eq!(
295 + (
296 + c.provider,
297 + c.base_url.as_str(),
298 + c.api_key.as_deref(),
299 + c.model.as_str()
300 + ),
301 + (Provider::OpenAi, "http://x/v1", Some("sk"), "my-model")
302 + );
303 + let plain = config(&[("OPENAI_API_KEY", "sk")]);
304 + assert_eq!(
305 + (plain.base_url.as_str(), plain.model.as_str()),
306 + ("https://api.openai.com/v1", "gpt-4o-mini")
307 + );
308 + assert_eq!(config(&[]).provider, Provider::Onde);
309 + }
310 +
311 + #[test]
312 + fn onde_wins_over_openai_variables() {
313 + let c = config(&[
314 + ("ONDE_API_KEY", "app:secret"),
315 + ("OPENAI_API_KEY", "sk-other"),
316 + ("OPENAI_BASE_URL", "http://elsewhere/v1"),
317 + ("OPENAI_MODEL", "gpt-x"),
318 + ]);
319 + assert_eq!(c.provider, Provider::Onde);
320 + assert_eq!(c.api_key.as_deref(), Some("app:secret"));
321 + assert_eq!(c.base_url, "https://cloud.ondeinference.com/v1");
322 + assert_eq!(c.model, "onde-kkk");
323 + }
324 +
325 + #[test]
326 + fn explicit_provider_wins_over_detected_keys() {
327 + let c = config(&[
328 + ("ONDE_API_KEY", "app:secret"),
329 + ("OPENAI_API_KEY", "sk"),
330 + ("SPLITFIRE_PROVIDER", "openai"),
331 + ("SPLITFIRE_MODEL", "m"),
332 + ]);
333 + assert_eq!(
334 + (c.provider, c.api_key.as_deref(), c.model.as_str()),
335 + (Provider::OpenAi, Some("sk"), "m")
336 + );
337 + let onde = config(&[("SPLITFIRE_PROVIDER", "onde"), ("OPENAI_API_KEY", "sk")]);
338 + assert_eq!((onde.provider, onde.api_key), (Provider::Onde, None));
339 + }
340 +
341 /// One test touches the process environment, so it can't race another.
342 #[test]
343 fn key_from_env_then_file_ignoring_empty_env() {
@@ -182,6 +353,7 @@ mod tests {
353 std::env::set_var("SPLITFIRE_DATA_DIR", &dir);
354 std::env::set_var("ONDE_API_KEY", "");
355 }
356 + assert_eq!(ENV.config_file_candidates(), vec![dir.join("env")]);
357 let c = LlmConfig::from_env(&ENV);
358 assert_eq!(c.api_key.as_deref(), Some("file:key"));
359 assert_eq!(c.model, "m1");
@@ -233,6 +405,8 @@ pub struct ToolCallRequest {
405 pub id: String,
406 pub name: String,
407 pub arguments: String,
408 + /// Provider extras that must be echoed back (e.g. Gemini's `thought_signature`).
409 + pub extra_content: Option<Value>,
410 }
411
412 #[derive(Debug, Default)]
@@ -271,11 +445,15 @@ impl Completion {
445 .tool_calls
446 .iter()
447 .map(|tc| {
274 - json!({
448 + let mut call = json!({
449 "id": tc.id,
450 "type": "function",
451 "function": { "name": tc.name, "arguments": tc.arguments },
278 - })
452 + });
453 + if let Some(extra) = &tc.extra_content {
454 + call["extra_content"] = extra.clone();
455 + }
456 + call
457 })
458 .collect();
459 }
@@ -319,6 +497,7 @@ struct ToolCallDelta {
497 index: usize,
498 id: Option<String>,
499 function: Option<FunctionDelta>,
500 + extra_content: Option<Value>,
501 }
502
503 #[derive(Deserialize)]
@@ -370,8 +549,9 @@ impl LlmClient {
549 /// Verify the configured key with a minimal chat completion.
550 /// Returns `Err` when no key is set, the key is rejected, or the endpoint is unreachable.
551 pub async fn check_auth(&self) -> Result<()> {
552 + let provider = self.config.provider;
553 let Some(key) = &self.config.api_key else {
374 - bail!("no ONDE_API_KEY configured");
554 + bail!("no {} configured", provider.key_var());
555 };
556 let body = json!({
557 "model": self.config.model,
@@ -386,14 +566,17 @@ impl LlmClient {
566 .bearer_auth(key)
567 .send()
568 .await
389 - .context("reaching Onde Cloud")?;
569 + .with_context(|| format!("reaching {}", provider.display_name()))?;
570 let status = resp.status();
571 if matches!(status.as_u16(), 401 | 403) {
392 - bail!("Onde Cloud rejected the API key ({status})");
572 + bail!(
573 + "{} rejected the API key ({status})",
574 + provider.display_name()
575 + );
576 }
577 if !status.is_success() {
578 let text = resp.text().await.unwrap_or_default();
396 - bail!("Onde Cloud returned {status}: {text}");
579 + bail!("{} returned {status}: {text}", provider.display_name());
580 }
581 Ok(())
582 }
@@ -440,7 +623,8 @@ impl LlmClient {
623 ) -> Result<Completion> {
624 if self.config.api_key.is_none() {
625 bail!(
443 - "No API key configured. Set ONDE_API_KEY in the environment or run the agent's `--setup`"
626 + "No API key configured. Set {} in the environment or run the agent's `--setup`",
627 + self.config.provider.key_var()
628 );
629 }
630 let mut body = json!({
@@ -523,6 +707,9 @@ impl LlmClient {
707 if let Some(id) = tc.id {
708 slot.id = id;
709 }
710 + if tc.extra_content.is_some() {
711 + slot.extra_content = tc.extra_content;
712 + }
713 if let Some(f) = tc.function {
714 if let Some(n) = f.name {
715 slot.name.push_str(&n);
crates/ed-acp/src/tools.rs
+2
@@ -41,6 +41,8 @@ pub struct ToolCtx {
41 pub always_rejected: Arc<Mutex<HashSet<String>>>,
42 /// Tools from MCP servers available to this session.
43 pub mcp: Arc<McpToolset>,
44 + /// How long `run_command` may run: `<PREFIX>_COMMAND_TIMEOUT_SECS`, default 120 seconds.
45 + pub command_timeout: std::time::Duration,
46 }
47
48 /// What a tool call produced.
crates/ed-acp/src/workspace.rs
+4 -7
@@ -4,7 +4,6 @@
4 //! optional `root` for multi-root workspaces. Writes and commands ask for approval.
5
6 use std::path::Path;
7 -use std::time::Duration;
7
8 use agent_client_protocol::ErrorCode;
9 use agent_client_protocol::schema::v1::{
@@ -22,8 +21,6 @@ use crate::tools::{
21 function_def, resolve_in, truncate,
22 };
23
25 -const COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
26 -
24 /// The workspace tools as a [`Toolset`].
25 #[derive(Debug, Default, Clone, Copy)]
26 pub struct WorkspaceTools;
@@ -385,7 +382,7 @@ impl ToolCtx {
382 .block_task();
383 let exit = tokio::select! {
384 r = wait => Some(r?.exit_status),
388 - () = tokio::time::sleep(COMMAND_TIMEOUT) => None,
385 + () = tokio::time::sleep(self.command_timeout) => None,
386 () = self.cancel.cancelled() => None,
387 };
388 if exit.is_none() {
@@ -413,7 +410,7 @@ impl ToolCtx {
410 _ => "exited".into(),
411 },
412 None if self.cancel.is_cancelled() => "cancelled".into(),
416 - None => format!("timed out after {}s", COMMAND_TIMEOUT.as_secs()),
413 + None => format!("timed out after {}s", self.command_timeout.as_secs()),
414 };
415 let failed = !matches!(exit.as_ref().and_then(|s| s.exit_code), Some(0));
416 let trunc = if output.truncated {
@@ -438,9 +435,9 @@ impl ToolCtx {
435 .kill_on_drop(true)
436 .output();
437 let out = tokio::select! {
441 - r = tokio::time::timeout(COMMAND_TIMEOUT, child) => match r {
438 + r = tokio::time::timeout(self.command_timeout, child) => match r {
439 Ok(r) => r?,
443 - Err(_) => return Ok(ToolOutcome::err(format!("Command timed out after {}s", COMMAND_TIMEOUT.as_secs()))),
440 + Err(_) => return Ok(ToolOutcome::err(format!("Command timed out after {}s", self.command_timeout.as_secs()))),
441 },
442 () = self.cancel.cancelled() => return Ok(ToolOutcome::err("Command cancelled")),
443 };