sprout-agent: make Databricks defaults env-only (#868)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-08 09:17:30 -07:00
committed by GitHub
parent b384354e2b
commit 4ec7f8125e
4 changed files with 49 additions and 139 deletions

View File

@@ -138,8 +138,8 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. |
| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. |
| `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. |
| `DATABRICKS_HOST` | goose config | Required when provider=databricks or when using Databricks fallback. If unset, read from goose's `~/.config/goose/config.yaml`. |
| `DATABRICKS_MODEL` | goose config | Required when provider=databricks or when using Databricks fallback. If unset, uses `DATABRICKS_MODEL` from goose config, or `GOOSE_MODEL`/`GOOSE_MODE` when `GOOSE_PROVIDER=databricks`. |
| `DATABRICKS_HOST` | — | Required when provider=databricks or when using Databricks fallback. |
| `DATABRICKS_MODEL` | — | Required when provider=databricks or when using Databricks fallback. |
| `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. |
| `SPROUT_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. |
| `SPROUT_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. |
@@ -170,7 +170,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude |
| Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet |
If `SPROUT_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `SPROUT_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent automatically falls back to Databricks OAuth when Databricks host/model config is available. The same Databricks fallback applies when `SPROUT_AGENT_PROVIDER` is unset. Host/model can come from env or from goose's config file; explicit Anthropic/OpenAI API keys always win.
If `SPROUT_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `SPROUT_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent automatically falls back to Databricks OAuth when `DATABRICKS_HOST` and `DATABRICKS_MODEL` are set. The same Databricks fallback applies when `SPROUT_AGENT_PROVIDER` is unset. Explicit Anthropic/OpenAI API keys always win.
`provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format).

View File

@@ -1,4 +1,4 @@
use std::{collections::HashMap, path::PathBuf, time::Duration};
use std::time::Duration;
pub const PROTOCOL_VERSION: u32 = 1;
@@ -84,9 +84,8 @@ pub struct Config {
impl Config {
pub fn from_env() -> Result<Self, String> {
let goose_databricks = GooseDatabricksConfig::load_default();
let databricks_host = env("DATABRICKS_HOST").or_else(|| goose_databricks.host.clone());
let databricks_model = env("DATABRICKS_MODEL").or_else(|| goose_databricks.model.clone());
let databricks_host = env("DATABRICKS_HOST");
let databricks_model = env("DATABRICKS_MODEL");
let provider = resolve_provider(
env("SPROUT_AGENT_PROVIDER").as_deref(),
env("ANTHROPIC_API_KEY").as_deref(),
@@ -115,12 +114,8 @@ impl Config {
),
Provider::Databricks => (
env("DATABRICKS_TOKEN").unwrap_or_default(),
databricks_model.ok_or_else(|| {
"config: DATABRICKS_MODEL required (or set GOOSE_MODEL in goose config with GOOSE_PROVIDER=databricks)".to_string()
})?,
databricks_host.ok_or_else(|| {
"config: DATABRICKS_HOST required (or set DATABRICKS_HOST in goose config)".to_string()
})?,
databricks_model.ok_or_else(|| "config: DATABRICKS_MODEL required".to_string())?,
databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?,
OpenAiApi::Chat, // Databricks invocations is chat-shaped
),
};
@@ -237,66 +232,6 @@ fn req(k: &str) -> Result<String, String> {
env(k).ok_or_else(|| format!("config: {k} required"))
}
#[derive(Default)]
struct GooseDatabricksConfig {
host: Option<String>,
model: Option<String>,
}
impl GooseDatabricksConfig {
fn load_default() -> Self {
goose_config_path()
.and_then(|p| Self::load_from_path(&p))
.unwrap_or_default()
}
fn load_from_path(path: &std::path::Path) -> Option<Self> {
let raw = std::fs::read_to_string(path).ok()?;
let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(&raw).ok()?;
Some(Self::from_map(&map))
}
fn from_map(map: &HashMap<String, serde_yaml::Value>) -> Self {
let host = yaml_string(map, "DATABRICKS_HOST");
let explicit_model = yaml_string(map, "DATABRICKS_MODEL");
let goose_provider = yaml_string(map, "GOOSE_PROVIDER");
let goose_model = yaml_string(map, "GOOSE_MODEL");
let goose_mode = yaml_string(map, "GOOSE_MODE");
let model = explicit_model.or_else(|| {
if goose_provider
.as_deref()
.is_some_and(|p| p.eq_ignore_ascii_case("databricks"))
{
goose_model.or(goose_mode)
} else {
None
}
});
Self { host, model }
}
}
fn yaml_string(map: &HashMap<String, serde_yaml::Value>, key: &str) -> Option<String> {
map.get(key)?
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn goose_config_path() -> Option<PathBuf> {
if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") {
return Some(PathBuf::from(root).join("config").join("config.yaml"));
}
let home = std::env::var("HOME").ok()?;
Some(
PathBuf::from(home)
.join(".config")
.join("goose")
.join("config.yaml"),
)
}
fn present_nonempty(v: Option<&str>) -> bool {
v.map(str::trim).is_some_and(|s| !s.is_empty())
}
@@ -555,72 +490,6 @@ mod tests {
assert!(err.contains("OPENAI_COMPAT_API=nope"), "{err}");
}
#[test]
fn goose_databricks_config_reads_host_and_model() {
let map = HashMap::from([
(
"DATABRICKS_HOST".to_string(),
serde_yaml::Value::String("https://dbc.example".into()),
),
(
"GOOSE_PROVIDER".to_string(),
serde_yaml::Value::String("databricks".into()),
),
(
"GOOSE_MODEL".to_string(),
serde_yaml::Value::String("goose-claude-4-6-sonnet".into()),
),
]);
let cfg = GooseDatabricksConfig::from_map(&map);
assert_eq!(cfg.host.as_deref(), Some("https://dbc.example"));
assert_eq!(cfg.model.as_deref(), Some("goose-claude-4-6-sonnet"));
}
#[test]
fn goose_databricks_config_prefers_explicit_databricks_model() {
let map = HashMap::from([
(
"DATABRICKS_HOST".to_string(),
serde_yaml::Value::String("https://dbc.example".into()),
),
(
"DATABRICKS_MODEL".to_string(),
serde_yaml::Value::String("explicit-db-model".into()),
),
(
"GOOSE_PROVIDER".to_string(),
serde_yaml::Value::String("databricks".into()),
),
(
"GOOSE_MODEL".to_string(),
serde_yaml::Value::String("goose-model".into()),
),
]);
let cfg = GooseDatabricksConfig::from_map(&map);
assert_eq!(cfg.model.as_deref(), Some("explicit-db-model"));
}
#[test]
fn goose_databricks_config_ignores_goose_model_for_other_provider() {
let map = HashMap::from([
(
"DATABRICKS_HOST".to_string(),
serde_yaml::Value::String("https://dbc.example".into()),
),
(
"GOOSE_PROVIDER".to_string(),
serde_yaml::Value::String("anthropic".into()),
),
(
"GOOSE_MODEL".to_string(),
serde_yaml::Value::String("claude".into()),
),
]);
let cfg = GooseDatabricksConfig::from_map(&map);
assert_eq!(cfg.host.as_deref(), Some("https://dbc.example"));
assert!(cfg.model.is_none());
}
#[test]
fn resolve_provider_keeps_requested_provider_when_token_present() {
assert_eq!(

View File

@@ -3,6 +3,8 @@ fn main() {
println!("cargo:rerun-if-env-changed=SPROUT_RELAY_HTTP");
println!("cargo:rerun-if-env-changed=SPROUT_UPDATER_PUBLIC_KEY");
println!("cargo:rerun-if-env-changed=SPROUT_UPDATER_ENDPOINT");
println!("cargo:rerun-if-env-changed=SPROUT_BUILD_DATABRICKS_HOST");
println!("cargo:rerun-if-env-changed=SPROUT_BUILD_DATABRICKS_MODEL");
println!("cargo:rustc-check-cfg=cfg(sprout_updater_enabled)");
if let Ok(relay_url) = std::env::var("SPROUT_RELAY_URL") {
@@ -13,6 +15,14 @@ fn main() {
println!("cargo:rustc-env=SPROUT_DESKTOP_BUILD_RELAY_HTTP={relay_http}");
}
if let Ok(host) = std::env::var("SPROUT_BUILD_DATABRICKS_HOST") {
println!("cargo:rustc-env=SPROUT_DESKTOP_BUILD_DATABRICKS_HOST={host}");
}
if let Ok(model) = std::env::var("SPROUT_BUILD_DATABRICKS_MODEL") {
println!("cargo:rustc-env=SPROUT_DESKTOP_BUILD_DATABRICKS_MODEL={model}");
}
let updater_public_key = std::env::var("SPROUT_UPDATER_PUBLIC_KEY")
.ok()
.map(|value| value.trim().to_string())

View File

@@ -998,6 +998,13 @@ pub fn spawn_agent_child(
);
}
// Baked-in Databricks defaults for internal builds (sprout-releases sets
// SPROUT_BUILD_DATABRICKS_* at compile time; OSS builds bake nothing).
// Written BEFORE user env_vars so a GUI/persona override still wins.
for (key, value) in build_databricks_defaults() {
command.env(key, value);
}
// ── User env vars: persona first, then per-agent (last wins) ────────
//
// Precedence: desktop parent env < persona env_vars < agent env_vars.
@@ -1051,6 +1058,23 @@ fn child_rust_log_filter() -> String {
}
}
/// Databricks host/model baked in at compile time for internal builds. Empty
/// in OSS builds, where the `SPROUT_BUILD_DATABRICKS_*` env is unset.
fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> {
let mut defaults = Vec::new();
if let Some(host) = option_env!("SPROUT_DESKTOP_BUILD_DATABRICKS_HOST") {
if !host.is_empty() {
defaults.push(("DATABRICKS_HOST", host));
}
}
if let Some(model) = option_env!("SPROUT_DESKTOP_BUILD_DATABRICKS_MODEL") {
if !model.is_empty() {
defaults.push(("DATABRICKS_MODEL", model));
}
}
defaults
}
pub fn start_managed_agent_process(
app: &AppHandle,
record: &mut ManagedAgentRecord,
@@ -1182,6 +1206,13 @@ mod tests {
assert_eq!(p.mcp_command, Some("sprout-dev-mcp"));
}
#[test]
fn databricks_defaults_empty_in_oss_build() {
// OSS (and normal test) builds set neither SPROUT_BUILD_DATABRICKS_*,
// so nothing is baked in and no DATABRICKS_* is injected on spawn.
assert!(super::build_databricks_defaults().is_empty());
}
#[test]
fn sprout_agent_resolved_via_path() {
assert!(known_acp_runtime("/usr/local/bin/sprout-agent").is_some_and(|p| p.mcp_hooks));