Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/sprout-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ path = "tests/bin/fake_mcp.rs"
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls", "form"] }
rmcp = { version = "1", default-features = false, features = ["client", "transport-child-process"] }
arc-swap = "1"
Expand Down
18 changes: 15 additions & 3 deletions crates/sprout-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ OPENAI_COMPAT_API_KEY=sk-... \
OPENAI_COMPAT_MODEL=gpt-5 \
OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \
./target/release/sprout-agent

# Or Databricks model serving via OAuth 2.0 PKCE
SPROUT_AGENT_PROVIDER=databricks \
DATABRICKS_HOST=https://dbc-...cloud.databricks.com \
DATABRICKS_MODEL=goose-claude-4-6-sonnet \
./target/release/sprout-agent
```

That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr.
Expand Down Expand Up @@ -123,15 +129,18 @@ Everything is environment variables. No flags, no config files. (We are a subpro

| Variable | Default | Notes |
|---|---|---|
| `SPROUT_AGENT_PROVIDER` | — | Required. `anthropic` or `openai`. |
| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. |
| `SPROUT_AGENT_PROVIDER` | — | `anthropic`, `openai`, or `databricks`. If unset, or if `anthropic`/`openai` is selected but its API key is missing, Databricks is auto-selected when `DATABRICKS_HOST` + `DATABRICKS_MODEL` are set. |
| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic unless Databricks fallback is configured. |
| `ANTHROPIC_MODEL` | — | Required when provider=anthropic. |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | |
| `ANTHROPIC_API_VERSION` | `2023-06-01` | |
| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. |
| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai unless Databricks fallback is configured. |
| `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_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. |
| `SPROUT_AGENT_MAX_ROUNDS` | `0` | Tool-loop iteration cap. 0 = unlimited. |
Expand All @@ -157,6 +166,9 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder |
| OpenRouter | `openai` | `POST {base}/chat/completions` | anything they route |
| 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.

`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).

Expand Down
296 changes: 287 additions & 9 deletions crates/sprout-agent/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::time::Duration;
use std::{collections::HashMap, path::PathBuf, time::Duration};

pub const PROTOCOL_VERSION: u32 = 1;

Expand Down Expand Up @@ -79,12 +79,16 @@ pub struct Config {

impl Config {
pub fn from_env() -> Result<Self, String> {
let provider = match req("SPROUT_AGENT_PROVIDER")?.to_ascii_lowercase().as_str() {
"anthropic" => Provider::Anthropic,
"openai" | "openai-compat" => Provider::OpenAi,
"databricks" => Provider::Databricks,
o => return Err(format!("config: SPROUT_AGENT_PROVIDER={o} not supported")),
};
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 provider = resolve_provider(
env("SPROUT_AGENT_PROVIDER").as_deref(),
env("ANTHROPIC_API_KEY").as_deref(),
env("OPENAI_COMPAT_API_KEY").as_deref(),
databricks_host.as_deref(),
databricks_model.as_deref(),
)?;
// OPENAI_COMPAT_API is only read when provider=openai, so a stray
// bad value can't break an Anthropic-only deployment.
//
Expand All @@ -106,8 +110,12 @@ impl Config {
),
Provider::Databricks => (
env("DATABRICKS_TOKEN").unwrap_or_default(),
req("DATABRICKS_MODEL")?,
req("DATABRICKS_HOST")?,
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()
})?,
OpenAiApi::Chat, // Databricks invocations is chat-shaped
),
};
Expand Down Expand Up @@ -216,6 +224,121 @@ 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())
}

fn databricks_available(host: Option<&str>, model: Option<&str>) -> bool {
present_nonempty(host) && present_nonempty(model)
}

fn resolve_provider(
requested: Option<&str>,
anthropic_key: Option<&str>,
openai_key: Option<&str>,
databricks_host: Option<&str>,
databricks_model: Option<&str>,
) -> Result<Provider, String> {
let databricks_ready = databricks_available(databricks_host, databricks_model);
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => {
let normalized = raw.to_ascii_lowercase();
match normalized.as_str() {
"anthropic" if present_nonempty(anthropic_key) => Ok(Provider::Anthropic),
"anthropic" if databricks_ready => {
tracing::warn!(
requested = raw,
"API key missing for requested provider; falling back to Databricks OAuth"
);
Ok(Provider::Databricks)
}
"anthropic" => Err(
"config: ANTHROPIC_API_KEY required (or set DATABRICKS_HOST and DATABRICKS_MODEL for Databricks OAuth fallback)".into(),
),
"openai" | "openai-compat" if present_nonempty(openai_key) => Ok(Provider::OpenAi),
"openai" | "openai-compat" if databricks_ready => {
tracing::warn!(
requested = raw,
"API key missing for requested provider; falling back to Databricks OAuth"
);
Ok(Provider::Databricks)
}
"openai" | "openai-compat" => Err(
"config: OPENAI_COMPAT_API_KEY required (or set DATABRICKS_HOST and DATABRICKS_MODEL for Databricks OAuth fallback)".into(),
),
"databricks" => Ok(Provider::Databricks),
_ => Err(format!(
"config: SPROUT_AGENT_PROVIDER={raw} not supported"
)),
}
}
None if databricks_ready => Ok(Provider::Databricks),
None => Err(
"config: SPROUT_AGENT_PROVIDER required (or set DATABRICKS_HOST and DATABRICKS_MODEL for Databricks OAuth fallback)".into(),
),
}
}

/// Parse `OPENAI_COMPAT_API`. Pure (env-free) for testability; the
/// caller hands in the raw value.
fn parse_openai_api(raw: Option<&str>) -> Result<OpenAiApi, String> {
Expand Down Expand Up @@ -419,6 +542,161 @@ 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!(
resolve_provider(
Some("anthropic"),
Some("sk-ant"),
None,
Some("https://dbc.example"),
Some("db-model")
)
.unwrap(),
Provider::Anthropic
);
assert_eq!(
resolve_provider(
Some("openai"),
None,
Some("sk-openai"),
Some("https://dbc.example"),
Some("db-model")
)
.unwrap(),
Provider::OpenAi
);
}

#[test]
fn resolve_provider_falls_back_to_databricks_when_requested_token_missing() {
assert_eq!(
resolve_provider(
Some("anthropic"),
None,
None,
Some("https://dbc.example"),
Some("goose-claude-4-6-sonnet")
)
.unwrap(),
Provider::Databricks
);
assert_eq!(
resolve_provider(
Some("openai-compat"),
None,
Some(" "),
Some("https://dbc.example"),
Some("goose-claude-4-6-sonnet")
)
.unwrap(),
Provider::Databricks
);
}

#[test]
fn resolve_provider_can_auto_select_databricks_without_explicit_provider() {
assert_eq!(
resolve_provider(
None,
None,
None,
Some("https://dbc.example"),
Some("goose-claude-4-6-sonnet")
)
.unwrap(),
Provider::Databricks
);
}

#[test]
fn resolve_provider_requires_databricks_host_and_model_for_fallback() {
let err = resolve_provider(
Some("openai"),
None,
None,
Some("https://dbc.example"),
None,
)
.unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"));
let err =
resolve_provider(None, None, None, Some("https://dbc.example"), None).unwrap_err();
assert!(err.contains("SPROUT_AGENT_PROVIDER required"));
}

#[test]
fn resolve_provider_unsupported_error_preserves_user_casing() {
let err = resolve_provider(Some("OpenAIish"), None, None, None, None).unwrap_err();
assert!(err.contains("SPROUT_AGENT_PROVIDER=OpenAIish"));
}

#[test]
fn is_openai_host_matrix() {
// Lookalike-safe: `api.openai.com.evil.example` and malformed URLs
Expand Down
Loading