feat(agent): discover Databricks Unity Catalog models (#6918)

## Summary

Add Unity Catalog model-service discovery to `buzz-agent` Databricks v2,
preserve full Unity Catalog FQNs through ACP model discovery, route
model services through MLflow chat, and add the optional
`DATABRICKS_MODEL_FILTER` visibility filter.

The resilience follow-up bounds each catalog attempt across headers,
bounded body reads, and JSON parsing; retries transient 499/5xx,
transport/body failures, malformed JSON, and attempt timeouts; and emits
only safe catalog/status diagnostics.


## Validation

The live validation and test gates below were run on the pre-rewrite tip
`1cf6c66e7c5bf9a00c357bda24df48e3beeb310b`. The rewritten tip
`0364cf44f3eb5cfbd5771b0a0dd7868ce0ad2c19` has identical trees for both
commits, so the attribution rewrite changes no tested code.

- 31 catalog tests passed
- 19 Databricks OAuth/routing integration tests passed
- `cargo check -p buzz-agent` passed
- `cargo clippy -p buzz-agent --all-targets -- -D warnings` passed
- `cargo fmt --check` passed
- `git diff --check` passed
- Desktop Tauri validation passed through the repository-supported setup
path, including 2,777 library tests and auxiliary terminal suites

The full `buzz-agent` package has one separately reproduced pre-existing
failure: `regressions::cancel_kills_inflight_tool_via_mcp_notification`,
failing on both baseline `eb8e97cfe` and the follow-up worktree. No
Databricks path reaches that test. The live validation proves discovery
and ACP exposure, not inference against every listed model.

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
This commit is contained in:
Kalvin C
2026-08-27 13:37:12 -07:00
committed by GitHub
parent 57216c942f
commit be8d7756d5
18 changed files with 1905 additions and 251 deletions

View File

@@ -172,6 +172,12 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz
# Use `buzz-acp models` to discover available model IDs.
# BUZZ_ACP_MODEL=
# Optional Databricks model-picker visibility filter. Discovery-only; this does
# not grant inference access. Comma-separated full-string * / ? patterns are
# OR-matched against raw workspace endpoint and Unity Catalog model-service IDs.
# Unset or blank shows every catalog entry. A nonblank value with no usable patterns is invalid.
# DATABRICKS_MODEL_FILTER=databricks-*,data_tools.goose.*
# ── Timeouts & sessions ──────────────────────────────────────────────────────
# Max seconds per agent turn before timeout (default 320 = ~5 min).
# BUZZ_ACP_TURN_TIMEOUT=320

View File

@@ -149,6 +149,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | |
| `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_MODEL_FILTER` | — | Optional discovery-only, comma-separated full-string `*`/`?` patterns OR-matched against raw Databricks endpoint and Unity Catalog model-service IDs. Blank/unset shows all; this is visibility filtering, not an authorization boundary. |
| `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. |
| `BUZZ_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. |
| `BUZZ_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. |
@@ -241,7 +242,9 @@ lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md).
| Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude |
| OpenRouter | `openrouter` | `POST {base}/chat/completions` | anything they route (extended-thinking replay, provider-agnostic tool calling) |
| Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet |
| Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 |
| Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | workspace endpoints and Unity Catalog model-service FQNs; UC FQNs use MLflow Chat Completions |
The optional `DATABRICKS_MODEL_FILTER` applies only to model discovery. Each comma-separated entry is trimmed and matched against the complete raw ID with case-sensitive `*` (zero or more characters) and `?` (one Unicode character) semantics; patterns are OR-ed. Unset or blank preserves the full authenticated catalog. A nonblank value containing no usable patterns is rejected. This controls picker visibility only; Databricks and Unity Catalog permissions remain the authorization boundary. A filtered-empty result is authoritative and does not restore the built-in fallback models.
If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, the agent returns an error — there is no implicit fallback to another provider.

File diff suppressed because it is too large Load Diff

View File

@@ -429,6 +429,96 @@ pub enum Provider {
OpenRouter,
}
/// Optional visibility filter for the Databricks model catalog.
///
/// Each comma-separated pattern is trimmed and matched against the complete,
/// case-sensitive model id. Only `*` (zero or more characters) and `?` (one
/// character) have wildcard semantics; all other characters are literals.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatabricksModelFilter {
patterns: Vec<String>,
}
impl DatabricksModelFilter {
/// Parse `DATABRICKS_MODEL_FILTER`-style input.
///
/// Unset or whitespace-only input disables filtering. A nonblank value must
/// contain at least one nonblank comma-separated pattern.
pub fn parse(raw: Option<&str>) -> Result<Option<Self>, String> {
let Some(raw) = raw else {
return Ok(None);
};
if raw.trim().is_empty() {
return Ok(None);
}
let patterns: Vec<String> = raw
.split(',')
.map(str::trim)
.filter(|pattern| !pattern.is_empty())
.map(str::to_owned)
.collect();
if patterns.is_empty() {
return Err(
"config: DATABRICKS_MODEL_FILTER must contain at least one nonblank pattern".into(),
);
}
Ok(Some(Self { patterns }))
}
/// Return whether the complete model id matches at least one pattern.
pub fn matches(&self, model_id: &str) -> bool {
self.patterns
.iter()
.any(|pattern| glob_matches(pattern, model_id))
}
}
/// Match one full-string `*`/`?` pattern without treating any other character
/// as syntax. The inputs are converted to Unicode scalar values so `?` means
/// one character rather than one UTF-8 byte.
fn glob_matches(pattern: &str, value: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let value: Vec<char> = value.chars().collect();
let mut pattern_index = 0;
let mut value_index = 0;
let mut star_index = None;
let mut star_value_index = 0;
while value_index < value.len() {
match pattern.get(pattern_index) {
Some('?') => {
pattern_index += 1;
value_index += 1;
}
Some('*') => {
star_index = Some(pattern_index);
star_value_index = value_index;
pattern_index += 1;
}
Some(character) if *character == value[value_index] => {
pattern_index += 1;
value_index += 1;
}
_ if star_index.is_some() => {
if let Some(star_index) = star_index {
pattern_index = star_index + 1;
}
star_value_index += 1;
value_index = star_value_index;
}
_ => return false,
}
}
while matches!(pattern.get(pattern_index), Some('*')) {
pattern_index += 1;
}
pattern_index == pattern.len()
}
/// 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
@@ -509,6 +599,9 @@ pub struct Config {
/// Default (env unset/empty) is `None` — hooks are off unless the
/// operator explicitly opts in.
pub hook_servers: HookServers,
/// The effective `DATABRICKS_MODEL_FILTER` value. This is parsed by the
/// caller and passed explicitly so discovery never consults process env.
pub databricks_model_filter: Option<DatabricksModelFilter>,
pub api_key: String,
pub model: String,
pub base_url: String,
@@ -643,6 +736,9 @@ impl Config {
stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?,
require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0,
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
databricks_model_filter: DatabricksModelFilter::parse(
env("DATABRICKS_MODEL_FILTER").as_deref(),
)?,
hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0,
thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?,
thinking_summary: parse_thinking_summary(
@@ -660,7 +756,12 @@ impl Config {
/// and the catalog HTTP helpers are meaningful; all others are set to
/// inert defaults. Never call `from_env` for discovery — it requires
/// `DATABRICKS_MODEL` and other fields that are irrelevant here.
pub fn for_discovery(provider: Provider, api_key: String, base_url: String) -> Self {
pub fn for_discovery(
provider: Provider,
api_key: String,
base_url: String,
databricks_model_filter: Option<DatabricksModelFilter>,
) -> Self {
Self {
provider,
api_key,
@@ -691,6 +792,7 @@ impl Config {
stop_max_rejections: 0,
require_reply: false,
hook_servers: HookServers::None,
databricks_model_filter,
hints_enabled: false,
thinking_effort: None,
thinking_summary: ThinkingSummary::Auto,
@@ -1024,6 +1126,61 @@ fn parse_hook_servers(raw: Option<&str>) -> HookServers {
mod tests {
use super::*;
#[test]
fn databricks_model_filter_unset_and_blank_disable_filtering() {
for raw in [None, Some(""), Some(" ")] {
assert_eq!(DatabricksModelFilter::parse(raw).unwrap(), None);
}
}
#[test]
fn databricks_model_filter_rejects_nonblank_input_without_patterns() {
let error = DatabricksModelFilter::parse(Some(" , , ")).unwrap_err();
assert!(error.contains("DATABRICKS_MODEL_FILTER"), "{error}");
}
#[test]
fn databricks_model_filter_matches_exact_full_string_case_sensitively() {
let filter = DatabricksModelFilter::parse(Some("data_tools.goose.kimi-k3")).unwrap();
assert!(filter.as_ref().unwrap().matches("data_tools.goose.kimi-k3"));
assert!(!filter
.as_ref()
.unwrap()
.matches("prefix.data_tools.goose.kimi-k3"));
assert!(!filter.as_ref().unwrap().matches("data_tools.goose.Kimi-k3"));
}
#[test]
fn databricks_model_filter_matches_star_and_question_mark() {
let filter =
DatabricksModelFilter::parse(Some("databricks-*,data_tools.goose.????-k3")).unwrap();
let filter = filter.as_ref().unwrap();
assert!(filter.matches("databricks-gpt-5"));
assert!(filter.matches("data_tools.goose.kimi-k3"));
assert!(!filter.matches("data_tools.goose.kimi-k33"));
assert!(!filter.matches("other-model"));
}
#[test]
fn databricks_model_filter_trims_multiple_patterns_and_preserves_no_match() {
let filter = DatabricksModelFilter::parse(Some(" first , second-model , third-* "))
.unwrap()
.unwrap();
assert!(filter.matches("first"));
assert!(filter.matches("second-model"));
assert!(filter.matches("third-model"));
assert!(!filter.matches("fourth-model"));
}
#[test]
fn databricks_model_filter_question_mark_matches_one_unicode_character() {
let filter = DatabricksModelFilter::parse(Some("goose-? "))
.unwrap()
.unwrap();
assert!(filter.matches("goose-é"));
assert!(!filter.matches("goose-eé"));
}
#[test]
fn hook_servers_unset_is_none() {
assert!(matches!(parse_hook_servers(None), HookServers::None));
@@ -1834,7 +1991,8 @@ mod tests {
provider: Provider,
thinking_effort: Option<ThinkingEffort>,
) -> Config {
let mut cfg = Config::for_discovery(provider, "key".into(), "https://example.com".into());
let mut cfg =
Config::for_discovery(provider, "key".into(), "https://example.com".into(), None);
cfg.model = "some-model".into();
cfg.thinking_effort = thinking_effort;
// for_discovery sets max_output_tokens=1 and max_context_tokens=200_001 which satisfies

View File

@@ -368,13 +368,17 @@ async fn initialize(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSend
.await;
}
/// Resolve the Databricks model catalog for one `session/new` call.
/// Resolve a Databricks model catalog for one `session/new` call.
///
/// Tries to use a previously-cached successful discovery result. If the cache is empty,
/// runs `discover` and — on success — populates the cache for future calls. On failure
/// the error is returned and the cell is intentionally left empty so the next session retries.
/// The active filter is part of the result's authority: discovery failure may
/// not fall back to a configured model when it is present, because that would
/// bypass the same restriction applied to a successful catalog.
///
/// Extracted from `session_new` so that tests can drive this path with an injected
/// Tries to use a previously cached successful discovery result. If the cache
/// is empty, runs `discover` and — on success — populates the cache. On failure
/// the error is returned and the cell remains empty so the next session retries.
///
/// Extracted from `session_new` so tests can drive this path with an injected
/// discovery future without requiring a full `App` / transport stack.
async fn resolve_models_catalog(
cache: &tokio::sync::OnceCell<Vec<ModelEntry>>,
@@ -383,7 +387,7 @@ async fn resolve_models_catalog(
cache.get_or_try_init(|| discover).await.cloned()
}
/// Return the configured model as a one-entry catalog for this response.
/// Return the configured model as an unfiltered discovery fallback.
///
/// This value is never written to `models_cache`; failed discovery must be retried by
/// the next session rather than pinning degraded state for the process lifetime.
@@ -398,6 +402,17 @@ fn configured_model_fallback(model: &str) -> Vec<ModelEntry> {
vec![ModelEntry { id: model, name }]
}
/// A discovery failure may use the configured model only when no visibility
/// filter is active. Returning that model under an active filter would silently
/// bypass the operator's authoritative catalog restriction.
fn discovery_error_fallback(cfg: &Config) -> Vec<ModelEntry> {
if cfg.databricks_model_filter.is_some() {
Vec::new()
} else {
configured_model_fallback(&cfg.model)
}
}
async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
let p: SessionNewParams = match decode(params, "session/new") {
Ok(p) => p,
@@ -482,16 +497,18 @@ async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSen
Err(error @ AgentError::LlmAuth(_)) => {
tracing::warn!(
error = %error,
"Databricks OAuth model catalog unavailable; using configured model"
filter_active = app.cfg.databricks_model_filter.is_some(),
"Databricks OAuth model catalog unavailable; using filter-aware fallback"
);
configured_model_fallback(&app.cfg.model)
discovery_error_fallback(&app.cfg)
}
Err(error) => {
tracing::warn!(
error = %error,
"Databricks model catalog unavailable; using configured model"
filter_active = app.cfg.databricks_model_filter.is_some(),
"Databricks model catalog unavailable; using filter-aware fallback"
);
configured_model_fallback(&app.cfg.model)
discovery_error_fallback(&app.cfg)
}
};
models

View File

@@ -170,13 +170,11 @@ impl Llm {
)
}
DatabricksV2Route::MlflowChatCompletions => {
// MLflow Chat path (OpenAI-shaped): normalize effort via manifest.
let e = effort
.map(|ef| normalize_effort_for_databricks_v2(ef, effective_model));
(
openai_body(cfg, system_prompt, history, tools, effective_model, e),
parse_openai as OpenAiParse,
)
let body =
openai_body(cfg, system_prompt, history, tools, effective_model, e);
(body, parse_openai as OpenAiParse)
}
})
.await
@@ -325,8 +323,8 @@ impl Llm {
}),
parse_anthropic as OpenAiParse,
),
DatabricksV2Route::MlflowChatCompletions => (
json!({
DatabricksV2Route::MlflowChatCompletions => {
let body = json!({
"model": effective_model,
"stream": false,
"max_completion_tokens": max_output_tokens,
@@ -334,9 +332,9 @@ impl Llm {
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_prompt },
],
}),
parse_openai as OpenAiParse,
),
});
(body, parse_openai as OpenAiParse)
}
})
.await?;
Ok(r.text)
@@ -967,25 +965,10 @@ fn is_responses_required_error(body: &str) -> bool {
|| b.contains("use the responses api")
}
/// Resolve the Databricks v2 AI Gateway wire route for `model` from the manifest.
/// Resolve the Databricks v2 AI Gateway wire route for `model`.
///
/// The route is a capability of the `(databricks_v2, model)` pair, owned by
/// `scripts/model-capabilities.json` and resolved by the shared interpreter — the
/// same authority that drives effort/label resolution. This function only maps the
/// manifest's route enum onto the three concrete wire routes this dispatch path can
/// serve; it holds no routing knowledge of its own.
///
/// The manifest enum carries two non-wire variants that cannot occur here for a
/// concrete Databricks v2 model at dispatch time:
/// - `NotApplicable` is produced only for non-`databricks_v2` providers, and this
/// seam is reached only under `Provider::DatabricksV2`.
/// - `RouteUnknown` is produced only for a blank model id, which `Config` rejects at
/// startup (`DATABRICKS_MODEL` required) and `session/set_model` rejects at runtime
/// (empty `modelId` → `invalid_params`), so `effective_model` is never blank here.
///
/// Both are folded into `MlflowChatCompletions` — the manifest's own concrete-unknown
/// fallback and the route a blank id would historically have taken — so an unforeseen
/// reshape degrades to the safe OpenAI-wire route rather than panicking.
/// The capability resolver owns Unity Catalog FQN classification so the Rust
/// request path and desktop effort picker cannot disagree.
fn databricks_v2_route(model: &str) -> DatabricksV2Route {
use crate::model_capabilities::DatabricksV2Route as Manifest;
match crate::model_capabilities::resolve("databricks_v2", model).databricks_v2_wire_route {
@@ -2615,6 +2598,7 @@ mod tests {
stop_max_rejections: 0,
require_reply: false,
hook_servers: HookServers::None,
databricks_model_filter: None,
api_key: "key".into(),
model: "model".into(),
base_url: "http://example.invalid".into(),
@@ -2832,6 +2816,33 @@ mod tests {
}
}
#[tokio::test]
async fn databricks_v2_model_service_fqn_summary_uses_mlflow_chat() {
let model = "catalog.schema.claude-gpt-5";
let (base_url, captured) =
spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("summary"))]).await;
let mut config = cfg(Provider::DatabricksV2);
config.base_url = base_url;
let llm = Llm::new(&config).unwrap();
let summary = llm
.summarize(&config, "system", "history", 128, model)
.await
.unwrap();
assert_eq!(summary, "summary");
let requests = captured.lock().await;
let request = requests
.iter()
.find(|request| request.method == "POST")
.expect("summary must issue one POST");
assert_eq!(request.path, "/v1/ai-gateway/mlflow/v1/chat/completions");
let body = request.body.as_ref().expect("summary body");
assert_eq!(body["model"], model);
assert!(body["messages"].is_array());
assert_eq!(body["max_completion_tokens"], 128);
}
fn image_history() -> Vec<HistoryItem> {
vec![
HistoryItem::User("describe the image".into()),
@@ -3241,6 +3252,55 @@ mod tests {
}
}
#[test]
fn databricks_v2_model_service_fqn_shape_is_strict_and_precedes_manifest() {
use crate::model_capabilities::{resolve, DatabricksV2Route as Manifest};
for model in [
"catalog.schema.service",
"catalog.schema.claude-gpt-5",
"data_tools.goose.kimi-k3",
] {
assert!(
crate::model_capabilities::is_databricks_model_service_fqn(model),
"expected FQN shape: {model}"
);
assert_eq!(
databricks_v2_route(model),
DatabricksV2Route::MlflowChatCompletions,
"FQN route must precede manifest family inference: {model}"
);
}
let manifest_route =
|model: &str| match resolve("databricks_v2", model).databricks_v2_wire_route {
Manifest::OpenaiResponses => DatabricksV2Route::OpenAiResponses,
Manifest::AnthropicMessages => DatabricksV2Route::AnthropicMessages,
Manifest::MlflowChat | Manifest::NotApplicable | Manifest::RouteUnknown => {
DatabricksV2Route::MlflowChatCompletions
}
};
for model in [
"catalog.schema",
"catalog..service",
".schema.service",
"catalog.schema.",
"catalog.schema.service.extra",
"catalog/schema/service",
"catalog.schema service",
] {
assert!(
!crate::model_capabilities::is_databricks_model_service_fqn(model),
"unexpected FQN shape: {model}"
);
assert_eq!(
databricks_v2_route(model),
manifest_route(model),
"malformed/partial IDs must retain manifest routing: {model}"
);
}
}
#[test]
fn databricks_v2_dispatch_is_pure_manifest_projection() {
// Mutation-bypass guard: the dispatch seam must be a pure projection of

View File

@@ -284,14 +284,41 @@ fn prefix_matches(token: &str, s: &str) -> bool {
}
}
/// Return whether `model` is exactly three non-empty dot-separated components.
///
/// Databricks Unity Catalog model-service names are catalog data, not model
/// family hints. Both capability interpreters use this shape check before
/// family matching so suffixes such as `kimi-k3` cannot inherit endpoint
/// capabilities accidentally.
pub(crate) fn is_databricks_model_service_fqn(model: &str) -> bool {
let mut components = model.split('.');
let (Some(catalog), Some(schema), Some(service)) =
(components.next(), components.next(), components.next())
else {
return false;
};
[catalog, schema, service].into_iter().all(|component| {
!component.is_empty()
&& !component.chars().any(char::is_whitespace)
&& !component.contains('/')
}) && components.next().is_none()
}
/// Resolve the capability profile for a `(provider, raw_model_id)` pair.
pub fn resolve(provider: &str, raw_model_id: &str) -> CapabilityResult {
let m = manifest();
let canon = canonical_provider(provider);
let blank = raw_model_id.trim().is_empty();
// Unity Catalog FQNs are neutral model-service identities. Resolve them
// through the concrete-unknown fallback before any suffix can match a
// provider family rule. Routing and effort normalization then share this
// one answer in Rust and TypeScript.
let model_service_fqn =
canon == "databricks_v2" && is_databricks_model_service_fqn(raw_model_id);
// 1. Provider-qualified exact-record lookup (case-insensitive on the id).
if !blank {
if !blank && !model_service_fqn {
for rec in &m.exact_records {
if rec.provider == canon && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) {
return CapabilityResult {
@@ -307,7 +334,7 @@ pub fn resolve(provider: &str, raw_model_id: &str) -> CapabilityResult {
}
// 2. Boundary-aware family match: longest token wins, lexicographic tie-break.
if !blank {
if !blank && !model_service_fqn {
let model_lower = raw_model_id.to_ascii_lowercase();
let stripped = strip_catalog_prefix(&model_lower, &m.family_tokens);
let mut best: Option<(usize, &FamilyRule)> = None;
@@ -809,6 +836,21 @@ mod tests {
// --- Migrated relational/invariant tests (see 42-test inventory) ---
// These assert cross-input properties a single corpus vector cannot express.
#[test]
fn databricks_v2_fqn_uses_neutral_concrete_unknown_capabilities() {
let fqn = resolve("databricks_v2", "data_workflow_tools.goose.goose-kimi-k3");
let fallback = resolve("databricks_v2", "some-unknown-xyz");
assert_eq!(fqn.thinking_mode, fallback.thinking_mode);
assert_eq!(fqn.supported_efforts, fallback.supported_efforts);
assert_eq!(fqn.default_effort, fallback.default_effort);
assert_eq!(
fqn.databricks_v2_wire_route,
fallback.databricks_v2_wire_route
);
assert_eq!(fqn.normalization_policy, fallback.normalization_policy);
assert_eq!(fqn.registry_label, None);
}
#[test]
fn test_gpt5_numeric_date_suffix_matches_base_not_version() {
// A 4-digit date-like suffix on a non-boundary must fall to the gpt-5 base,

View File

@@ -429,17 +429,25 @@ async fn spawn_capturing_server(
let body: serde_json::Value =
serde_json::from_slice(&buf[header_end..header_end + body_len])
.unwrap_or(json!(null));
let is_unity_catalog = path.starts_with("/api/2.1/unity-catalog/model-services");
captured.lock().await.push(CapturedRequest {
path,
authorization,
body,
});
let body = queue
.lock()
.await
.pop_front()
.unwrap_or_else(|| json!({ "error": "no canned response" }));
let body_s = serde_json::to_string(&body).unwrap();
let response_body = if is_unity_catalog {
// v2 discovery probes both catalogs concurrently. Existing
// request-shape tests need only the workspace fixture, so the
// UC side is explicitly successful and empty.
json!({ "model_services": [], "next_page_token": null })
} else {
queue
.lock()
.await
.pop_front()
.unwrap_or_else(|| json!({ "error": "no canned response" }))
};
let body_s = serde_json::to_string(&response_body).unwrap();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body_s.len(),
@@ -622,6 +630,7 @@ async fn run_captured_prompt(
.filter(|r| {
!r.path.starts_with("/api/2.0/serving-endpoints")
&& !r.path.starts_with("/api/ai-gateway/v2/endpoints")
&& !r.path.starts_with("/api/2.1/unity-catalog/model-services")
})
.collect();
assert_eq!(llm_reqs.len(), 1, "expected exactly one LLM request");
@@ -764,6 +773,37 @@ async fn databricks_v2_other_models_route_through_ai_gateway_mlflow_chat() {
);
}
#[tokio::test]
async fn databricks_v2_model_service_fqn_uses_mlflow_chat_and_preserves_full_id() {
let canned = vec![json!({
"id": "x",
"object": "chat.completion",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "ok" },
"finish_reason": "stop"
}]
})];
// Family-looking text in a Unity Catalog namespace is data, not route
// authority. The full raw FQN must reach the MLflow model field.
let model = "catalog.schema.claude-gpt-5";
let req = run_captured_prompt("databricks_v2", model, canned).await;
assert_eq!(
req.path.as_str(),
"/ai-gateway/mlflow/v1/chat/completions",
"Unity Catalog model-service FQNs must always use MLflow Chat"
);
assert_eq!(req.body["model"], model);
assert!(
req.body
.get("messages")
.and_then(|value| value.as_array())
.is_some(),
"model-service FQN requests must use the Chat Completions envelope"
);
}
// ---------- session/set_model integration tests ----------
/// Helper: run initialize + session/new + optional set_model + session/prompt on a
@@ -849,6 +889,7 @@ async fn session_set_model_switches_databricks_legacy_route() {
.filter(|r| {
!r.path.starts_with("/api/2.0/serving-endpoints")
&& !r.path.starts_with("/api/ai-gateway/v2/endpoints")
&& !r.path.starts_with("/api/2.1/unity-catalog/model-services")
})
.collect();
assert_eq!(
@@ -896,6 +937,7 @@ async fn session_set_model_switches_databricks_v2_route() {
.filter(|r| {
!r.path.starts_with("/api/2.0/serving-endpoints")
&& !r.path.starts_with("/api/ai-gateway/v2/endpoints")
&& !r.path.starts_with("/api/2.1/unity-catalog/model-services")
})
.collect();
assert_eq!(
@@ -1020,7 +1062,7 @@ async fn model_discovery_surfaces_rejected_static_token_as_auth_failure() {
let _ = axum::serve(listener, app).await;
});
let cfg = Config::for_discovery(Provider::DatabricksV2, "rejected".into(), host);
let cfg = Config::for_discovery(Provider::DatabricksV2, "rejected".into(), host, None);
let error = discover_databricks_models(&cfg).await.unwrap_err();
assert!(
@@ -1031,10 +1073,13 @@ async fn model_discovery_surfaces_rejected_static_token_as_auth_failure() {
!error.to_string().contains("rejected bearer"),
"auth errors must not propagate provider bodies that may echo credentials: {error}"
);
assert_eq!(
requests.load(Ordering::SeqCst),
1,
"a static token cannot refresh, so discovery must not issue a duplicate request"
// The independent catalog requests run concurrently; the first auth
// failure can short-circuit the joined result before the peer finishes.
// Assert the contract at the behavior boundary rather than assuming both
// in-flight requests always reach the stub.
assert!(
requests.load(Ordering::SeqCst) >= 1,
"static-token auth failure must issue at least one catalog request"
);
}
@@ -1180,7 +1225,7 @@ async fn non_auth_discovery_failure_uses_configured_model_without_caching_fallba
.await;
assert!(h.recv_for(initialize).await.get("result").is_some());
for expected_attempts in 1..=2 {
for expected_attempts in [3, 6] {
let request = h
.send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] }))
.await;

View File

@@ -8,11 +8,11 @@ use super::agent_model_process::run_agent_models_command;
use super::managed_agent_definition::apply_model_provider_prompt_update;
// The map-only lookup is reached solely from the base-URL helpers that exist for
// their unit tests; discovery itself always goes through the process-env variant.
#[cfg(test)]
use super::agent_models_env::env_value;
use super::agent_models_env::{
effective_discovery_provider, env_or_process_value, redaction_env_with_value, DiscoveryProvider,
};
#[cfg(test)]
use super::agent_models_env::{env_value, env_value_or_process_if_absent};
use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback};
use crate::{
@@ -692,8 +692,8 @@ async fn discover_anthropic_models(
mod databricks;
#[cfg(test)]
use databricks::{
databricks_sign_in_required_error, databricks_static_token_error, is_databricks_provider,
should_start_interactive_auth,
databricks_models_response, databricks_sign_in_required_error, databricks_static_token_error,
is_databricks_provider, should_start_interactive_auth,
};
use databricks::{discover_databricks_models, DatabricksAuthIntent};

View File

@@ -5,7 +5,8 @@ use std::sync::{LazyLock, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use crate::commands::agent_models_env::{
env_or_process_value, redaction_env_with_value, DiscoveryProvider,
env_or_process_value, env_value_or_process_if_absent, redaction_env_with_value,
DiscoveryProvider,
};
use crate::managed_agents::AgentModelInfo;
use crate::managed_agents::AgentModelsResponse;
@@ -167,10 +168,14 @@ pub(super) async fn discover_databricks_models(
None => return Ok(None),
};
let api_key = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default();
let filter = env_value_or_process_if_absent(env, "DATABRICKS_MODEL_FILTER");
let parsed_filter = buzz_agent_pkg::config::DatabricksModelFilter::parse(filter.as_deref())
.map_err(|error| format!("invalid DATABRICKS_MODEL_FILTER: {error}"))?;
let config = buzz_agent_pkg::config::Config::for_discovery(
databricks_agent_provider(provider_name),
api_key.clone(),
host.clone(),
parsed_filter.clone(),
);
let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &api_key);
@@ -230,11 +235,30 @@ pub(super) async fn discover_databricks_models(
}
};
if entries.is_empty() {
databricks_models_response(
provider_name,
entries,
selected_model,
parsed_filter.as_ref(),
)
.map(Some)
}
/// When a catalog query fails, Desktop reports the catalog error to the UI and
/// does not fall through to subprocess discovery, so the filter cannot be
/// bypassed by a second model source.
pub(super) fn databricks_models_response(
provider_name: &str,
entries: Vec<buzz_agent_pkg::ModelEntry>,
selected_model: Option<String>,
filter: Option<&buzz_agent_pkg::config::DatabricksModelFilter>,
) -> Result<AgentModelsResponse, String> {
let entries_are_empty = entries.is_empty();
if entries_are_empty && filter.is_none() {
return Err("Databricks model discovery returned no models".to_string());
}
Ok(Some(AgentModelsResponse {
Ok(AgentModelsResponse {
agent_name: provider_name.trim().to_string(),
agent_version: "models-api".to_string(),
models: entries
@@ -247,8 +271,8 @@ pub(super) async fn discover_databricks_models(
.collect(),
agent_default_model: None,
selected_model,
supports_switching: true,
}))
supports_switching: !entries_are_empty,
})
}
fn format_redacted_error(

View File

@@ -25,6 +25,22 @@ pub(super) fn env_or_process_value(env: &BTreeMap<String, String>, key: &str) ->
})
}
/// Read a value from the merged discovery env, preserving an explicit blank
/// override. Only when the merged map has no such key does the inherited
/// process environment provide a fallback. This mirrors the child process,
/// where a merged key overrides the inherited environment even when blank.
pub(super) fn env_value_or_process_if_absent(
env: &BTreeMap<String, String>,
key: &str,
) -> Option<String> {
match env.get(key) {
Some(value) => Some(value.trim().to_string()),
None => std::env::var(key)
.ok()
.map(|value| value.trim().to_string()),
}
}
/// Clone `env` with `key` set to the value a request actually used, so error
/// redaction masks the inherited process value and not just the mapped one.
pub(super) fn redaction_env_with_value(

View File

@@ -476,11 +476,46 @@ fn model_discovery_ignores_stale_record_for_linked_agent() {
// ---------------------------------------------------------------------------
// Databricks provider detection
// ---------------------------------------------------------------------------
//
#[test]
fn merged_filter_value_overrides_inherited_process_value_even_when_blank() {
let env = BTreeMap::from([("DATABRICKS_MODEL_FILTER".to_string(), " ".to_string())]);
assert_eq!(
env_value_or_process_if_absent(&env, "DATABRICKS_MODEL_FILTER"),
Some(String::new())
);
}
#[test]
fn absent_filter_value_uses_process_value_when_available() {
const TEST_FILTER_ENV: &str = "BUZZ_TEST_DATABRICKS_MODEL_FILTER";
let original = std::env::var(TEST_FILTER_ENV).ok();
std::env::set_var(TEST_FILTER_ENV, "process-*");
let value = env_value_or_process_if_absent(&BTreeMap::new(), TEST_FILTER_ENV);
match original {
Some(value) => std::env::set_var(TEST_FILTER_ENV, value),
None => std::env::remove_var(TEST_FILTER_ENV),
}
assert_eq!(value.as_deref(), Some("process-*"));
}
#[test]
fn databricks_filtered_empty_response_is_authoritative() {
let filter = buzz_agent_pkg::config::DatabricksModelFilter::parse(Some("allowed-*")).unwrap();
let response = databricks_models_response(
"databricks_v2",
Vec::new(),
Some("configured".into()),
filter.as_ref(),
)
.expect("active filter permits an empty authoritative catalog");
assert!(response.models.is_empty());
assert!(!response.supports_switching);
assert_eq!(response.selected_model.as_deref(), Some("configured"));
}
// Parse/filter/pagination tests live in crates/buzz-agent/src/catalog.rs
// (they moved there with the Option C refactor).
// ---------------------------------------------------------------------------
// Dead-knob guards: mcp_command and turn_timeout_seconds
// ---------------------------------------------------------------------------

View File

@@ -180,7 +180,7 @@ pub fn validate_user_env_keys(env_vars: &BTreeMap<String, String>) -> Result<(),
/// - `BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL` — agent runtime selection
/// - `BUZZ_AGENT_THINKING_EFFORT` — non-secret enum (none/minimal/low/medium/high/xhigh/max)
/// - `BUZZ_AGENT_THINKING_SUMMARY` — non-secret enum (auto/concise/detailed)
/// - `DATABRICKS_HOST`, `DATABRICKS_MODEL` — Block non-secret defaults
/// - `DATABRICKS_HOST`, `DATABRICKS_MODEL`, `DATABRICKS_MODEL_FILTER` — Block non-secret defaults
pub(crate) fn is_safe_to_reveal(key: &str) -> bool {
const SAFE_KEYS: &[&str] = &[
"BUZZ_AGENT_PROVIDER",
@@ -189,6 +189,7 @@ pub(crate) fn is_safe_to_reveal(key: &str) -> bool {
"BUZZ_AGENT_THINKING_SUMMARY",
"DATABRICKS_HOST",
"DATABRICKS_MODEL",
"DATABRICKS_MODEL_FILTER",
];
let upper = key.to_ascii_uppercase();
SAFE_KEYS.iter().any(|safe| upper == *safe)

View File

@@ -236,6 +236,27 @@ fn allowlisted_env_key_is_case_insensitive() {
);
}
#[test]
fn allowlisted_databricks_filter_shows_plain_value() {
let mut before = base();
before
.env
.insert("DATABRICKS_MODEL_FILTER".into(), "old-*".into());
let mut after = before.clone();
after
.env
.insert("DATABRICKS_MODEL_FILTER".into(), "new-*".into());
assert_eq!(
change_at(&diff(&before, &after), "env.DATABRICKS_MODEL_FILTER"),
&RestartChange::Value {
before: Value::String("old-*".into()),
after: Value::String("new-*".into()),
},
"the discovery filter is non-secret and should be reviewable"
);
}
#[test]
fn non_allowlisted_env_key_stays_masked() {
// A key not in the allowlist must remain masked regardless of its name.

View File

@@ -250,6 +250,8 @@ with a TypeScript lookup table or an id comparison in a component.
refresh only local persona/team/managed-agent caches; they must never
invalidate the remote relay directory.
15. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components.
## The tests that enforce this
- `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing

View File

@@ -287,6 +287,19 @@ function toResult(
};
}
function isDatabricksModelServiceFqn(model: string): boolean {
const components = model.split(".");
return (
components.length === 3 &&
components.every(
(component) =>
component.length > 0 &&
!/\s/.test(component) &&
!component.includes("/"),
)
);
}
/**
* Resolve the capability profile for a `(provider, rawModelId)` pair.
*
@@ -300,9 +313,13 @@ export function resolveModelCapabilities(
): CapabilityResult {
const canon = canonicalizeProvider(provider);
const blank = rawModelId.trim().length === 0;
// Unity Catalog FQNs are neutral model-service identities. Resolve them
// through the concrete-unknown fallback before suffix family matching.
const modelServiceFqn =
canon === "databricks_v2" && isDatabricksModelServiceFqn(rawModelId);
// 1. Provider-qualified exact-record lookup (case-insensitive on the id).
if (!blank) {
if (!blank && !modelServiceFqn) {
const idLower = rawModelId.toLowerCase();
for (const rec of MANIFEST.exact_records) {
if (
@@ -315,7 +332,7 @@ export function resolveModelCapabilities(
}
// 2. Boundary-aware family match: longest token wins, lexicographic tie-break.
if (!blank) {
if (!blank && !modelServiceFqn) {
const modelLower = rawModelId.toLowerCase();
const stripped = stripCatalogPrefix(modelLower, MANIFEST.family_tokens);
let best: { len: number; rule: FamilyRule } | null = null;

View File

@@ -63,6 +63,18 @@ test("registry label aliases refuse ambiguous stripped record keys", () => {
);
});
test("Unity Catalog FQNs use neutral concrete-unknown capabilities", () => {
const fqn = resolveModelCapabilities(
"databricks_v2",
"data_workflow_tools.goose.goose-kimi-k3",
);
const fallback = resolveModelCapabilities(
"databricks_v2",
"some-unknown-xyz",
);
assert.deepEqual(fqn, fallback);
});
test("every executable corpus vector resolves to its expected six-axis profile", () => {
for (const entry of executable) {
const id = entry.id ?? "<no-id>";

View File

@@ -988,7 +988,7 @@ export async function getBakedBuildEnvKeys(): Promise<string[]> {
*
* The value is already masked in Rust for secret keys (keys not in the
* explicit safe-to-reveal allowlist: `BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL`,
* `DATABRICKS_HOST`, `DATABRICKS_MODEL`). Non-allowlisted keys have their
* `DATABRICKS_HOST`, `DATABRICKS_MODEL`, `DATABRICKS_MODEL_FILTER`). Non-allowlisted keys have their
* values replaced with `••••••`. Non-secret values are shown as-is.
* Empty-value keys are filtered out.
*/