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
};