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
21 changes: 12 additions & 9 deletions crates/sprout-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. |
| `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. |
| `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 @@ -147,17 +148,19 @@ Everything is environment variables. No flags, no config files. (We are a subpro

`sprout-agent` speaks two HTTP dialects. Pick with `SPROUT_AGENT_PROVIDER`.

| Provider | `SPROUT_AGENT_PROVIDER` | Endpoint | Tested with |
| Provider | `SPROUT_AGENT_PROVIDER` | Endpoint (auto) | Tested with |
|---|---|---|---|
| Anthropic | `anthropic` | `POST {base}/v1/messages` | claude-sonnet-4-5, claude-opus-4 |
| OpenAI | `openai` | `POST {base}/chat/completions` | gpt-5, gpt-4o |
| vLLM | `openai` | OpenAI-compatible endpoint | any tool-calling model |
| llama.cpp | `openai` | `--api-server` mode | any tool-calling GGUF |
| Ollama | `openai` | `http://localhost:11434/v1` | llama3.1, qwen2.5-coder |
| OpenRouter | `openai` | `https://openrouter.ai/api/v1` | anything they route |
| Block Gateway | `openai` | internal | gpt-5, claude |

The "OpenAI" path is wire-compatible with the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). If a provider claims OpenAI compatibility and supports `tools` + `tool_choice: auto`, it works here.
| OpenAI | `openai` | `POST {base}/responses` | gpt-5, gpt-5-mini, o4-mini, gpt-4o |
| vLLM | `openai` | `POST {base}/chat/completions` | any tool-calling model |
| llama.cpp | `openai` | `POST {base}/chat/completions` | any tool-calling GGUF |
| 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 |

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

By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI_COMPAT_BASE_URL` points at an `*.openai.com` host and **Chat Completions** everywhere else. Pin the choice explicitly with `OPENAI_COMPAT_API=chat` or `OPENAI_COMPAT_API=responses` for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway).

`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box<dyn>`, no async-trait. Adding a third provider is a `match` arm and one `body`/`parse` pair in `llm.rs`.

Expand Down
86 changes: 85 additions & 1 deletion crates/sprout-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ pub enum Provider {
OpenAi,
}

/// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API`
/// (`auto|chat|responses`); ignored when `provider = Anthropic`. `Auto`
/// picks Responses for `*.openai.com`, Chat Completions otherwise, and
/// permits a one-shot chat→responses upgrade on a "use /v1/responses"
/// provider error.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OpenAiApi {
Chat,
Responses,
Auto,
}

#[derive(Debug, Clone)]
pub struct Config {
pub provider: Provider,
Expand Down Expand Up @@ -57,6 +69,8 @@ pub struct Config {
pub model: String,
pub base_url: String,
pub anthropic_api_version: String,
/// OpenAI endpoint selection. See [`OpenAiApi`].
pub openai_api: OpenAiApi,
}

impl Config {
Expand All @@ -66,16 +80,20 @@ impl Config {
"openai" | "openai-compat" => Provider::OpenAi,
o => return Err(format!("config: SPROUT_AGENT_PROVIDER={o} not supported")),
};
let (api_key, model, base_url) = match provider {
// OPENAI_COMPAT_API is only read when provider=openai, so a stray
// bad value can't break an Anthropic-only deployment.
let (api_key, model, base_url, openai_api) = match provider {
Provider::Anthropic => (
req("ANTHROPIC_API_KEY")?,
req("ANTHROPIC_MODEL")?,
env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"),
OpenAiApi::Auto, // unused for Anthropic
),
Provider::OpenAi => (
req("OPENAI_COMPAT_API_KEY")?,
req("OPENAI_COMPAT_MODEL")?,
env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?,
),
};
let system_prompt = match (env("SPROUT_AGENT_SYSTEM_PROMPT"), env("SPROUT_AGENT_SYSTEM_PROMPT_FILE")) {
Expand All @@ -92,6 +110,7 @@ impl Config {
model,
base_url,
anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"),
openai_api,
max_rounds: parse_env("SPROUT_AGENT_MAX_ROUNDS", 0)?,
max_output_tokens: parse_env("SPROUT_AGENT_MAX_OUTPUT_TOKENS", 32_768)?,
llm_timeout: Duration::from_secs(parse_env("SPROUT_AGENT_LLM_TIMEOUT_SECS", 120)?),
Expand Down Expand Up @@ -182,6 +201,35 @@ fn req(k: &str) -> Result<String, String> {
env(k).ok_or_else(|| format!("config: {k} required"))
}

/// 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> {
match raw.unwrap_or("auto").trim().to_ascii_lowercase().as_str() {
"chat" | "chat-completions" | "chat_completions" => Ok(OpenAiApi::Chat),
"responses" => Ok(OpenAiApi::Responses),
"auto" | "" => Ok(OpenAiApi::Auto),
other => Err(format!(
"config: OPENAI_COMPAT_API={other} not supported (use auto|chat|responses)"
)),
}
}

/// `true` when `base_url` is an official OpenAI host. Hosts on
/// `*.openai.com` get Responses under `Auto`; everything else (vLLM,
/// Ollama, OpenRouter, Block Gateway, …) gets Chat Completions.
/// Lookalike-safe: `api.openai.com.evil.example` returns `false`.
pub fn is_openai_host(base_url: &str) -> bool {
let rest = match base_url
.strip_prefix("https://")
.or_else(|| base_url.strip_prefix("http://"))
{
Some(r) => r,
None => return false,
};
let host = &rest[..rest.find(['/', ':']).unwrap_or(rest.len())];
host == "api.openai.com" || host.ends_with(".openai.com")
}

fn parse_env<T: std::str::FromStr>(key: &str, default: T) -> Result<T, String>
where
T::Err: std::fmt::Display,
Expand Down Expand Up @@ -337,4 +385,40 @@ mod tests {
// expectation for callers.
assert!(hs.allows("*"));
}

#[test]
fn parse_openai_api_values() {
use OpenAiApi::*;
for (raw, want) in [
(None, Ok(Auto)),
(Some("auto"), Ok(Auto)),
(Some(" AUTO "), Ok(Auto)),
(Some(""), Ok(Auto)),
(Some("chat"), Ok(Chat)),
(Some("chat-completions"), Ok(Chat)),
(Some("Responses"), Ok(Responses)),
] {
assert_eq!(parse_openai_api(raw), want, "raw={raw:?}");
}
let err = parse_openai_api(Some("nope")).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API=nope"), "{err}");
}

#[test]
fn is_openai_host_matrix() {
// Lookalike-safe: `api.openai.com.evil.example` and malformed URLs
// are treated as non-OpenAI (which falls back to Chat Completions).
for (url, want) in [
("https://api.openai.com/v1", true),
("https://api.openai.com", true),
("http://eu.api.openai.com/v1", true),
("http://localhost:11434/v1", false),
("https://openrouter.ai/api/v1", false),
("https://gateway.block.example/v1", false),
("https://api.openai.com.evil.example/v1", false),
("not a url", false),
] {
assert_eq!(is_openai_host(url), want, "url={url}");
}
}
}
Loading
Loading