Skip to content

Commit 15a73aa

Browse files
authored
feat(buzz-agent): add databricks_v2 provider for AI Gateway v2 (#1311)
1 parent e6738c5 commit 15a73aa

5 files changed

Lines changed: 271 additions & 34 deletions

File tree

crates/buzz-agent/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro
129129

130130
| Variable | Default | Notes |
131131
|---|---|---|
132-
| `BUZZ_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. |
132+
| `BUZZ_AGENT_PROVIDER` || `anthropic`, `openai`, `databricks`, or `databricks_v2`. 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. |
133133
| `ANTHROPIC_API_KEY` || Required when provider=anthropic unless Databricks fallback is configured. |
134134
| `ANTHROPIC_MODEL` || Required when provider=anthropic. |
135135
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | |
@@ -158,7 +158,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro
158158

159159
## Providers
160160

161-
`buzz-agent` speaks two HTTP dialects. Pick with `BUZZ_AGENT_PROVIDER`.
161+
`buzz-agent` speaks a few HTTP dialects. Pick with `BUZZ_AGENT_PROVIDER`.
162162

163163
| Provider | `BUZZ_AGENT_PROVIDER` | Endpoint (auto) | Tested with |
164164
|---|---|---|---|
@@ -170,14 +170,15 @@ Everything is environment variables. No flags, no config files. (We are a subpro
170170
| OpenRouter | `openai` | `POST {base}/chat/completions` | anything they route |
171171
| Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude |
172172
| Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet |
173+
| Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 |
173174

174175
If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `BUZZ_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 `BUZZ_AGENT_PROVIDER` is unset. Explicit Anthropic/OpenAI API keys always win.
175176

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

178179
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).
179180

180-
`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`.
181+
`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box<dyn>`, no async-trait. Adding a provider is a `match` arm and one `body`/`parse` pair in `llm.rs`.
181182

182183
## MCP Servers
183184

crates/buzz-agent/src/config.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ pub enum Provider {
3333
/// with a dynamically-acquired bearer (OAuth 2.0 PKCE, or static `DATABRICKS_TOKEN`).
3434
/// Wire format is OpenAI-chat-compatible — reuses the same body builder and parser.
3535
Databricks,
36+
/// Databricks AI Gateway v2. Routes by model family through the gateway's
37+
/// OpenAI Responses, Anthropic Messages, or MLflow Chat Completions paths.
38+
DatabricksV2,
3639
}
3740

3841
/// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API`
@@ -114,8 +117,8 @@ impl Config {
114117
// bad value can't break an Anthropic-only deployment.
115118
//
116119
// Databricks borrows api_key as the *optional* `DATABRICKS_TOKEN` escape
117-
// hatch — empty means "use OAuth PKCE." The model lives in the URL path,
118-
// not the request body (see `EndpointStrategy::DatabricksServing`).
120+
// hatch — empty means "use OAuth PKCE." Legacy Databricks encodes the
121+
// model in the URL path; Databricks v2 keeps it in the request body.
119122
let (api_key, model, base_url, openai_api) = match provider {
120123
Provider::Anthropic => (
121124
req("ANTHROPIC_API_KEY")?,
@@ -137,12 +140,12 @@ impl Config {
137140
env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
138141
parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?,
139142
),
140-
Provider::Databricks => (
143+
Provider::Databricks | Provider::DatabricksV2 => (
141144
env("DATABRICKS_TOKEN").unwrap_or_default(),
142145
resolve_model(databricks_model.as_deref(), buzz_agent_model.as_deref())
143146
.ok_or_else(|| "config: DATABRICKS_MODEL required".to_string())?,
144147
databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?,
145-
OpenAiApi::Chat, // Databricks invocations is chat-shaped
148+
OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch
146149
),
147150
};
148151
let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) {
@@ -318,6 +321,7 @@ fn resolve_provider(
318321
"config: OPENAI_COMPAT_API_KEY required (or set DATABRICKS_HOST and DATABRICKS_MODEL for Databricks OAuth fallback)".into(),
319322
),
320323
"databricks" => Ok(Provider::Databricks),
324+
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
321325
_ => Err(format!(
322326
"config: BUZZ_AGENT_PROVIDER={raw} not supported"
323327
)),

crates/buzz-agent/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,13 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
8989
}
9090

9191
/// `buzz-agent auth <provider>` — run the interactive auth flow for a
92-
/// provider and persist the result, then exit. Today the only provider is
93-
/// `databricks` (OAuth 2.0 PKCE). Reads `DATABRICKS_HOST` from env; needs
94-
/// a browser on the machine.
92+
/// provider and persist the result, then exit. Today this supports Databricks
93+
/// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the
94+
/// machine.
9595
async fn auth_subcommand(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
9696
let provider = args.first().map(String::as_str);
9797
match provider {
98-
Some("databricks") => {
98+
Some("databricks" | "databricks_v2" | "databricks-v2") => {
9999
let host = std::env::var("DATABRICKS_HOST")
100100
.map_err(|_| "auth databricks: DATABRICKS_HOST required")?;
101101
let pkce = auth::PkceOAuthConfig {

crates/buzz-agent/src/llm.rs

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ const MAX_LLM_ERROR_BODY_BYTES: usize = 4 * 1024;
2323
/// alongside its `_body` serializer.
2424
type OpenAiParse = fn(Value) -> Result<LlmResponse, AgentError>;
2525

26+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27+
enum DatabricksV2Route {
28+
OpenAiResponses,
29+
AnthropicMessages,
30+
MlflowChatCompletions,
31+
}
32+
2633
pub struct Llm {
2734
http: Client,
2835
/// One-shot sticky flag: set when a Chat Completions request comes
@@ -83,6 +90,23 @@ impl Llm {
8390
})
8491
.await
8592
}
93+
Provider::DatabricksV2 => {
94+
self.databricks_v2_request(cfg, |route| match route {
95+
DatabricksV2Route::OpenAiResponses => (
96+
responses_body(cfg, system_prompt, history, tools),
97+
parse_responses as OpenAiParse,
98+
),
99+
DatabricksV2Route::AnthropicMessages => (
100+
anthropic_body(cfg, system_prompt, history, tools),
101+
parse_anthropic as OpenAiParse,
102+
),
103+
DatabricksV2Route::MlflowChatCompletions => (
104+
openai_body(cfg, system_prompt, history, tools),
105+
parse_openai as OpenAiParse,
106+
),
107+
})
108+
.await
109+
}
86110
}
87111
}
88112

@@ -137,6 +161,46 @@ impl Llm {
137161
.await?;
138162
Ok(r.text)
139163
}
164+
Provider::DatabricksV2 => {
165+
let r = self
166+
.databricks_v2_request(cfg, |route| match route {
167+
DatabricksV2Route::OpenAiResponses => (
168+
json!({
169+
"model": cfg.model,
170+
"max_output_tokens": max_output_tokens,
171+
"instructions": system_prompt,
172+
"input": user_prompt,
173+
}),
174+
parse_responses as OpenAiParse,
175+
),
176+
DatabricksV2Route::AnthropicMessages => (
177+
json!({
178+
"model": cfg.model,
179+
"max_tokens": max_output_tokens,
180+
"system": system_prompt,
181+
"messages": [{
182+
"role": "user",
183+
"content": [{ "type": "text", "text": user_prompt }],
184+
}],
185+
}),
186+
parse_anthropic as OpenAiParse,
187+
),
188+
DatabricksV2Route::MlflowChatCompletions => (
189+
json!({
190+
"model": cfg.model,
191+
"stream": false,
192+
"max_completion_tokens": max_output_tokens,
193+
"messages": [
194+
{ "role": "system", "content": system_prompt },
195+
{ "role": "user", "content": user_prompt },
196+
],
197+
}),
198+
parse_openai as OpenAiParse,
199+
),
200+
})
201+
.await?;
202+
Ok(r.text)
203+
}
140204
}
141205
}
142206

@@ -176,6 +240,22 @@ impl Llm {
176240
}
177241
}
178242

243+
async fn databricks_v2_request<F>(
244+
&self,
245+
cfg: &Config,
246+
build: F,
247+
) -> Result<LlmResponse, AgentError>
248+
where
249+
F: FnOnce(DatabricksV2Route) -> (Value, OpenAiParse) + Send,
250+
{
251+
let route = databricks_v2_route_for_model(&cfg.model);
252+
let (body, parse) = build(route);
253+
parse(
254+
self.post_openai(cfg, databricks_v2_path(route), &body)
255+
.await?,
256+
)
257+
}
258+
179259
/// POST to an OpenAI-family endpoint. For OpenAI-compat this is just
180260
/// `{base_url}{path}` with the body untouched. For Databricks the URL
181261
/// becomes `{base_url}/serving-endpoints/{model}/invocations` and the
@@ -517,6 +597,27 @@ fn is_responses_required_error(body: &str) -> bool {
517597
|| b.contains("use the responses api")
518598
}
519599

600+
fn databricks_v2_route_for_model(model: &str) -> DatabricksV2Route {
601+
// Databricks v2 catalog names currently identify OpenAI-shaped GPT-5
602+
// models and Anthropic-shaped Claude models by these substrings.
603+
let lower = model.to_ascii_lowercase();
604+
if lower.contains("gpt-5") || lower.contains("gpt5") {
605+
DatabricksV2Route::OpenAiResponses
606+
} else if lower.contains("claude") {
607+
DatabricksV2Route::AnthropicMessages
608+
} else {
609+
DatabricksV2Route::MlflowChatCompletions
610+
}
611+
}
612+
613+
fn databricks_v2_path(route: DatabricksV2Route) -> &'static str {
614+
match route {
615+
DatabricksV2Route::OpenAiResponses => "/ai-gateway/openai/v1/responses",
616+
DatabricksV2Route::AnthropicMessages => "/ai-gateway/anthropic/v1/messages",
617+
DatabricksV2Route::MlflowChatCompletions => "/ai-gateway/mlflow/v1/chat/completions",
618+
}
619+
}
620+
520621
fn parse_responses(v: Value) -> Result<LlmResponse, AgentError> {
521622
let mut text = String::new();
522623
let mut tool_calls = Vec::new();
@@ -886,7 +987,7 @@ fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, AgentError>
886987
Provider::Anthropic | Provider::OpenAi => {
887988
Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())))
888989
}
889-
Provider::Databricks => {
990+
Provider::Databricks | Provider::DatabricksV2 => {
890991
if !cfg.api_key.is_empty() {
891992
return Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())));
892993
}
@@ -1199,6 +1300,31 @@ mod tests {
11991300
}
12001301
}
12011302

1303+
#[test]
1304+
fn databricks_v2_routes_by_model_family() {
1305+
for (model, route, path) in [
1306+
(
1307+
"databricks-gpt-5-5",
1308+
DatabricksV2Route::OpenAiResponses,
1309+
"/ai-gateway/openai/v1/responses",
1310+
),
1311+
(
1312+
"databricks-claude-opus-4-7",
1313+
DatabricksV2Route::AnthropicMessages,
1314+
"/ai-gateway/anthropic/v1/messages",
1315+
),
1316+
(
1317+
"custom-tool-model",
1318+
DatabricksV2Route::MlflowChatCompletions,
1319+
"/ai-gateway/mlflow/v1/chat/completions",
1320+
),
1321+
] {
1322+
let got = databricks_v2_route_for_model(model);
1323+
assert_eq!(got, route, "model={model}");
1324+
assert_eq!(databricks_v2_path(got), path, "model={model}");
1325+
}
1326+
}
1327+
12021328
#[test]
12031329
fn parse_responses_rejects_malformed_function_arguments() {
12041330
let v = serde_json::json!({

0 commit comments

Comments
 (0)