mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>";
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user