Skip to content

Commit 7d5509d

Browse files
author
am
committed
fix: support keyless openai-compatible endpoints
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
1 parent 122a8b8 commit 7d5509d

16 files changed

Lines changed: 533 additions & 217 deletions

crates/buzz-agent/src/config.rs

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,9 @@ const DEFAULT_SYSTEM_PROMPT: &str =
752752
pub enum Provider {
753753
Anthropic,
754754
OpenAi,
755+
/// A custom OpenAI-compatible endpoint. Unlike official OpenAI, the base
756+
/// URL is explicit and bearer authentication is optional.
757+
OpenAiCompat,
755758
/// Databricks model serving. Routes to `{base_url}/serving-endpoints/{model}/invocations`
756759
/// with a dynamically-acquired bearer (OAuth 2.0 PKCE, or static `DATABRICKS_TOKEN`).
757760
/// Wire format is OpenAI-chat-compatible — reuses the same body builder and parser.
@@ -901,6 +904,16 @@ impl Config {
901904
env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
902905
parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?,
903906
),
907+
Provider::OpenAiCompat => (
908+
env("OPENAI_COMPAT_API_KEY").unwrap_or_default(),
909+
resolve_model(
910+
buzz_agent_model.as_deref(),
911+
env("OPENAI_COMPAT_MODEL").as_deref(),
912+
)
913+
.ok_or_else(|| "config: OPENAI_COMPAT_MODEL required".to_string())?,
914+
parse_openai_compat_base_url(env("OPENAI_COMPAT_BASE_URL").as_deref())?,
915+
parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?,
916+
),
904917
Provider::Databricks | Provider::DatabricksV2 => (
905918
env("DATABRICKS_TOKEN").unwrap_or_default(),
906919
resolve_model(buzz_agent_model.as_deref(), databricks_model.as_deref())
@@ -1139,10 +1152,9 @@ fn resolve_provider(
11391152
"anthropic" => Err(
11401153
"config: ANTHROPIC_API_KEY required".into(),
11411154
),
1142-
"openai" | "openai-compat" if present_nonempty(openai_key) => Ok(Provider::OpenAi),
1143-
"openai" | "openai-compat" => Err(
1144-
"config: OPENAI_COMPAT_API_KEY required".into(),
1145-
),
1155+
"openai" if present_nonempty(openai_key) => Ok(Provider::OpenAi),
1156+
"openai" => Err("config: OPENAI_COMPAT_API_KEY required".into()),
1157+
"openai-compat" => Ok(Provider::OpenAiCompat),
11461158
"databricks" => Ok(Provider::Databricks),
11471159
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
11481160
"openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter),
@@ -1158,6 +1170,19 @@ fn resolve_provider(
11581170
}
11591171
}
11601172

1173+
fn parse_openai_compat_base_url(raw: Option<&str>) -> Result<String, String> {
1174+
let value = raw
1175+
.map(str::trim)
1176+
.filter(|value| !value.is_empty())
1177+
.ok_or_else(|| "config: OPENAI_COMPAT_BASE_URL required for openai-compat".to_string())?;
1178+
let parsed = url::Url::parse(value)
1179+
.map_err(|_| "config: OPENAI_COMPAT_BASE_URL must be a valid HTTP(S) URL".to_string())?;
1180+
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
1181+
return Err("config: OPENAI_COMPAT_BASE_URL must be a valid HTTP(S) URL".to_string());
1182+
}
1183+
Ok(value.trim_end_matches('/').to_string())
1184+
}
1185+
11611186
/// Parse `OPENAI_COMPAT_API`. Pure (env-free) for testability; the
11621187
/// caller hands in the raw value.
11631188
fn parse_openai_api(raw: Option<&str>) -> Result<OpenAiApi, String> {
@@ -1450,13 +1475,31 @@ mod tests {
14501475
}
14511476

14521477
#[test]
1453-
fn resolve_provider_errors_when_requested_provider_key_missing() {
1454-
// No fallback — missing key returns an error regardless of Databricks availability.
1478+
fn resolve_provider_requires_only_official_openai_key() {
14551479
let err = resolve_provider(Some("anthropic"), None, None, None).unwrap_err();
14561480
assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}");
14571481

1458-
let err = resolve_provider(Some("openai-compat"), None, Some(" "), None).unwrap_err();
1482+
let err = resolve_provider(Some("openai"), None, Some(" "), None).unwrap_err();
14591483
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
1484+
1485+
assert_eq!(
1486+
resolve_provider(Some("openai-compat"), None, None, None).unwrap(),
1487+
Provider::OpenAiCompat
1488+
);
1489+
}
1490+
1491+
#[test]
1492+
fn openai_compat_base_url_is_required_and_normalized() {
1493+
assert!(parse_openai_compat_base_url(None)
1494+
.unwrap_err()
1495+
.contains("required for openai-compat"));
1496+
assert!(parse_openai_compat_base_url(Some("ftp://localhost/v1"))
1497+
.unwrap_err()
1498+
.contains("valid HTTP(S) URL"));
1499+
assert_eq!(
1500+
parse_openai_compat_base_url(Some(" http://localhost:11434/v1/// ")).unwrap(),
1501+
"http://localhost:11434/v1"
1502+
);
14601503
}
14611504

14621505
#[test]

crates/buzz-agent/src/llm.rs

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ impl Llm {
106106
.await
107107
.and_then(parse_openai_with_reasoning_details)
108108
}
109-
Provider::OpenAi | Provider::Databricks => {
109+
Provider::OpenAi | Provider::OpenAiCompat | Provider::Databricks => {
110110
self.openai_request(cfg, effective_model, |use_responses, request_model| {
111111
// Normalize effort for model-specific availability. Startup no longer rejects
112112
// `max` for pure OpenAI/Databricks; this per-model table is the single authority
@@ -246,7 +246,7 @@ impl Llm {
246246
let v = self.post_openrouter(cfg, &body).await?;
247247
Ok(parse_openai(v)?.text)
248248
}
249-
Provider::OpenAi | Provider::Databricks => {
249+
Provider::OpenAi | Provider::OpenAiCompat | Provider::Databricks => {
250250
let r = self
251251
.openai_request(cfg, effective_model, |use_responses, request_model| {
252252
if use_responses {
@@ -471,14 +471,19 @@ impl Llm {
471471
// statuses map to `LlmAuth` in `post`: a 403 is indistinguishable from
472472
// an expired-token 403 here, so we refresh once and let it propagate.
473473
let mut bearer = self.auth.bearer().await.map_err(PostError::from)?;
474+
let use_bearer = cfg.provider != Provider::OpenAiCompat || !bearer.is_empty();
474475
let mut refreshed = false;
475476
loop {
476-
match post(&self.http, &url, body_ref, cfg.llm_timeout, |r| {
477-
r.bearer_auth(&bearer)
477+
match post(&self.http, &url, body_ref, cfg.llm_timeout, |request| {
478+
if use_bearer {
479+
request.bearer_auth(&bearer)
480+
} else {
481+
request
482+
}
478483
})
479484
.await
480485
{
481-
Err(PostError::Agent(AgentError::LlmAuth(_))) if !refreshed => {
486+
Err(PostError::Agent(AgentError::LlmAuth(_))) if use_bearer && !refreshed => {
482487
refreshed = true;
483488
bearer = self
484489
.auth
@@ -2071,7 +2076,7 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig {
20712076
/// flow; subsequent requests use the cache + refresh transparently.
20722077
pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, AgentError> {
20732078
match cfg.provider {
2074-
Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => {
2079+
Provider::Anthropic | Provider::OpenAi | Provider::OpenAiCompat | Provider::OpenRouter => {
20752080
Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())))
20762081
}
20772082
Provider::Databricks | Provider::DatabricksV2 => {
@@ -2097,9 +2102,11 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, A
20972102
pub(crate) fn summary_completion_cap(provider: Provider, max_output_tokens: u32) -> u32 {
20982103
match provider {
20992104
Provider::OpenRouter => max_output_tokens.saturating_mul(2),
2100-
Provider::Anthropic | Provider::OpenAi | Provider::Databricks | Provider::DatabricksV2 => {
2101-
max_output_tokens
2102-
}
2105+
Provider::Anthropic
2106+
| Provider::OpenAi
2107+
| Provider::OpenAiCompat
2108+
| Provider::Databricks
2109+
| Provider::DatabricksV2 => max_output_tokens,
21032110
}
21042111
}
21052112

@@ -5725,6 +5732,49 @@ mod tests {
57255732
}
57265733
}
57275734

5735+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5736+
async fn post_openai_compat_omits_authorization_when_key_is_empty() {
5737+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
5738+
use tokio::net::TcpListener;
5739+
5740+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5741+
let base = format!("http://{}", listener.local_addr().unwrap());
5742+
let captured = tokio::spawn(async move {
5743+
let (mut socket, _) = listener.accept().await.unwrap();
5744+
let mut bytes = Vec::new();
5745+
let mut buffer = [0u8; 4096];
5746+
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
5747+
let count = socket.read(&mut buffer).await.unwrap();
5748+
if count == 0 {
5749+
break;
5750+
}
5751+
bytes.extend_from_slice(&buffer[..count]);
5752+
}
5753+
let body = "{\"ok\":true}";
5754+
socket
5755+
.write_all(
5756+
format!(
5757+
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5758+
body.len(), body
5759+
)
5760+
.as_bytes(),
5761+
)
5762+
.await
5763+
.unwrap();
5764+
String::from_utf8_lossy(&bytes).to_ascii_lowercase()
5765+
});
5766+
5767+
let llm = llm_with(Arc::new(StaticTokenSource::new("")));
5768+
let mut config = cfg(Provider::OpenAiCompat);
5769+
config.base_url = base;
5770+
llm.post_openai(&config, "/v1/x", &json!({}), "model")
5771+
.await
5772+
.unwrap();
5773+
5774+
let headers = captured.await.unwrap();
5775+
assert!(!headers.contains("authorization:"), "{headers}");
5776+
}
5777+
57285778
/// A single 401 forces exactly one refresh, the retry with the fresh
57295779
/// token succeeds, and a *later* call gets its own refresh — proving the
57305780
/// one-shot guard is per-call, not stored on the source.

0 commit comments

Comments
 (0)