mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
## Problem
The Databricks model dropdown offers a handful of stale models — and
there's no way to tell that list apart from the real one. The AI Gateway
exposes **66** chat/embedding endpoints on `block-lakehouse-production`,
but the picker was showing a short list that includes models the gateway
no longer serves and embedding endpoints that can't chat at all.
Three independent defects, all on the discovery path:
**1. Live discovery never ran for agents with no saved provider.**
`get_agent_models` gates every in-process discovery attempt on the
provider (`is_openai_compatible_provider` / `is_anthropic_provider` /
`is_databricks_provider`), reading it straight from `record.provider`.
That field is `null` for every agent record created before provider
persistence — and for any agent that inherits its provider from the
build. So all three gates saw `None`, no HTTP discovery ran, and the
request fell through to the `buzz-acp models` subprocess. On the
Databricks path that subprocess returns `discovery_failure_fallback` —
the small hardcoded `DATABRICKS_V2_KNOWN_MODELS` catalog — which the
frontend renders exactly like a live catalog. An internal DMG that bakes
`BUZZ_AGENT_PROVIDER=databricks_v2` and a `DATABRICKS_HOST` still got
the fallback.
**2. The fallback list couldn't represent the running model.**
When discovery genuinely fails, the picker should at minimum be able to
show what the agent is actually configured with. For `DatabricksV2` it
couldn't: the fallback returned only the hardcoded slate, so a model
like `databricks-gpt-5-5` wasn't selectable in its own picker.
**3. Embedding endpoints were offered as chat models.**
`databricks-bge-large-en` was selectable (visible in the dialog today).
The v2 endpoints payload carries no `task` or `state` field, so there is
nothing to filter on but the name.
## Changes
- **`effective_discovery_provider`** (new,
`desktop/src-tauri/src/commands/agent_models_env.rs`) — an explicit
provider (saved record value, or the create/edit dialog's current form
value) still always wins; when there is none, discovery falls back to
the runtime's own provider env var (`GOOSE_PROVIDER`,
`BUZZ_AGENT_PROVIDER`, …) read off the merged env, which by that point
already carries the baked build floor and the process env. Wired into
both `get_agent_models` and `discover_agent_models`.
`SavedAgentModelDiscoveryConfig` now carries `provider_env_var` from
`known_acp_runtime`, so each runtime reads *its own* key rather than a
shared guess.
- The relay-mesh branches in `discover_agent_models` deliberately keep
using `input.provider`: those key off a deliberate provider selection,
never a baked default.
- **Asserted vs inferred matters for missing credentials.** The OpenAI
and Anthropic gates error on a missing API key, while the Databricks
gate falls through; an inferred provider hitting the first two would
have replaced a working subprocess catalog with `config:
ANTHROPIC_API_KEY required` (`export GOOSE_PROVIDER=anthropic` is
goose's documented way to pick a provider, and it keeps the key in its
own keyring). So `effective_discovery_provider` returns a
`DiscoveryProvider` that remembers how the value was resolved, and
`required_env` only reports a missing credential for an asserted
provider. A wrong guess declines and lets the subprocess answer.
- **`is_chat_capable_endpoint`** (new,
`crates/buzz-agent/src/catalog.rs`) — applied in
`parse_v2_endpoints_page`. Drops `*embedding*` and segment-matched `bge`
/ `gte` endpoints; keeps everything unrecognised (fail-open, so a new
model family is never hidden). Segment matching is why it's `split('-')`
and not `contains`: a substring check would swallow legitimate names.
- **`discovery_failure_fallback`** for `Provider::DatabricksV2` now
leads with the configured model (deduped against the known slate,
blank-tolerant), so a failed discovery still yields a picker that can
show the running model. The configured model is trimmed once up front —
`resolve_model` doesn't trim, so a padded `DATABRICKS_MODEL` used to
slip past the dedupe and appear twice.
- **`sort_v2_endpoints_newest_first`** (new, second commit) — the
catalog is now ordered newest-first on each endpoint's
`created_timestamp`, ties broken by name. Previously Buzz sorted
nothing, so the gateway's own order reached the picker: it pages in two
phases (Databricks-managed, then workspace-created — the page token
decodes to `{"phase":"user"}`), each alphabetical, which buried
`databricks-claude-opus-5` 8th behind five older Claude endpoints and
`goose-claude-opus-5` — the newest endpoint in the catalog — 55th of 63.
Sorting in `fetch_v2_models` means both discovery paths inherit it with
no wire or type changes, and the combobox filter preserves incoming
order. Endpoints with an absent or unparseable timestamp sort last
rather than first, so a wire-shape change degrades to "unordered at the
bottom" instead of "shuffled to the top".
- The name tiebreak is load-bearing: eleven managed endpoints share one
placeholder timestamp (`1699610000000`), so without it their relative
order would vary between runs. That placeholder is also not always
accurate — a few genuinely recent endpoints
(`databricks-kimi-k2-7-code`, `databricks-llama-4-maverick`) land at the
bottom with the 2023 batch. The gateway offers nothing better to sort
on.
- Env/provider lookup helpers moved out of `agent_models.rs` into
`agent_models_env.rs`. This keeps the command module under the file-size
limit **without ratcheting the override up** — the existing 1079 entry
is untouched (file is now 1066 lines).
## Verification
Live against `block-lakehouse-production`, release build:
```
BUZZ_ACP_AGENT_COMMAND=$PWD/target/release/buzz-agent \
BUZZ_AGENT_PROVIDER=databricks_v2 \
DATABRICKS_HOST=https://block-lakehouse-production.cloud.databricks.com \
DATABRICKS_MODEL=databricks-gpt-5-5 \
./target/release/buzz-acp models --json
```
- before: 66 endpoints, including `databricks-bge-large-en`,
`databricks-gte-large-en`, `databricks-qwen3-embedding-0-6b`
- after: **63** endpoints, `[.models[] | select(.id |
test("embedding|-bge-|-gte-"))]` → `[]`
Top of the list after the sort commit:
```
goose-claude-opus-5 2026-07-24
databricks-claude-opus-5 2026-07-23
databricks-gemini-3-6-flash 2026-07-20
databricks-gemini-3-5-flash-lite 2026-07-20
databricks-inkling 2026-07-14
```
Tests: 15 new (8 in `catalog.rs` — including the two-wire-shape
timestamp parse, the sort's tiebreak/no-timestamp cases, and the
padded-model dedupe — and 7 plus one assertion in
`agent_models_tests.rs`, 3 of them covering the asserted/inferred
credential split), two existing tests updated. `just check`, `just
test-unit`, and `just desktop-tauri-test` all pass (1636 desktop-tauri
tests, 274 buzz-agent lib tests).
Not run locally: the Docker-backed integration suite (`just test`) —
this diff touches neither `buzz-relay`, `buzz-db`, nor `buzz-auth`.
## Follow-ups (deliberately out of scope)
Two inference-path defects found while investigating, both reproduced
live against the gateway and both independent of discovery:
1. **Gemini thought signatures are dropped.** The gateway returns a bare
`thoughtSignature` on tool calls; the external-model serving endpoints
return it nested as `extra_content.google.thought_signature`. Neither
shape is round-tripped, so multi-turn tool use on `databricks-gemini-*`
fails with a 400 on the second turn.
2. **Array-shaped `content` is silently discarded.** Some models return
OpenAI `content` as a block array rather than a string; `parse_openai`'s
`str_field` returns `None` and the text is dropped.
The legacy `serving-endpoints` path does not work around either one, and
costs reasoning support on the GPT-5 family.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
632 lines
24 KiB
Rust
632 lines
24 KiB
Rust
//! Databricks model catalog discovery.
|
|
//!
|
|
//! Exposes [`discover_databricks_models`] — an async helper that lists
|
|
//! available models for the `databricks` and `databricks_v2` providers
|
|
//! without triggering a browser OAuth flow. Auth is acquired in-process via
|
|
//! [`build_token_source`](crate::llm::build_token_source):
|
|
//!
|
|
//! - Static bearer (`DATABRICKS_TOKEN`): returned immediately.
|
|
//! - PKCE cache hit: returned from disk without a network round-trip.
|
|
//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)` — the
|
|
//! caller degrades gracefully; no browser, no hang.
|
|
|
|
use reqwest::Client;
|
|
|
|
use crate::{
|
|
config::{Config, Provider},
|
|
llm::build_token_source,
|
|
types::AgentError,
|
|
};
|
|
|
|
/// A discovered model entry: `id` is the picker value, `name` is the display
|
|
/// label (same as `id` for Databricks — the API has no separate display name).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ModelEntry {
|
|
pub id: String,
|
|
pub name: String,
|
|
}
|
|
|
|
/// Known Databricks AI Gateway v2 models — used as a fallback when the
|
|
/// `api/ai-gateway/v2/endpoints` call returns an empty list.
|
|
/// Mirrors goose's `DATABRICKS_V2_KNOWN_MODELS`.
|
|
pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] =
|
|
&["databricks-gpt-5-5", "databricks-claude-opus-4-7"];
|
|
|
|
/// Returns the discovery-failure fallback catalog for a Databricks provider.
|
|
///
|
|
/// This is the list of models advertised by `session/new` when
|
|
/// `discover_databricks_models` returns an error (e.g., no token available).
|
|
///
|
|
/// - `DatabricksV2` falls back to the configured model plus
|
|
/// [`DATABRICKS_V2_KNOWN_MODELS`] so the model-picker is always populated for
|
|
/// AI Gateway v2 users. The configured model leads: without it a fallback
|
|
/// catalog can omit the very model the agent is running, leaving the picker
|
|
/// unable to represent the current selection.
|
|
/// - Legacy `Databricks` falls back to only the configured model — the
|
|
/// `DATABRICKS_V2_KNOWN_MODELS` IDs are AI Gateway v2 endpoints that the
|
|
/// `/serving-endpoints/{model}/invocations` API may not serve.
|
|
///
|
|
/// Extracting this as a pure function makes the split testable without
|
|
/// spawning an async runtime or making network calls.
|
|
pub fn discovery_failure_fallback(provider: Provider, configured_model: &str) -> Vec<ModelEntry> {
|
|
// `resolve_model` does not trim, so a padded `DATABRICKS_MODEL` reaches here:
|
|
// normalize once, or the dedupe below misses and the picker lists the model
|
|
// twice (once padded, once from the known slate).
|
|
let configured_model = configured_model.trim();
|
|
let configured = ModelEntry {
|
|
id: configured_model.to_string(),
|
|
name: configured_model.to_string(),
|
|
};
|
|
match provider {
|
|
Provider::DatabricksV2 => {
|
|
let mut entries = Vec::with_capacity(DATABRICKS_V2_KNOWN_MODELS.len() + 1);
|
|
if !configured_model.is_empty() {
|
|
entries.push(configured);
|
|
}
|
|
entries.extend(
|
|
DATABRICKS_V2_KNOWN_MODELS
|
|
.iter()
|
|
.filter(|id| **id != configured_model)
|
|
.map(|id| ModelEntry {
|
|
id: id.to_string(),
|
|
name: id.to_string(),
|
|
}),
|
|
);
|
|
entries
|
|
}
|
|
Provider::Databricks => vec![configured],
|
|
_ => vec![configured],
|
|
}
|
|
}
|
|
|
|
/// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves
|
|
/// chat/completions traffic.
|
|
///
|
|
/// The v1 `serving-endpoints` payload carries `task`, so [`parse_v1_endpoints`]
|
|
/// can filter on it directly. The v2 `ai-gateway/v2/endpoints` payload carries
|
|
/// no task or readiness field at all, so the only signal available here is the
|
|
/// endpoint name. Embedding endpoints are the one family that reliably cannot
|
|
/// serve a chat request — they reject it with
|
|
/// `API type 'mlflow/v1/chat/completions' is not supported by '<name>'` — so
|
|
/// they are dropped rather than offered as selectable models.
|
|
///
|
|
/// Deliberately narrow: image-capable endpoints (e.g.
|
|
/// `databricks-gemini-3-pro-image`) do answer chat requests, so they stay. Any
|
|
/// name this heuristic does not recognise is kept — preferring to include over
|
|
/// silently dropping, matching [`parse_v1_endpoints`].
|
|
pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool {
|
|
let lower = name.to_ascii_lowercase();
|
|
if lower.contains("embedding") {
|
|
return false;
|
|
}
|
|
// Segment match so `bge`/`gte` cannot fire on a substring of a longer word.
|
|
!lower
|
|
.split('-')
|
|
.any(|segment| matches!(segment, "bge" | "gte"))
|
|
}
|
|
|
|
/// Discover available models for a Databricks provider.
|
|
///
|
|
/// Returns a non-empty `Vec<ModelEntry>` on success. Returns
|
|
/// `Err(AgentError::LlmAuth)` when no token is available (no static token,
|
|
/// no PKCE cache) — callers should degrade gracefully rather than hanging.
|
|
///
|
|
/// # Panics
|
|
/// Never panics.
|
|
pub async fn discover_databricks_models(cfg: &Config) -> Result<Vec<ModelEntry>, AgentError> {
|
|
let token_source = build_token_source(cfg)?;
|
|
let bearer = token_source.bearer_no_browser().await?;
|
|
|
|
let http = Client::new();
|
|
let host = cfg.base_url.trim_end_matches('/');
|
|
|
|
match cfg.provider {
|
|
Provider::Databricks => fetch_v1_models(&http, host, &bearer).await,
|
|
Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await,
|
|
_ => Err(AgentError::InvalidParams(
|
|
"discover_databricks_models called for non-Databricks provider".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// v1 — api/2.0/serving-endpoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn fetch_v1_models(
|
|
http: &Client,
|
|
host: &str,
|
|
bearer: &str,
|
|
) -> Result<Vec<ModelEntry>, AgentError> {
|
|
let url = format!("{host}/api/2.0/serving-endpoints");
|
|
let response = http
|
|
.get(&url)
|
|
.bearer_auth(bearer)
|
|
.send()
|
|
.await
|
|
.map_err(|e| AgentError::Llm(format!("Databricks model discovery request failed: {e}")))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(AgentError::Llm(format!(
|
|
"Databricks model discovery HTTP {status}: {body}"
|
|
)));
|
|
}
|
|
|
|
let json: serde_json::Value = response.json().await.map_err(|e| {
|
|
AgentError::Llm(format!(
|
|
"Databricks model discovery response parse failed: {e}"
|
|
))
|
|
})?;
|
|
|
|
parse_v1_endpoints(&json)
|
|
}
|
|
|
|
/// Parse a `GET api/2.0/serving-endpoints` response.
|
|
///
|
|
/// Filters to endpoints that are READY and serve an LLM chat/completions task.
|
|
/// When `state.ready` or `task` is absent the endpoint is included — prefer
|
|
/// including over silently dropping, per spec.
|
|
pub(crate) fn parse_v1_endpoints(json: &serde_json::Value) -> Result<Vec<ModelEntry>, AgentError> {
|
|
let endpoints = json
|
|
.get("endpoints")
|
|
.and_then(|v| v.as_array())
|
|
.ok_or_else(|| {
|
|
AgentError::Llm(
|
|
"Databricks model discovery: unexpected response (missing 'endpoints' array)"
|
|
.into(),
|
|
)
|
|
})?;
|
|
|
|
let models = endpoints
|
|
.iter()
|
|
.filter_map(|endpoint| {
|
|
let name = endpoint.get("name")?.as_str()?.to_string();
|
|
|
|
// Require READY state when present; include when absent.
|
|
let state_ready = endpoint
|
|
.get("state")
|
|
.and_then(|s| s.get("ready"))
|
|
.and_then(|r| r.as_str())
|
|
.map(|r| r == "READY")
|
|
.unwrap_or(true);
|
|
if !state_ready {
|
|
return None;
|
|
}
|
|
|
|
// Require LLM chat or completions task when present.
|
|
let task_ok = endpoint
|
|
.get("task")
|
|
.and_then(|t| t.as_str())
|
|
.map(|t| t == "llm/v1/chat" || t == "llm/v1/completions")
|
|
.unwrap_or(true);
|
|
if !task_ok {
|
|
return None;
|
|
}
|
|
|
|
Some(ModelEntry {
|
|
id: name.clone(),
|
|
name,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(models)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// v2 — api/ai-gateway/v2/endpoints (paginated)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Percent-encode a string for use as a URL query parameter value.
|
|
/// Only encodes characters that are not unreserved (RFC 3986).
|
|
fn percent_encode(s: &str) -> String {
|
|
s.bytes()
|
|
.flat_map(|b| match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
vec![b as char]
|
|
}
|
|
_ => format!("%{b:02X}").chars().collect(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
async fn fetch_v2_models(
|
|
http: &Client,
|
|
host: &str,
|
|
bearer: &str,
|
|
) -> Result<Vec<ModelEntry>, AgentError> {
|
|
let mut all_endpoints: Vec<V2Endpoint> = Vec::new();
|
|
let mut page_token: Option<String> = None;
|
|
let base_url = format!("{host}/api/ai-gateway/v2/endpoints");
|
|
|
|
// Cap at 20 pages (2 000 endpoints) to bound execution time.
|
|
for _ in 0..20 {
|
|
// Build URL with query params manually — avoids requiring the `query`
|
|
// reqwest feature in buzz-agent's Cargo.toml.
|
|
let url = match &page_token {
|
|
Some(tok) => format!(
|
|
"{base_url}?page_size=100&page_token={}",
|
|
percent_encode(tok)
|
|
),
|
|
None => format!("{base_url}?page_size=100"),
|
|
};
|
|
let response = http
|
|
.get(&url)
|
|
.bearer_auth(bearer)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
AgentError::Llm(format!("Databricks v2 model discovery request failed: {e}"))
|
|
})?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(AgentError::Llm(format!(
|
|
"Databricks v2 model discovery HTTP {status}: {body}"
|
|
)));
|
|
}
|
|
|
|
let json: serde_json::Value = response.json().await.map_err(|e| {
|
|
AgentError::Llm(format!(
|
|
"Databricks v2 model discovery response parse failed: {e}"
|
|
))
|
|
})?;
|
|
|
|
let (page_endpoints, next) = parse_v2_endpoints_page(&json)?;
|
|
all_endpoints.extend(page_endpoints);
|
|
|
|
match next {
|
|
Some(tok) if Some(&tok) != page_token.as_ref() => page_token = Some(tok),
|
|
_ => break,
|
|
}
|
|
}
|
|
|
|
// Fall back to known-model list if the API returned nothing.
|
|
if all_endpoints.is_empty() {
|
|
return Ok(DATABRICKS_V2_KNOWN_MODELS
|
|
.iter()
|
|
.map(|id| ModelEntry {
|
|
id: id.to_string(),
|
|
name: id.to_string(),
|
|
})
|
|
.collect());
|
|
}
|
|
|
|
sort_v2_endpoints_newest_first(&mut all_endpoints);
|
|
|
|
Ok(all_endpoints
|
|
.into_iter()
|
|
.map(|endpoint| endpoint.entry)
|
|
.collect())
|
|
}
|
|
|
|
/// A v2 gateway endpoint plus the key discovery orders the catalog by.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct V2Endpoint {
|
|
pub(crate) entry: ModelEntry,
|
|
/// `created_timestamp` as epoch milliseconds. `None` when the field is
|
|
/// absent or unparseable — those sort last rather than jumping the queue.
|
|
pub(crate) created_ms: Option<i64>,
|
|
}
|
|
|
|
/// Read `created_timestamp` from one endpoint object.
|
|
///
|
|
/// The gateway sends epoch milliseconds as a JSON *string*
|
|
/// (`"created_timestamp": "1699610000000"`); accept a bare number too, so a
|
|
/// wire-shape change doesn't silently drop every endpoint to the bottom.
|
|
fn endpoint_created_ms(endpoint: &serde_json::Value) -> Option<i64> {
|
|
let value = endpoint.get("created_timestamp")?;
|
|
value
|
|
.as_i64()
|
|
.or_else(|| value.as_str()?.trim().parse::<i64>().ok())
|
|
}
|
|
|
|
/// Order the catalog newest-first, breaking ties by name.
|
|
///
|
|
/// The gateway returns endpoints in two phases — Databricks-managed first, then
|
|
/// workspace-created — each alphabetical by name, which buries a brand-new
|
|
/// frontier model deep in the list. Newest-first puts the models people are
|
|
/// reaching for at the top of the picker.
|
|
///
|
|
/// Endpoints with no usable timestamp sort last, and the name tiebreak keeps the
|
|
/// result stable: several managed endpoints share one placeholder timestamp, so
|
|
/// without it their relative order would be arbitrary.
|
|
pub(crate) fn sort_v2_endpoints_newest_first(endpoints: &mut [V2Endpoint]) {
|
|
endpoints.sort_by(|a, b| {
|
|
// `None` < `Some(_)`, so reversing puts timestamped endpoints first.
|
|
b.created_ms
|
|
.cmp(&a.created_ms)
|
|
.then_with(|| a.entry.name.cmp(&b.entry.name))
|
|
});
|
|
}
|
|
|
|
/// Parse one page of a `GET api/ai-gateway/v2/endpoints` response.
|
|
///
|
|
/// Returns `(endpoints, next_page_token)`. An empty or absent `next_page_token`
|
|
/// signals the last page. Endpoints that cannot serve chat traffic are dropped
|
|
/// (see [`is_chat_capable_endpoint`]) so the model picker only offers models the
|
|
/// agent can actually run. Page order is preserved here; the caller sorts once
|
|
/// every page is in (see [`sort_v2_endpoints_newest_first`]).
|
|
pub(crate) fn parse_v2_endpoints_page(
|
|
json: &serde_json::Value,
|
|
) -> Result<(Vec<V2Endpoint>, Option<String>), AgentError> {
|
|
let endpoints = json
|
|
.get("endpoints")
|
|
.and_then(|v| v.as_array())
|
|
.ok_or_else(|| {
|
|
AgentError::Llm(
|
|
"Databricks v2 model discovery: unexpected response (missing 'endpoints' array)"
|
|
.into(),
|
|
)
|
|
})?;
|
|
|
|
let models = endpoints
|
|
.iter()
|
|
.filter_map(|endpoint| {
|
|
let name = endpoint.get("name")?.as_str()?.to_string();
|
|
if !is_chat_capable_endpoint(&name) {
|
|
return None;
|
|
}
|
|
Some(V2Endpoint {
|
|
entry: ModelEntry {
|
|
id: name.clone(),
|
|
name,
|
|
},
|
|
created_ms: endpoint_created_ms(endpoint),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let next_page_token = json
|
|
.get("next_page_token")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|token| !token.is_empty())
|
|
.map(str::to_string);
|
|
|
|
Ok((models, next_page_token))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn v1_parse_filters_ready_chat_endpoints() {
|
|
let json = serde_json::json!({
|
|
"endpoints": [
|
|
// included: READY + llm/v1/chat
|
|
{"name": "my-llm", "state": {"ready": "READY"}, "task": "llm/v1/chat"},
|
|
// included: READY + llm/v1/completions
|
|
{"name": "my-completions", "state": {"ready": "READY"}, "task": "llm/v1/completions"},
|
|
// excluded: NOT_READY
|
|
{"name": "dead-endpoint", "state": {"ready": "NOT_READY"}, "task": "llm/v1/chat"},
|
|
// excluded: wrong task
|
|
{"name": "embedding-ep", "state": {"ready": "READY"}, "task": "llm/v1/embedding"},
|
|
// included: no state field → include by default
|
|
{"name": "no-state", "task": "llm/v1/chat"},
|
|
// included: no task field → include by default
|
|
{"name": "no-task", "state": {"ready": "READY"}},
|
|
]
|
|
});
|
|
|
|
let models = parse_v1_endpoints(&json).unwrap();
|
|
let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
|
|
assert_eq!(ids, vec!["my-llm", "my-completions", "no-state", "no-task"]);
|
|
}
|
|
|
|
#[test]
|
|
fn v1_parse_errors_on_missing_endpoints_array() {
|
|
let json = serde_json::json!({"data": []});
|
|
let err = parse_v1_endpoints(&json).unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("missing 'endpoints' array"),
|
|
"got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn v1_parse_empty_endpoints_returns_empty_vec() {
|
|
let json = serde_json::json!({"endpoints": []});
|
|
let models = parse_v1_endpoints(&json).unwrap();
|
|
assert!(models.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn v2_parse_extracts_names_and_page_token() {
|
|
let json = serde_json::json!({
|
|
"endpoints": [
|
|
{"name": "databricks-claude-opus-4-7"},
|
|
{"name": "databricks-gpt-5-5"},
|
|
{"name": "custom-model"}
|
|
],
|
|
"next_page_token": "tok123"
|
|
});
|
|
|
|
let (models, next) = parse_v2_endpoints_page(&json).unwrap();
|
|
let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect();
|
|
assert_eq!(
|
|
ids,
|
|
vec![
|
|
"databricks-claude-opus-4-7",
|
|
"databricks-gpt-5-5",
|
|
"custom-model"
|
|
]
|
|
);
|
|
assert_eq!(next.as_deref(), Some("tok123"));
|
|
}
|
|
|
|
#[test]
|
|
fn v2_parse_empty_token_signals_last_page() {
|
|
let json = serde_json::json!({
|
|
"endpoints": [{"name": "only-model"}],
|
|
"next_page_token": ""
|
|
});
|
|
|
|
let (models, next) = parse_v2_endpoints_page(&json).unwrap();
|
|
assert_eq!(models.len(), 1);
|
|
assert!(
|
|
next.is_none(),
|
|
"empty token should be treated as no more pages"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn v2_parse_absent_token_signals_last_page() {
|
|
let json = serde_json::json!({"endpoints": [{"name": "only-model"}]});
|
|
let (_, next) = parse_v2_endpoints_page(&json).unwrap();
|
|
assert!(next.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn v2_parse_errors_on_missing_endpoints_array() {
|
|
let json = serde_json::json!({"data": []});
|
|
let err = parse_v2_endpoints_page(&json).unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("missing 'endpoints' array"),
|
|
"got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn v2_parse_drops_embedding_endpoints() {
|
|
// The v2 payload carries no `task`, so embedding endpoints are only
|
|
// recognisable by name. They reject chat requests, so offering them in
|
|
// the picker can only produce a 400 at send time.
|
|
let json = serde_json::json!({
|
|
"endpoints": [
|
|
{"name": "databricks-bge-large-en"},
|
|
{"name": "databricks-gte-large-en"},
|
|
{"name": "databricks-qwen3-embedding-0-6b"},
|
|
{"name": "databricks-claude-opus-5"},
|
|
{"name": "databricks-gemini-3-pro-image"},
|
|
]
|
|
});
|
|
|
|
let (models, _) = parse_v2_endpoints_page(&json).unwrap();
|
|
let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect();
|
|
// Image endpoints DO answer chat requests, so they are retained.
|
|
assert_eq!(
|
|
ids,
|
|
vec!["databricks-claude-opus-5", "databricks-gemini-3-pro-image"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn v2_parse_reads_created_timestamp_in_either_wire_shape() {
|
|
// The gateway sends epoch ms as a string; a bare number must work too.
|
|
let json = serde_json::json!({
|
|
"endpoints": [
|
|
{"name": "string-ts", "created_timestamp": "1784932442251"},
|
|
{"name": "number-ts", "created_timestamp": 1784932442251i64},
|
|
{"name": "junk-ts", "created_timestamp": "not-a-number"},
|
|
{"name": "no-ts"},
|
|
]
|
|
});
|
|
|
|
let (models, _) = parse_v2_endpoints_page(&json).unwrap();
|
|
let stamps: Vec<Option<i64>> = models.iter().map(|m| m.created_ms).collect();
|
|
assert_eq!(
|
|
stamps,
|
|
vec![Some(1784932442251), Some(1784932442251), None, None,]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn v2_endpoints_sort_newest_first_then_by_name() {
|
|
// Mirrors the real catalog: the gateway pages Databricks-managed
|
|
// endpoints first, then workspace-created ones, each alphabetical — so
|
|
// the newest model is buried mid-list until this sort runs.
|
|
let json = serde_json::json!({
|
|
"endpoints": [
|
|
{"name": "databricks-claude-opus-5", "created_timestamp": "1784851200000"},
|
|
{"name": "databricks-gpt-5-6-sol", "created_timestamp": "1784073600000"},
|
|
{"name": "databricks-gpt-5-6-luna", "created_timestamp": "1784073600000"},
|
|
{"name": "databricks-llama-4-maverick", "created_timestamp": "1699610000000"},
|
|
{"name": "goose-claude-opus-5", "created_timestamp": "1784932442251"},
|
|
{"name": "endpoint-without-timestamp"},
|
|
]
|
|
});
|
|
|
|
let (mut models, _) = parse_v2_endpoints_page(&json).unwrap();
|
|
sort_v2_endpoints_newest_first(&mut models);
|
|
|
|
let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect();
|
|
assert_eq!(
|
|
ids,
|
|
vec![
|
|
// Newest first, across both pagination phases.
|
|
"goose-claude-opus-5",
|
|
"databricks-claude-opus-5",
|
|
// Same timestamp — the name tiebreak keeps this deterministic.
|
|
"databricks-gpt-5-6-luna",
|
|
"databricks-gpt-5-6-sol",
|
|
"databricks-llama-4-maverick",
|
|
// No usable timestamp sorts last, never first.
|
|
"endpoint-without-timestamp",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn is_chat_capable_endpoint_keeps_unrecognised_names() {
|
|
// Prefer including over silently dropping — an unknown family is kept.
|
|
assert!(is_chat_capable_endpoint("databricks-glm-5-2"));
|
|
assert!(is_chat_capable_endpoint("some-teams-custom-endpoint"));
|
|
// `bge`/`gte` match as whole segments only, never as substrings.
|
|
assert!(is_chat_capable_endpoint("databricks-budget-gtex-model"));
|
|
assert!(!is_chat_capable_endpoint("databricks-bge-large-en"));
|
|
assert!(!is_chat_capable_endpoint("databricks-gte-large-en"));
|
|
assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b"));
|
|
}
|
|
|
|
#[test]
|
|
fn v2_discovery_failure_fallback_leads_with_configured_model() {
|
|
let result = discovery_failure_fallback(Provider::DatabricksV2, "databricks-claude-opus-5");
|
|
let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
|
|
|
|
// The running model must be representable in the picker even when
|
|
// discovery failed, so it leads the fallback catalog.
|
|
assert_eq!(ids.first(), Some(&"databricks-claude-opus-5"));
|
|
for known in DATABRICKS_V2_KNOWN_MODELS {
|
|
assert!(ids.contains(known), "fallback must retain '{known}'");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn v2_discovery_failure_fallback_does_not_duplicate_configured_model() {
|
|
let configured = DATABRICKS_V2_KNOWN_MODELS[0];
|
|
let result = discovery_failure_fallback(Provider::DatabricksV2, configured);
|
|
let occurrences = result.iter().filter(|m| m.id == configured).count();
|
|
assert_eq!(occurrences, 1, "got: {result:?}");
|
|
assert_eq!(result.len(), DATABRICKS_V2_KNOWN_MODELS.len());
|
|
}
|
|
|
|
#[test]
|
|
fn v2_discovery_failure_fallback_tolerates_blank_configured_model() {
|
|
for configured in ["", " "] {
|
|
let result = discovery_failure_fallback(Provider::DatabricksV2, configured);
|
|
let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
|
|
assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn v2_discovery_failure_fallback_dedupes_a_padded_configured_model() {
|
|
// `DATABRICKS_MODEL=" databricks-gpt-5-5 "` reaches here untrimmed, and an
|
|
// untrimmed comparison would list the model twice — once padded, once from
|
|
// the known slate.
|
|
let configured = DATABRICKS_V2_KNOWN_MODELS[0];
|
|
let result =
|
|
discovery_failure_fallback(Provider::DatabricksV2, &format!(" {configured} "));
|
|
let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
|
|
assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec());
|
|
}
|
|
}
|