mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
New Databricks endpoints (Claude Opus 5.5, GPT-6 Astra/Sol, etc.) showed as raw FQNs in the model picker until someone added a curated corpus record for each. This adds a strict token-class grammar as a third label tier inside the Databricks label helpers (Rust `model_capabilities.rs` + TS `modelCapabilities.ts`): exact record → unique alias → **generated label** → raw id. In TS it fires only for an explicit `databricks_v2` provider; providerless ids stay raw. ## Grammar Takes the last component of a UC FQN (or strips one wrapper: `databricks-`, `goose-`, `kgoose-`, `builderbot-`; an id that is neither stays raw), splits on `-`, and classifies each token as version, letter+digit version, size, trailing date (dropped), or word. Labels are built only from the id's own text, and the grammar never reinterprets a version. When in doubt it returns raw: - **Known families only.** The family (leading letters of the first token) must be in the manifest's existing `label_family_tokens`. Custom endpoints such as `prod.ai.rag-agent-3` or `builderbot-pr-reviews-v2` stay raw. Supporting a new vendor means adding one entry to that list, not a record per model. - **Attached digits stay whole** (`gpt10` → `GPT10`). If the family already carries digits and the next token is a version (`goose-qwen3-5-122b-a10b`, `system.ai.llama3-1-8b`, `system.ai.claude3-5-sonnet`), the id stays raw; this is checked before any reorder. - A third consecutive number (`gemini-3-7-1-flash`) returns raw rather than splitting off a patch version. - Any non-ASCII input returns raw, checked before trimming or case folding. - Any unclassifiable token, or no version-bearing token (`builderbot-pr-reviews`), returns raw. `(latest)`/`Preview` never appear unless an exact record supplies them. Special cases, each justified by an unseen-id shape rather than the corpus score: - `meta-llama-` → `Llama`: a namespace convention. - Claude numeric-first reorder: Goose names Claude endpoints `claude-4-7-opus`. Only 1–2 version tokens followed by `opus`/`sonnet`/`haiku` reorder (`claude-5-6-opus` → `Claude Opus 5.6`); anything else keeps its order (`claude-5-6-thinking` → `Claude 5.6 Thinking`). Both current numeric-first ids also have exact records. - Qwen compact decimal: Qwen's own naming `qwen35` → `Qwen3.5` when the second digit is nonzero. ## Colliding labels in the picker The same model is often served from several sources, so humanized labels collide (four rows are all `GPT-5.5`). `disambiguateModelLabels` in `formatAgentModelLabel.ts` suffixes only colliding Databricks labels with their source: - UC FQN: its `catalog.schema`, e.g. `GPT-6 Astra (system.ai)` / `GPT-6 Astra (sandbox.public)`. - Workspace endpoint: its wrapper, e.g. `GPT-5.5 (databricks)` / `GPT-5.5 (goose)` / `GPT-5.5 (kgoose)`. - Still colliding after that (e.g. a date variant in the same schema): the full id, e.g. `Gemini 3.7 Flash (system.ai.gemini-3-7-flash-0925)`. Unique labels, raw-id rows, the default row, and other providers are unchanged. It runs in `getDiscoveredPersonaModelOptions` (covering `AgentDropdownSelect`, `PersonaModelCombobox`, `PersonaDropdownField`, whose closed triggers read the option label) and in `ModelPicker`. Model search in `AgentDropdownSelect` and `PersonaModelCombobox` also matches the raw id, so searching `system.ai` finds those rows. Rust `ModelEntry.name` is unchanged. ## What labels drive Picker values, persistence, routing, catalog dedup, `resolve()` and capabilities all stay keyed on the raw id; two ids sharing a label both remain selectable. Labels do drive picker search (alongside the raw id), the workspace catalog's equal-time sort, and `TeamIdentityCard`'s "Mixed models" summary, which compares lowercased base labels, so `goose-` and `system.ai` GPT-6 Astra read as one model there (correct, since they are the same model). ## Tests Shared fixtures in `scripts/databricks-label-fixtures.json` are replayed by both Rust and TS. Each row asserts its tier: `exact` rows match a record, `alias` rows resolve with generation off, `generated` rows return nothing with generation off (so a future alias or record cannot mask them), and `raw` rows return nothing either way. A curator guard requires that across all 57 exact records, the ids the grammar does not reproduce equal exactly the 10 curator-only ids. `disambiguateModelLabels` has unit tests for namespace, wrapper, and full-id-fallback collisions, untouched rows, other providers, and case-insensitive grouping. The defaults-picker e2e checks the suffixed rows and trigger, raw-id search, and that choosing the second uncurated FQN by its visible name persists its raw id and restores its label. An edit-agent e2e covers the existing-agent model dropdown (`#edit-agent-model`): both suffixed rows render, the `system.ai` row is chosen by name, and after save and reopen the agent stores the `system.ai` id and shows `GPT-6 Astra (system.ai)`. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
1952 lines
70 KiB
Rust
1952 lines
70 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)`.
|
|
//!
|
|
//! This helper never opens a browser. Callers choose whether to reject, degrade,
|
|
//! or start a separate interactive authentication flow.
|
|
|
|
use std::{collections::HashSet, path::Path, sync::Arc, time::Duration};
|
|
|
|
use reqwest::Client;
|
|
use serde_json::Value;
|
|
|
|
use crate::{
|
|
auth::TokenSource,
|
|
config::{Config, DatabricksModelFilter, Provider},
|
|
llm::build_token_source,
|
|
types::AgentError,
|
|
};
|
|
|
|
/// A discovered model entry: `id` is the picker value (the raw endpoint id or
|
|
/// Unity Catalog model-service FQN, and the wire/config value), `name` is the
|
|
/// display label. Databricks catalog APIs do not provide a consistently useful
|
|
/// picker label, so discovery derives `name` as exact record → unique alias →
|
|
/// generated label grammar (known `label_family_tokens` families only) → raw
|
|
/// id. Adding a vendor means adding one `label_family_tokens` entry.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ModelEntry {
|
|
pub id: String,
|
|
pub name: String,
|
|
}
|
|
|
|
const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)";
|
|
const MAX_CATALOG_PAGES: usize = 20;
|
|
const MAX_CATALOG_ERROR_BODY_BYTES: usize = 4 * 1024;
|
|
const MAX_CATALOG_RESPONSE_BODY_BYTES: usize = 2 * 1024 * 1024;
|
|
const CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
|
const CATALOG_MAX_RETRIES: usize = 3;
|
|
const CATALOG_RETRY_BACKOFF: Duration = Duration::from_millis(100);
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct CatalogRequestPolicy {
|
|
timeout: Duration,
|
|
max_retries: usize,
|
|
retry_backoff: Duration,
|
|
}
|
|
|
|
const DEFAULT_CATALOG_REQUEST_POLICY: CatalogRequestPolicy = CatalogRequestPolicy {
|
|
timeout: CATALOG_REQUEST_TIMEOUT,
|
|
max_retries: CATALOG_MAX_RETRIES,
|
|
retry_backoff: CATALOG_RETRY_BACKOFF,
|
|
};
|
|
const WORKSPACE_CATALOG_QUERY: &str = "?page_size=100";
|
|
const UNITY_CATALOG_QUERY: &str = "?page_size=100&view=FULL";
|
|
type CatalogPage<T> = Result<(Vec<T>, Option<String>), AgentError>;
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct CatalogDescriptor<T> {
|
|
name: &'static str,
|
|
path: &'static str,
|
|
initial_query: &'static str,
|
|
parse_page: fn(&Value) -> CatalogPage<T>,
|
|
}
|
|
|
|
const WORKSPACE_CATALOG_DESCRIPTOR: CatalogDescriptor<V2Endpoint> = CatalogDescriptor {
|
|
name: "Databricks workspace endpoint catalog",
|
|
path: "/api/ai-gateway/v2/endpoints",
|
|
initial_query: WORKSPACE_CATALOG_QUERY,
|
|
parse_page: parse_v2_endpoints_page,
|
|
};
|
|
const UNITY_CATALOG_DESCRIPTOR: CatalogDescriptor<ModelEntry> = CatalogDescriptor {
|
|
name: "Databricks Unity Catalog model-service catalog",
|
|
path: "/api/2.1/unity-catalog/model-services",
|
|
initial_query: UNITY_CATALOG_QUERY,
|
|
parse_page: parse_uc_model_services_page,
|
|
};
|
|
|
|
/// Display label for a discovered Databricks endpoint or model-service id. Ids
|
|
/// the label grammar cannot fully parse deliberately pass through unchanged.
|
|
fn curated_model_name(id: &str) -> String {
|
|
crate::model_capabilities::databricks_registry_label(id).unwrap_or_else(|| id.to_string())
|
|
}
|
|
|
|
/// Fallback catalog used only when both authenticated Databricks v2 catalogs
|
|
/// successfully respond with no entries and no visibility filter is active.
|
|
/// The known-model ids come from the manifest, the single runtime source.
|
|
fn authenticated_empty_v2_catalog() -> Vec<ModelEntry> {
|
|
crate::model_capabilities::databricks_v2_known_models()
|
|
.iter()
|
|
.map(|id| ModelEntry {
|
|
id: id.clone(),
|
|
name: format!(
|
|
"{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}",
|
|
curated_model_name(id)
|
|
),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Heuristic chat-capability filter for v2 workspace endpoints.
|
|
///
|
|
/// The v2 catalog omits task metadata. Known embedding endpoint families cannot
|
|
/// answer chat-completions requests, so do not offer them as selectable models.
|
|
/// Unknown names remain visible; this filter is intentionally narrow.
|
|
pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool {
|
|
let lower = name.to_ascii_lowercase();
|
|
if lower.contains("embedding") {
|
|
return false;
|
|
}
|
|
!lower
|
|
.split('-')
|
|
.any(|segment| matches!(segment, "bge" | "gte"))
|
|
}
|
|
|
|
/// Discover available models for a Databricks provider.
|
|
///
|
|
/// Returns an empty vector when an authenticated catalog is valid but no
|
|
/// visible entries remain after filtering. Returns `Err(AgentError::LlmAuth)`
|
|
/// when no token is available (no static token, no PKCE cache). The helper
|
|
/// itself never starts interactive authentication.
|
|
///
|
|
/// For v2, the known-model fallback is used only when both catalog requests
|
|
/// succeed empty and no filter is active. A filter is applied to v1 results
|
|
/// after its existing endpoint capability filtering.
|
|
///
|
|
/// # Panics
|
|
/// Never panics.
|
|
pub async fn discover_databricks_models(cfg: &Config) -> Result<Vec<ModelEntry>, AgentError> {
|
|
discover_databricks_models_with_cache_dir(cfg, None).await
|
|
}
|
|
|
|
/// Discover Databricks models while storing PKCE credentials under an explicit
|
|
/// cache root. `None` preserves buzz-agent's production cache location.
|
|
pub async fn discover_databricks_models_with_cache_dir(
|
|
cfg: &Config,
|
|
cache_dir: Option<&Path>,
|
|
) -> Result<Vec<ModelEntry>, AgentError> {
|
|
let token_source = if matches!(cfg.provider, Provider::Databricks | Provider::DatabricksV2)
|
|
&& cfg.api_key.is_empty()
|
|
{
|
|
crate::auth::PkceOAuthTokenSource::new(crate::llm::databricks_pkce_config(
|
|
&cfg.base_url,
|
|
cache_dir.map(Path::to_path_buf),
|
|
))?
|
|
} else {
|
|
build_token_source(cfg)?
|
|
};
|
|
discover_databricks_models_with_token_source(cfg, token_source).await
|
|
}
|
|
|
|
async fn discover_databricks_models_with_token_source(
|
|
cfg: &Config,
|
|
token_source: Arc<dyn TokenSource>,
|
|
) -> Result<Vec<ModelEntry>, AgentError> {
|
|
discover_databricks_models_with_client(cfg, token_source, &Client::new()).await
|
|
}
|
|
|
|
pub(crate) async fn discover_databricks_models_with_client(
|
|
cfg: &Config,
|
|
token_source: Arc<dyn TokenSource>,
|
|
http: &Client,
|
|
) -> Result<Vec<ModelEntry>, AgentError> {
|
|
let mut bearer = token_source.bearer_no_browser().await?;
|
|
let host = cfg.base_url.trim_end_matches('/');
|
|
let mut refreshed = false;
|
|
|
|
loop {
|
|
let result = match cfg.provider {
|
|
Provider::Databricks => fetch_v1_models(http, host, &bearer)
|
|
.await
|
|
.map(|models| apply_model_filter(models, cfg.databricks_model_filter.as_ref())),
|
|
Provider::DatabricksV2 => {
|
|
fetch_v2_models(
|
|
http,
|
|
host,
|
|
&bearer,
|
|
cfg.databricks_model_filter.as_ref(),
|
|
refreshed,
|
|
)
|
|
.await
|
|
}
|
|
_ => {
|
|
return Err(AgentError::InvalidParams(
|
|
"discover_databricks_models called for non-Databricks provider".into(),
|
|
));
|
|
}
|
|
};
|
|
|
|
match result {
|
|
Err(AgentError::LlmAuth(_)) if !refreshed => {
|
|
refreshed = true;
|
|
let fresh = token_source.refresh_now(&bearer).await?;
|
|
if fresh == bearer {
|
|
return Err(AgentError::LlmAuth(
|
|
"Databricks rejected the configured credential".into(),
|
|
));
|
|
}
|
|
bearer = fresh;
|
|
}
|
|
result => return result,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_model_filter(
|
|
models: Vec<ModelEntry>,
|
|
filter: Option<&DatabricksModelFilter>,
|
|
) -> Vec<ModelEntry> {
|
|
match filter {
|
|
Some(filter) => models
|
|
.into_iter()
|
|
.filter(|model| filter.matches(&model.id))
|
|
.collect(),
|
|
None => models,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 json = fetch_catalog_page(
|
|
http,
|
|
&url,
|
|
"Databricks serving-endpoints catalog",
|
|
bearer,
|
|
DEFAULT_CATALOG_REQUEST_POLICY,
|
|
)
|
|
.await?;
|
|
|
|
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 the existing v1 contract.
|
|
pub(crate) fn parse_v1_endpoints(json: &Value) -> Result<Vec<ModelEntry>, AgentError> {
|
|
let endpoints = json
|
|
.get("endpoints")
|
|
.and_then(Value::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(Value::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(Value::as_str)
|
|
.map(|t| t == "llm/v1/chat" || t == "llm/v1/completions")
|
|
.unwrap_or(true);
|
|
if !task_ok {
|
|
return None;
|
|
}
|
|
|
|
Some(ModelEntry {
|
|
name: curated_model_name(&name),
|
|
id: name,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(models)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// v2 — api/ai-gateway/v2/endpoints + Unity Catalog model-services
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// Fetch both Databricks v2 catalogs concurrently and merge them into the
|
|
/// selectable model list. One catalog may be unavailable; an empty result is
|
|
/// still authoritative and never falls through to the known-model fallback
|
|
/// when a visibility filter is active.
|
|
async fn fetch_v2_models(
|
|
http: &Client,
|
|
host: &str,
|
|
bearer: &str,
|
|
filter: Option<&DatabricksModelFilter>,
|
|
allow_partial_auth_failure: bool,
|
|
) -> Result<Vec<ModelEntry>, AgentError> {
|
|
fetch_v2_models_with_policy(
|
|
http,
|
|
host,
|
|
bearer,
|
|
filter,
|
|
allow_partial_auth_failure,
|
|
DEFAULT_CATALOG_REQUEST_POLICY,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn fetch_v2_models_with_policy(
|
|
http: &Client,
|
|
host: &str,
|
|
bearer: &str,
|
|
filter: Option<&DatabricksModelFilter>,
|
|
allow_partial_auth_failure: bool,
|
|
policy: CatalogRequestPolicy,
|
|
) -> Result<Vec<ModelEntry>, AgentError> {
|
|
let workspace =
|
|
fetch_catalog_pages_with_policy(http, host, bearer, WORKSPACE_CATALOG_DESCRIPTOR, policy);
|
|
let unity_catalog =
|
|
fetch_catalog_pages_with_policy(http, host, bearer, UNITY_CATALOG_DESCRIPTOR, policy);
|
|
|
|
let (workspace, unity_catalog) = tokio::join!(workspace, unity_catalog);
|
|
let (workspace, unity_catalog, both_succeeded) = match (workspace, unity_catalog) {
|
|
(Ok(workspace), Ok(unity_catalog)) => (workspace, unity_catalog, true),
|
|
(Ok(workspace), Err(error)) => {
|
|
if matches!(&error, AgentError::LlmAuth(_)) && !allow_partial_auth_failure {
|
|
return Err(error);
|
|
}
|
|
tracing::warn!(
|
|
catalog = "unity-catalog model-services",
|
|
error_kind = catalog_error_kind(&error),
|
|
"Databricks model discovery degraded: catalog unavailable"
|
|
);
|
|
(workspace, Vec::new(), false)
|
|
}
|
|
(Err(error), Ok(unity_catalog)) => {
|
|
if matches!(&error, AgentError::LlmAuth(_)) && !allow_partial_auth_failure {
|
|
return Err(error);
|
|
}
|
|
tracing::warn!(
|
|
catalog = "workspace ai-gateway v2 endpoints",
|
|
error_kind = catalog_error_kind(&error),
|
|
"Databricks model discovery degraded: catalog unavailable"
|
|
);
|
|
(Vec::new(), unity_catalog, false)
|
|
}
|
|
(Err(workspace_error), Err(unity_catalog_error)) => {
|
|
return Err(combined_catalog_error(workspace_error, unity_catalog_error));
|
|
}
|
|
};
|
|
|
|
Ok(merge_v2_models(
|
|
workspace,
|
|
unity_catalog,
|
|
filter,
|
|
both_succeeded && filter.is_none(),
|
|
))
|
|
}
|
|
|
|
fn catalog_error_kind(error: &AgentError) -> &'static str {
|
|
match error {
|
|
AgentError::InvalidParams(_) => "invalid-params",
|
|
AgentError::Llm(_) => "llm",
|
|
AgentError::LlmAuth(_) => "auth",
|
|
AgentError::LlmModelNotFound(_) => "model-not-found",
|
|
AgentError::LlmContextExceeded(_) => "context-exceeded",
|
|
AgentError::UnsupportedImageInput(_) => "unsupported-image",
|
|
AgentError::Mcp(_) => "mcp",
|
|
AgentError::Cancelled => "cancelled",
|
|
}
|
|
}
|
|
|
|
fn combined_catalog_error(workspace: AgentError, unity_catalog: AgentError) -> AgentError {
|
|
let auth_failure = matches!(&workspace, AgentError::LlmAuth(_))
|
|
|| matches!(&unity_catalog, AgentError::LlmAuth(_));
|
|
let message = format!(
|
|
"Databricks v2 model discovery failed: workspace endpoint catalog: {workspace}; Unity Catalog model-service catalog: {unity_catalog}"
|
|
);
|
|
if auth_failure {
|
|
AgentError::LlmAuth(message)
|
|
} else {
|
|
AgentError::Llm(message)
|
|
}
|
|
}
|
|
|
|
fn merge_v2_models(
|
|
workspace: Vec<V2Endpoint>,
|
|
mut unity_catalog: Vec<ModelEntry>,
|
|
filter: Option<&DatabricksModelFilter>,
|
|
allow_known_model_fallback: bool,
|
|
) -> Vec<ModelEntry> {
|
|
let mut seen_ids = HashSet::new();
|
|
let mut merged = Vec::with_capacity(workspace.len() + unity_catalog.len());
|
|
|
|
// Workspace endpoints are ordered newest-first across all pages.
|
|
let mut workspace = workspace;
|
|
sort_v2_endpoints_newest_first(&mut workspace);
|
|
for endpoint in workspace {
|
|
if seen_ids.insert(endpoint.entry.id.clone()) {
|
|
merged.push(endpoint.entry);
|
|
}
|
|
}
|
|
|
|
// UC has no user-facing recency contract. Sort by the raw FQN for stable
|
|
// picker order, then deduplicate only by raw selectable id.
|
|
unity_catalog.sort_unstable_by(|a, b| a.id.cmp(&b.id));
|
|
for entry in unity_catalog {
|
|
if seen_ids.insert(entry.id.clone()) {
|
|
merged.push(entry);
|
|
}
|
|
}
|
|
|
|
if merged.is_empty() && allow_known_model_fallback && filter.is_none() {
|
|
merged = authenticated_empty_v2_catalog();
|
|
}
|
|
|
|
apply_model_filter(merged, filter)
|
|
}
|
|
|
|
async fn fetch_catalog_pages_with_policy<T>(
|
|
http: &Client,
|
|
host: &str,
|
|
bearer: &str,
|
|
descriptor: CatalogDescriptor<T>,
|
|
policy: CatalogRequestPolicy,
|
|
) -> Result<Vec<T>, AgentError> {
|
|
let CatalogDescriptor {
|
|
name: catalog,
|
|
path,
|
|
initial_query,
|
|
parse_page,
|
|
} = descriptor;
|
|
let base_url = format!("{host}{path}");
|
|
let mut all_items = Vec::new();
|
|
let mut page_token: Option<String> = None;
|
|
let mut seen_tokens = HashSet::new();
|
|
|
|
for _page in 0..MAX_CATALOG_PAGES {
|
|
let url = match &page_token {
|
|
Some(token) => format!(
|
|
"{base_url}{initial_query}&page_token={}",
|
|
percent_encode(token)
|
|
),
|
|
None => format!("{base_url}{initial_query}"),
|
|
};
|
|
let json = fetch_catalog_page(http, &url, catalog, bearer, policy).await?;
|
|
let (items, next_token) = parse_page(&json)
|
|
.map_err(|error| catalog_context_error(catalog, error, "response parse failed"))?;
|
|
all_items.extend(items);
|
|
|
|
match next_token {
|
|
None => return Ok(all_items),
|
|
Some(next_token) if seen_tokens.insert(next_token.clone()) => {
|
|
page_token = Some(next_token);
|
|
}
|
|
Some(next_token) => {
|
|
return Err(AgentError::Llm(format!(
|
|
"{catalog} pagination repeated page token {next_token:?}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(AgentError::Llm(format!(
|
|
"{catalog} pagination exhausted after {MAX_CATALOG_PAGES} pages"
|
|
)))
|
|
}
|
|
|
|
struct ReadResponseBody {
|
|
bytes: Vec<u8>,
|
|
truncated: bool,
|
|
}
|
|
|
|
enum CatalogRequestError {
|
|
Auth,
|
|
Status {
|
|
status: reqwest::StatusCode,
|
|
body: String,
|
|
},
|
|
Transport(reqwest::Error),
|
|
Body(reqwest::Error),
|
|
InvalidJson(serde_json::Error),
|
|
BodyTooLarge,
|
|
}
|
|
|
|
async fn fetch_catalog_page(
|
|
http: &Client,
|
|
url: &str,
|
|
catalog: &str,
|
|
bearer: &str,
|
|
policy: CatalogRequestPolicy,
|
|
) -> Result<Value, AgentError> {
|
|
let max_retries = policy.max_retries.max(1);
|
|
let error_body_limit = if bearer.len() > MAX_CATALOG_ERROR_BODY_BYTES {
|
|
0
|
|
} else {
|
|
MAX_CATALOG_ERROR_BODY_BYTES.saturating_add(bearer.len())
|
|
};
|
|
|
|
for attempt in 0..max_retries {
|
|
let result = tokio::time::timeout(policy.timeout, async {
|
|
let response = http
|
|
.get(url)
|
|
.bearer_auth(bearer)
|
|
.send()
|
|
.await
|
|
.map_err(CatalogRequestError::Transport)?;
|
|
let status = response.status();
|
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
|
// Preserve the auth contract: do not consume an auth-failure
|
|
// body because gateways may echo credential material. The
|
|
// bounded attempt ends at headers for this intentionally
|
|
// redacted branch; all other status/body paths below consume
|
|
// their response body inside the same deadline.
|
|
return Err(CatalogRequestError::Auth);
|
|
}
|
|
if !status.is_success() {
|
|
let mut response = response;
|
|
let body = read_catalog_error_body(&mut response, error_body_limit)
|
|
.await
|
|
.map_err(CatalogRequestError::Body)?;
|
|
return Err(CatalogRequestError::Status { status, body });
|
|
}
|
|
|
|
let mut response = response;
|
|
if response
|
|
.content_length()
|
|
.is_some_and(|length| length > MAX_CATALOG_RESPONSE_BODY_BYTES as u64)
|
|
{
|
|
return Err(CatalogRequestError::BodyTooLarge);
|
|
}
|
|
let body = read_response_body(&mut response, MAX_CATALOG_RESPONSE_BODY_BYTES)
|
|
.await
|
|
.map_err(CatalogRequestError::Body)?;
|
|
if body.truncated {
|
|
return Err(CatalogRequestError::BodyTooLarge);
|
|
}
|
|
serde_json::from_slice(&body.bytes).map_err(CatalogRequestError::InvalidJson)
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(json)) => return Ok(json),
|
|
Ok(Err(CatalogRequestError::Auth)) => {
|
|
return Err(AgentError::LlmAuth(format!("{catalog} HTTP 401")));
|
|
}
|
|
Ok(Err(CatalogRequestError::Status { status, body })) => {
|
|
if (status.as_u16() == 499 || status.is_server_error())
|
|
&& retry_catalog_attempt(
|
|
catalog,
|
|
attempt,
|
|
max_retries,
|
|
policy.retry_backoff,
|
|
Some(status.as_u16()),
|
|
"transient status",
|
|
)
|
|
.await
|
|
{
|
|
continue;
|
|
}
|
|
return Err(catalog_http_error_body(catalog, status, &body, bearer));
|
|
}
|
|
Ok(Err(CatalogRequestError::Transport(error))) => {
|
|
if (error.is_timeout() || error.is_connect() || error.is_request())
|
|
&& retry_catalog_attempt(
|
|
catalog,
|
|
attempt,
|
|
max_retries,
|
|
policy.retry_backoff,
|
|
None,
|
|
"transport error",
|
|
)
|
|
.await
|
|
{
|
|
continue;
|
|
}
|
|
return Err(AgentError::Llm(format!(
|
|
"{catalog} request failed: {error}"
|
|
)));
|
|
}
|
|
Ok(Err(CatalogRequestError::Body(error))) => {
|
|
if retry_catalog_attempt(
|
|
catalog,
|
|
attempt,
|
|
max_retries,
|
|
policy.retry_backoff,
|
|
None,
|
|
"response body error",
|
|
)
|
|
.await
|
|
{
|
|
continue;
|
|
}
|
|
return Err(AgentError::Llm(format!(
|
|
"{catalog} response body read failed: {error}"
|
|
)));
|
|
}
|
|
Ok(Err(CatalogRequestError::InvalidJson(error))) => {
|
|
if retry_catalog_attempt(
|
|
catalog,
|
|
attempt,
|
|
max_retries,
|
|
policy.retry_backoff,
|
|
None,
|
|
"invalid JSON response",
|
|
)
|
|
.await
|
|
{
|
|
continue;
|
|
}
|
|
return Err(AgentError::Llm(format!(
|
|
"{catalog} response parse failed: {error}"
|
|
)));
|
|
}
|
|
Ok(Err(CatalogRequestError::BodyTooLarge)) => {
|
|
return Err(AgentError::Llm(format!(
|
|
"{catalog} response exceeded {MAX_CATALOG_RESPONSE_BODY_BYTES} bytes"
|
|
)));
|
|
}
|
|
Err(_) => {
|
|
if retry_catalog_attempt(
|
|
catalog,
|
|
attempt,
|
|
max_retries,
|
|
policy.retry_backoff,
|
|
None,
|
|
"attempt timeout",
|
|
)
|
|
.await
|
|
{
|
|
continue;
|
|
}
|
|
return Err(AgentError::Llm(format!(
|
|
"{catalog} request timed out after {:?}",
|
|
policy.timeout
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(AgentError::Llm(format!(
|
|
"{catalog} request failed after {max_retries} attempts"
|
|
)))
|
|
}
|
|
|
|
async fn retry_catalog_attempt(
|
|
catalog: &str,
|
|
attempt: usize,
|
|
max_attempts: usize,
|
|
backoff: Duration,
|
|
status: Option<u16>,
|
|
reason: &'static str,
|
|
) -> bool {
|
|
if attempt + 1 >= max_attempts {
|
|
return false;
|
|
}
|
|
|
|
tracing::warn!(
|
|
catalog,
|
|
attempt = attempt + 1,
|
|
max_attempts,
|
|
status = ?status,
|
|
reason,
|
|
"Databricks model discovery catalog request retrying"
|
|
);
|
|
tokio::time::sleep(backoff).await;
|
|
true
|
|
}
|
|
|
|
fn catalog_http_error_body(
|
|
catalog: &str,
|
|
status: reqwest::StatusCode,
|
|
body: &str,
|
|
bearer: &str,
|
|
) -> AgentError {
|
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
|
return AgentError::LlmAuth(format!("{catalog} HTTP {status}"));
|
|
}
|
|
|
|
let body = if bearer.len() > MAX_CATALOG_ERROR_BODY_BYTES {
|
|
String::new()
|
|
} else if bearer.is_empty() {
|
|
body.to_string()
|
|
} else {
|
|
body.replace(bearer, "[redacted]")
|
|
};
|
|
let body = truncate_utf8_bytes(&body, MAX_CATALOG_ERROR_BODY_BYTES);
|
|
let classification = if status.as_u16() == 499 || status.is_server_error() {
|
|
"transient"
|
|
} else {
|
|
"failed"
|
|
};
|
|
AgentError::Llm(format!("{catalog} {classification} HTTP {status}: {body}"))
|
|
}
|
|
|
|
async fn read_response_body(
|
|
response: &mut reqwest::Response,
|
|
limit: usize,
|
|
) -> Result<ReadResponseBody, reqwest::Error> {
|
|
let mut bytes = Vec::with_capacity(limit.min(16 * 1024));
|
|
if limit == 0 {
|
|
return Ok(ReadResponseBody {
|
|
bytes,
|
|
truncated: true,
|
|
});
|
|
}
|
|
|
|
loop {
|
|
if bytes.len() == limit {
|
|
// Probe one frame past the bound. Without this read, a chunked body
|
|
// whose first chunk lands exactly on `limit` would be accepted
|
|
// without noticing the next frame.
|
|
let truncated = response.chunk().await?.is_some();
|
|
return Ok(ReadResponseBody { bytes, truncated });
|
|
}
|
|
|
|
let Some(chunk) = response.chunk().await? else {
|
|
return Ok(ReadResponseBody {
|
|
bytes,
|
|
truncated: false,
|
|
});
|
|
};
|
|
let remaining = limit - bytes.len();
|
|
if chunk.len() > remaining {
|
|
bytes.extend_from_slice(&chunk[..remaining]);
|
|
return Ok(ReadResponseBody {
|
|
bytes,
|
|
truncated: true,
|
|
});
|
|
}
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
}
|
|
|
|
async fn read_catalog_error_body(
|
|
response: &mut reqwest::Response,
|
|
limit: usize,
|
|
) -> Result<String, reqwest::Error> {
|
|
let body = read_response_body(response, limit).await?;
|
|
Ok(String::from_utf8_lossy(&body.bytes).into_owned())
|
|
}
|
|
|
|
fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String {
|
|
if value.len() <= max_bytes {
|
|
return value.to_string();
|
|
}
|
|
let mut end = max_bytes;
|
|
while !value.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
value[..end].to_string()
|
|
}
|
|
|
|
fn catalog_context_error(catalog: &str, error: AgentError, context: &str) -> AgentError {
|
|
match error {
|
|
AgentError::LlmAuth(message) => {
|
|
AgentError::LlmAuth(format!("{catalog} {context}: {message}"))
|
|
}
|
|
AgentError::Llm(message) => AgentError::Llm(format!("{catalog} {context}: {message}")),
|
|
other => AgentError::Llm(format!("{catalog} {context}: {other}")),
|
|
}
|
|
}
|
|
|
|
/// A v2 gateway endpoint plus the key discovery order field.
|
|
#[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 does not silently drop every endpoint to the bottom.
|
|
fn endpoint_created_ms(endpoint: &Value) -> Option<i64> {
|
|
let value = endpoint.get("created_timestamp")?;
|
|
value
|
|
.as_i64()
|
|
.or_else(|| value.as_str()?.trim().parse::<i64>().ok())
|
|
}
|
|
|
|
/// Order workspace endpoints newest-first, breaking ties by name.
|
|
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.
|
|
///
|
|
/// Page order is preserved here; the caller sorts once every page is in.
|
|
pub(crate) fn parse_v2_endpoints_page(
|
|
json: &Value,
|
|
) -> Result<(Vec<V2Endpoint>, Option<String>), AgentError> {
|
|
let endpoints = json
|
|
.get("endpoints")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| AgentError::Llm("unexpected response (missing 'endpoints' array)".into()))?;
|
|
|
|
let models = endpoints
|
|
.iter()
|
|
.filter_map(|endpoint| {
|
|
let name = endpoint.get("name")?.as_str()?.to_string();
|
|
if name.is_empty() || !is_chat_capable_endpoint(&name) {
|
|
return None;
|
|
}
|
|
Some(V2Endpoint {
|
|
entry: ModelEntry {
|
|
name: curated_model_name(&name),
|
|
id: name,
|
|
},
|
|
created_ms: endpoint_created_ms(endpoint),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let next_page_token = next_page_token(json);
|
|
Ok((models, next_page_token))
|
|
}
|
|
|
|
/// Parse one page of a `GET api/2.1/unity-catalog/model-services` response.
|
|
///
|
|
/// Unity Catalog resource names are returned as `model-services/<catalog>.<schema>.<service>`.
|
|
/// Only the exact resource prefix, a structurally valid three-component FQN,
|
|
/// and chat-capable service metadata are selectable. Missing or empty capability
|
|
/// metadata is retained for compatibility with older Databricks workspaces; a
|
|
/// non-empty capability list must advertise the MLflow chat API used for model-
|
|
/// service inference. The positive visibility filter is applied later.
|
|
pub(crate) fn parse_uc_model_services_page(
|
|
json: &Value,
|
|
) -> Result<(Vec<ModelEntry>, Option<String>), AgentError> {
|
|
let services = json
|
|
.get("model_services")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| {
|
|
AgentError::Llm("unexpected response (missing 'model_services' array)".into())
|
|
})?;
|
|
|
|
let models = services
|
|
.iter()
|
|
.filter_map(|service| {
|
|
let resource_name = service.get("name")?.as_str()?;
|
|
let fqn = resource_name.strip_prefix("model-services/")?;
|
|
if !crate::model_capabilities::is_databricks_model_service_fqn(fqn)
|
|
|| !uc_model_service_supports_chat(service)
|
|
{
|
|
return None;
|
|
}
|
|
Some(ModelEntry {
|
|
id: fqn.to_string(),
|
|
name: curated_model_name(fqn),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok((models, next_page_token(json)))
|
|
}
|
|
|
|
fn uc_model_service_supports_chat(service: &Value) -> bool {
|
|
let Some(api_types) = service.get("supported_api_types").and_then(Value::as_array) else {
|
|
return true;
|
|
};
|
|
|
|
api_types.is_empty()
|
|
|| api_types
|
|
.iter()
|
|
.any(|api_type| api_type.as_str() == Some("mlflow/v1/chat/completions"))
|
|
}
|
|
|
|
fn next_page_token(json: &Value) -> Option<String> {
|
|
json.get("next_page_token")
|
|
.and_then(Value::as_str)
|
|
.filter(|token| !token.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use async_trait::async_trait;
|
|
use axum::{extract::Query, http::StatusCode, routing::get, Json, Router};
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
const TEST_CATALOG_DESCRIPTOR: CatalogDescriptor<V2Endpoint> = CatalogDescriptor {
|
|
name: "test catalog",
|
|
path: "/catalog",
|
|
initial_query: "?page_size=100",
|
|
parse_page: parse_v2_endpoints_page,
|
|
};
|
|
|
|
fn test_policy(timeout: Duration, max_retries: usize) -> CatalogRequestPolicy {
|
|
CatalogRequestPolicy {
|
|
timeout,
|
|
max_retries,
|
|
retry_backoff: Duration::ZERO,
|
|
}
|
|
}
|
|
|
|
struct RefreshingTestTokenSource {
|
|
refreshes: AtomicUsize,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TokenSource for RefreshingTestTokenSource {
|
|
async fn bearer(&self) -> Result<String, AgentError> {
|
|
Ok("rejected".into())
|
|
}
|
|
|
|
async fn refresh_now(&self, rejected: &str) -> Result<String, AgentError> {
|
|
assert_eq!(rejected, "rejected");
|
|
self.refreshes.fetch_add(1, Ordering::SeqCst);
|
|
Ok("fresh".into())
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discovery_refreshes_rejected_bearer_once_then_retries_successfully() {
|
|
use axum::{
|
|
extract::Query,
|
|
http::{HeaderMap, StatusCode},
|
|
routing::get,
|
|
Json, Router,
|
|
};
|
|
use std::collections::HashMap;
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let requests = Arc::new(AtomicUsize::new(0));
|
|
let requests_for_route = requests.clone();
|
|
let app = Router::new().route(
|
|
"/api/ai-gateway/v2/endpoints",
|
|
get(
|
|
move |headers: HeaderMap, Query(_query): Query<HashMap<String, String>>| {
|
|
let requests = requests_for_route.clone();
|
|
async move {
|
|
requests.fetch_add(1, Ordering::SeqCst);
|
|
match headers
|
|
.get("authorization")
|
|
.and_then(|value| value.to_str().ok())
|
|
{
|
|
Some("Bearer fresh") => Ok(Json(serde_json::json!({
|
|
"endpoints": [{"name": "discovered-model"}],
|
|
"next_page_token": null,
|
|
}))),
|
|
_ => Err((StatusCode::UNAUTHORIZED, "rejected")),
|
|
}
|
|
}
|
|
},
|
|
),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let source = Arc::new(RefreshingTestTokenSource {
|
|
refreshes: AtomicUsize::new(0),
|
|
});
|
|
let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host, None);
|
|
let models = discover_databricks_models_with_token_source(&cfg, source.clone())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(models[0].id, "discovered-model");
|
|
assert_eq!(source.refreshes.load(Ordering::SeqCst), 1);
|
|
assert_eq!(requests.load(Ordering::SeqCst), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn v2_discovery_merges_workspace_and_unity_catalog_after_filtering() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let app = Router::new()
|
|
.route(
|
|
"/api/ai-gateway/v2/endpoints",
|
|
get(|Query(query): Query<HashMap<String, String>>| async move {
|
|
assert_eq!(query.get("page_size").map(String::as_str), Some("100"));
|
|
Json(serde_json::json!({
|
|
"endpoints": [
|
|
{"name": "blocked-workspace", "created_timestamp": 3},
|
|
{"name": "allowed-workspace", "created_timestamp": 2},
|
|
],
|
|
"next_page_token": null,
|
|
}))
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/2.1/unity-catalog/model-services",
|
|
get(|Query(query): Query<HashMap<String, String>>| async move {
|
|
assert_eq!(query.get("page_size").map(String::as_str), Some("100"));
|
|
assert_eq!(query.get("view").map(String::as_str), Some("FULL"));
|
|
Json(serde_json::json!({
|
|
"model_services": [
|
|
{"name": "model-services/catalog.schema.blocked-service"},
|
|
{"name": "model-services/catalog.schema.allowed-service"},
|
|
{"name": "model-services/catalog.schema.allowed-service"},
|
|
],
|
|
"next_page_token": null,
|
|
}))
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let filter =
|
|
DatabricksModelFilter::parse(Some("allowed-*,catalog.schema.allowed-*")).unwrap();
|
|
let cfg = Config::for_discovery(Provider::DatabricksV2, "token".into(), host, filter);
|
|
let models = discover_databricks_models(&cfg).await.unwrap();
|
|
assert_eq!(
|
|
models
|
|
.iter()
|
|
.map(|model| model.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["allowed-workspace", "catalog.schema.allowed-service"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn v2_discovery_keeps_unity_catalog_when_workspace_catalog_fails() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let app = Router::new()
|
|
.route(
|
|
"/api/ai-gateway/v2/endpoints",
|
|
get(|| async { (StatusCode::SERVICE_UNAVAILABLE, "workspace unavailable") }),
|
|
)
|
|
.route(
|
|
"/api/2.1/unity-catalog/model-services",
|
|
get(|| async {
|
|
Json(serde_json::json!({
|
|
"model_services": [
|
|
{"name": "model-services/catalog.schema.uc-service"}
|
|
],
|
|
"next_page_token": null,
|
|
}))
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let cfg = Config::for_discovery(Provider::DatabricksV2, "token".into(), host, None);
|
|
let models = discover_databricks_models(&cfg).await.unwrap();
|
|
assert_eq!(
|
|
models
|
|
.iter()
|
|
.map(|model| model.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["catalog.schema.uc-service"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn v2_empty_catalog_fallback_is_disabled_by_filter() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let app = Router::new()
|
|
.route(
|
|
"/api/ai-gateway/v2/endpoints",
|
|
get(|| async {
|
|
Json(serde_json::json!({
|
|
"endpoints": [],
|
|
"next_page_token": null,
|
|
}))
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/2.1/unity-catalog/model-services",
|
|
get(|| async {
|
|
Json(serde_json::json!({
|
|
"model_services": [],
|
|
"next_page_token": null,
|
|
}))
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let unfiltered =
|
|
Config::for_discovery(Provider::DatabricksV2, "token".into(), host.clone(), None);
|
|
let fallback = discover_databricks_models(&unfiltered).await.unwrap();
|
|
assert_eq!(
|
|
fallback
|
|
.iter()
|
|
.map(|model| model.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
crate::model_capabilities::databricks_v2_known_models()
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<Vec<_>>()
|
|
);
|
|
|
|
let filter = DatabricksModelFilter::parse(Some("no-match")).unwrap();
|
|
let filtered = Config::for_discovery(Provider::DatabricksV2, "token".into(), host, filter);
|
|
assert!(discover_databricks_models(&filtered)
|
|
.await
|
|
.unwrap()
|
|
.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn catalog_pagination_encodes_tokens_and_rejects_repeated_tokens() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let app = Router::new().route(
|
|
"/catalog",
|
|
get(|Query(query): Query<HashMap<String, String>>| async move {
|
|
match query.get("page_token").map(String::as_str) {
|
|
None => Json(serde_json::json!({
|
|
"endpoints": [{"name": "first"}],
|
|
"next_page_token": "token with/slash",
|
|
})),
|
|
Some("token with/slash") => Json(serde_json::json!({
|
|
"endpoints": [{"name": "second"}],
|
|
})),
|
|
Some(other) => panic!("unexpected decoded page token: {other}"),
|
|
}
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let entries = fetch_catalog_pages_with_policy(
|
|
&Client::new(),
|
|
&host,
|
|
"token",
|
|
TEST_CATALOG_DESCRIPTOR,
|
|
DEFAULT_CATALOG_REQUEST_POLICY,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(entries.len(), 2);
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let app = Router::new().route(
|
|
"/catalog",
|
|
get(|| async {
|
|
Json(serde_json::json!({
|
|
"endpoints": [{"name": "loop"}],
|
|
"next_page_token": "same-token",
|
|
}))
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
let error = fetch_catalog_pages_with_policy(
|
|
&Client::new(),
|
|
&host,
|
|
"token",
|
|
TEST_CATALOG_DESCRIPTOR,
|
|
DEFAULT_CATALOG_REQUEST_POLICY,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(error.to_string().contains("repeated page token"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn catalog_pagination_errors_after_the_finite_page_cap() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let requests = Arc::new(AtomicUsize::new(0));
|
|
let requests_for_handler = requests.clone();
|
|
let app = Router::new().route(
|
|
"/catalog",
|
|
get(move |Query(_query): Query<HashMap<String, String>>| {
|
|
let page = requests_for_handler.fetch_add(1, Ordering::SeqCst) + 1;
|
|
async move {
|
|
Json(serde_json::json!({
|
|
"endpoints": [{"name": format!("model-{page}")}],
|
|
"next_page_token": format!("token-{page}"),
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let error = fetch_catalog_pages_with_policy(
|
|
&Client::new(),
|
|
&host,
|
|
"token",
|
|
TEST_CATALOG_DESCRIPTOR,
|
|
DEFAULT_CATALOG_REQUEST_POLICY,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(error
|
|
.to_string()
|
|
.contains("pagination exhausted after 20 pages"));
|
|
assert_eq!(requests.load(Ordering::SeqCst), 20);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn v2_discovery_degrades_a_stalled_secondary_catalog() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let app = Router::new()
|
|
.route(
|
|
"/api/ai-gateway/v2/endpoints",
|
|
get(|| async {
|
|
Json(serde_json::json!({
|
|
"endpoints": [{"name": "workspace-only"}],
|
|
"next_page_token": null,
|
|
}))
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/2.1/unity-catalog/model-services",
|
|
get(|| async {
|
|
// The handler never sends headers. The catalog attempt
|
|
// deadline must still let the workspace result win.
|
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
|
Json(serde_json::json!({
|
|
"model_services": [],
|
|
"next_page_token": null,
|
|
}))
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let started = std::time::Instant::now();
|
|
let models = fetch_v2_models_with_policy(
|
|
&Client::new(),
|
|
&host,
|
|
"token",
|
|
None,
|
|
false,
|
|
test_policy(Duration::from_millis(40), 1),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(1),
|
|
"stalled catalog exceeded its request deadline: {:?}",
|
|
started.elapsed()
|
|
);
|
|
assert_eq!(
|
|
models
|
|
.iter()
|
|
.map(|model| model.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["workspace-only"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn catalog_retries_499_and_5xx_then_recovers() {
|
|
for status in [
|
|
StatusCode::from_u16(499).unwrap(),
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
] {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let requests = Arc::new(AtomicUsize::new(0));
|
|
let requests_for_route = requests.clone();
|
|
let app = Router::new().route(
|
|
"/catalog",
|
|
get(move || {
|
|
let attempt = requests_for_route.fetch_add(1, Ordering::SeqCst);
|
|
async move {
|
|
if attempt == 0 {
|
|
Err((status, "provider body secret-token"))
|
|
} else {
|
|
Ok(Json(serde_json::json!({
|
|
"endpoints": [{"name": "recovered"}],
|
|
"next_page_token": null,
|
|
})))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let entries = fetch_catalog_pages_with_policy(
|
|
&Client::new(),
|
|
&host,
|
|
"secret-token",
|
|
TEST_CATALOG_DESCRIPTOR,
|
|
test_policy(Duration::from_secs(1), 3),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].entry.id, "recovered");
|
|
assert_eq!(requests.load(Ordering::SeqCst), 2);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn catalog_retries_malformed_json_then_recovers() {
|
|
use axum::response::IntoResponse;
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let requests = Arc::new(AtomicUsize::new(0));
|
|
let requests_for_route = requests.clone();
|
|
let app = Router::new().route(
|
|
"/catalog",
|
|
get(move || {
|
|
let attempt = requests_for_route.fetch_add(1, Ordering::SeqCst);
|
|
async move {
|
|
if attempt == 0 {
|
|
(StatusCode::OK, "not-json").into_response()
|
|
} else {
|
|
Json(serde_json::json!({
|
|
"endpoints": [{"name": "json-recovered"}],
|
|
"next_page_token": null,
|
|
}))
|
|
.into_response()
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let entries = fetch_catalog_pages_with_policy(
|
|
&Client::new(),
|
|
&host,
|
|
"token",
|
|
TEST_CATALOG_DESCRIPTOR,
|
|
test_policy(Duration::from_secs(1), 3),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(requests.load(Ordering::SeqCst), 2);
|
|
assert_eq!(entries[0].entry.id, "json-recovered");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn catalog_transient_failure_exhausts_exactly_three_attempts_without_bearer_leak() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let requests = Arc::new(AtomicUsize::new(0));
|
|
let requests_for_route = requests.clone();
|
|
let app = Router::new().route(
|
|
"/catalog",
|
|
get(move || {
|
|
requests_for_route.fetch_add(1, Ordering::SeqCst);
|
|
async {
|
|
(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"provider body secret-token",
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
|
|
let error = fetch_catalog_pages_with_policy(
|
|
&Client::new(),
|
|
&host,
|
|
"secret-token",
|
|
TEST_CATALOG_DESCRIPTOR,
|
|
test_policy(Duration::from_secs(1), 3),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(requests.load(Ordering::SeqCst), 3);
|
|
let message = error.to_string();
|
|
assert!(
|
|
message.contains("transient HTTP 503"),
|
|
"unexpected error: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("provider body"),
|
|
"body context was lost: {message}"
|
|
);
|
|
assert!(
|
|
!message.contains("secret-token"),
|
|
"bearer leaked through catalog error: {message}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn catalog_retries_when_headers_arrive_but_response_body_stalls() {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let host = format!("http://{}", listener.local_addr().unwrap());
|
|
let requests = Arc::new(AtomicUsize::new(0));
|
|
let headers_sent = Arc::new(AtomicUsize::new(0));
|
|
let requests_for_server = requests.clone();
|
|
let headers_for_server = headers_sent.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut socket, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
let attempt = requests_for_server.fetch_add(1, Ordering::SeqCst);
|
|
let headers_sent = headers_for_server.clone();
|
|
tokio::spawn(async move {
|
|
let mut request = Vec::new();
|
|
let mut chunk = [0u8; 1024];
|
|
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
match socket.read(&mut chunk).await {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(read) => request.extend_from_slice(&chunk[..read]),
|
|
}
|
|
}
|
|
|
|
if attempt == 0 {
|
|
socket
|
|
.write_all(
|
|
b"HTTP/1.1 200 OK\r\n\
|
|
Content-Type: application/json\r\n\
|
|
Content-Length: 64\r\n\
|
|
Connection: close\r\n\r\n\
|
|
{\"endpoints\": [",
|
|
)
|
|
.await
|
|
.ok();
|
|
headers_sent.store(1, Ordering::SeqCst);
|
|
// Keep the declared body incomplete. The outer attempt
|
|
// timeout, not reqwest::send(), must terminate this read.
|
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
|
} else {
|
|
let body =
|
|
r#"{"endpoints":[{"name":"body-recovered"}],"next_page_token":null}"#;
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
|
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
socket.write_all(response.as_bytes()).await.ok();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
let entries = fetch_catalog_pages_with_policy(
|
|
&Client::new(),
|
|
&host,
|
|
"token",
|
|
TEST_CATALOG_DESCRIPTOR,
|
|
test_policy(Duration::from_millis(40), 2),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(headers_sent.load(Ordering::SeqCst), 1);
|
|
assert_eq!(requests.load(Ordering::SeqCst), 2);
|
|
assert_eq!(entries[0].entry.id, "body-recovered");
|
|
}
|
|
#[test]
|
|
fn v1_filter_applies_to_raw_ids_after_endpoint_filtering() {
|
|
let filter = DatabricksModelFilter::parse(Some("allowed-*")).unwrap();
|
|
let models = apply_model_filter(
|
|
vec![
|
|
ModelEntry {
|
|
id: "allowed-model".into(),
|
|
name: "Allowed".into(),
|
|
},
|
|
ModelEntry {
|
|
id: "blocked-model".into(),
|
|
name: "Blocked".into(),
|
|
},
|
|
],
|
|
filter.as_ref(),
|
|
);
|
|
assert_eq!(models.len(), 1);
|
|
assert_eq!(models[0].id, "allowed-model");
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_error_body_is_bounded_and_redacts_bearer() {
|
|
let bearer = "secret-token";
|
|
let provider_body = format!("prefix {bearer} {}", "x".repeat(8_192));
|
|
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
|
|
let error = catalog_http_error_body("test catalog", status, &provider_body, bearer);
|
|
let message = error.to_string();
|
|
assert!(
|
|
message.contains("transient HTTP 503"),
|
|
"unexpected error: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("[redacted]"),
|
|
"bearer was not redacted: {message}"
|
|
);
|
|
assert!(!message.contains(bearer), "bearer leaked: {message}");
|
|
let prefix = format!("llm: test catalog transient HTTP {status}: ");
|
|
assert!(
|
|
message.starts_with(&prefix),
|
|
"unexpected catalog error prefix: message={message:?}, prefix={prefix:?}"
|
|
);
|
|
let diagnostic = &message[prefix.len()..];
|
|
assert!(
|
|
diagnostic.len() <= MAX_CATALOG_ERROR_BODY_BYTES,
|
|
"error body exceeded diagnostic bound: {}",
|
|
diagnostic.len()
|
|
);
|
|
|
|
// Keep the UTF-8 boundary behavior explicit as well.
|
|
let value = format!("{}é", "x".repeat(MAX_CATALOG_ERROR_BODY_BYTES));
|
|
let truncated = truncate_utf8_bytes(&value, MAX_CATALOG_ERROR_BODY_BYTES);
|
|
assert_eq!(truncated.len(), MAX_CATALOG_ERROR_BODY_BYTES);
|
|
assert!(truncated.is_char_boundary(truncated.len()));
|
|
}
|
|
|
|
#[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() {
|
|
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();
|
|
assert_eq!(
|
|
ids,
|
|
vec!["databricks-claude-opus-5", "databricks-gemini-3-pro-image",]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uc_parse_requires_exact_prefix_and_structural_fqn() {
|
|
let json = serde_json::json!({
|
|
"model_services": [
|
|
{"name": "model-services/data_tools.goose.kimi-k3"},
|
|
{"name": "model-services/catalog.schema.claude-gpt-5"},
|
|
{"name": "model-services/two.parts"},
|
|
{"name": "model-services/too.many.parts.here"},
|
|
{"name": "Model-services/wrong.case.service"},
|
|
{"name": "models/data_tools.goose.other"},
|
|
{"name": "model-services/.schema.service"},
|
|
{"name": "model-services/catalog..service"},
|
|
{"name": "model-services/catalog.schema."},
|
|
{"name": "model-services/catalog.schema/service"},
|
|
],
|
|
"next_page_token": "next token/1"
|
|
});
|
|
|
|
let (models, next) = parse_uc_model_services_page(&json).unwrap();
|
|
let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
|
|
assert_eq!(
|
|
ids,
|
|
vec!["data_tools.goose.kimi-k3", "catalog.schema.claude-gpt-5"]
|
|
);
|
|
assert_eq!(next.as_deref(), Some("next token/1"));
|
|
}
|
|
|
|
#[test]
|
|
fn uc_parse_filters_known_non_chat_services_and_preserves_unknown_capabilities() {
|
|
let json = serde_json::json!({
|
|
"model_services": [
|
|
{
|
|
"name": "model-services/system.ai.chat-model",
|
|
"supported_api_types": [
|
|
"mlflow/v1/chat/completions",
|
|
"mlflow/v1/responses"
|
|
]
|
|
},
|
|
{
|
|
"name": "model-services/system.ai.embedding-model",
|
|
"supported_api_types": ["mlflow/v1/embeddings"]
|
|
},
|
|
{
|
|
"name": "model-services/system.ai.responses-only-model",
|
|
"supported_api_types": ["mlflow/v1/responses"]
|
|
},
|
|
{
|
|
"name": "model-services/catalog.schema.empty-capabilities",
|
|
"supported_api_types": []
|
|
},
|
|
{"name": "model-services/catalog.schema.absent-capabilities"},
|
|
]
|
|
});
|
|
|
|
let (models, _) = parse_uc_model_services_page(&json).unwrap();
|
|
let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect();
|
|
assert_eq!(
|
|
ids,
|
|
vec![
|
|
"system.ai.chat-model",
|
|
"catalog.schema.empty-capabilities",
|
|
"catalog.schema.absent-capabilities",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uc_parse_requires_model_services_array() {
|
|
let err = parse_uc_model_services_page(&serde_json::json!({"data": []})).unwrap_err();
|
|
assert!(err.to_string().contains("missing 'model_services' array"));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_deduplicates_raw_ids_and_preserves_workspace_then_lexical_uc_order() {
|
|
let workspace = vec![
|
|
V2Endpoint {
|
|
entry: ModelEntry {
|
|
id: "workspace-new".into(),
|
|
name: "workspace-new".into(),
|
|
},
|
|
created_ms: Some(2),
|
|
},
|
|
V2Endpoint {
|
|
entry: ModelEntry {
|
|
id: "duplicate".into(),
|
|
name: "duplicate".into(),
|
|
},
|
|
created_ms: Some(1),
|
|
},
|
|
];
|
|
let uc = vec![
|
|
ModelEntry {
|
|
id: "z.schema.service".into(),
|
|
name: "z.schema.service".into(),
|
|
},
|
|
ModelEntry {
|
|
id: "a.schema.service".into(),
|
|
name: "a.schema.service".into(),
|
|
},
|
|
ModelEntry {
|
|
id: "duplicate".into(),
|
|
name: "same leaf".into(),
|
|
},
|
|
ModelEntry {
|
|
id: "a.other.service".into(),
|
|
name: "same leaf".into(),
|
|
},
|
|
];
|
|
|
|
let models = merge_v2_models(workspace, uc, None, false);
|
|
let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect();
|
|
assert_eq!(
|
|
ids,
|
|
vec![
|
|
"workspace-new",
|
|
"duplicate",
|
|
"a.other.service",
|
|
"a.schema.service",
|
|
"z.schema.service",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_applies_filter_after_union_and_does_not_restore_fallback() {
|
|
let filter = DatabricksModelFilter::parse(Some("allowed.*")).unwrap();
|
|
let filter = filter.as_ref();
|
|
let workspace = vec![V2Endpoint {
|
|
entry: ModelEntry {
|
|
id: "blocked-workspace".into(),
|
|
name: "blocked-workspace".into(),
|
|
},
|
|
created_ms: Some(1),
|
|
}];
|
|
let uc = vec![ModelEntry {
|
|
id: "allowed.schema.service".into(),
|
|
name: "allowed.schema.service".into(),
|
|
}];
|
|
let models = merge_v2_models(workspace, uc, filter, false);
|
|
assert_eq!(
|
|
models.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
|
|
vec!["allowed.schema.service"]
|
|
);
|
|
|
|
let no_match = DatabricksModelFilter::parse(Some("no-match")).unwrap();
|
|
assert!(merge_v2_models(Vec::new(), Vec::new(), no_match.as_ref(), true).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn merge_uses_known_fallback_only_for_unfiltered_successful_empty_union() {
|
|
let models = merge_v2_models(Vec::new(), Vec::new(), None, true);
|
|
assert_eq!(
|
|
models
|
|
.iter()
|
|
.map(|model| model.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
crate::model_capabilities::databricks_v2_known_models()
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[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 authenticated_empty_v2_catalog_marks_fallback_provenance() {
|
|
let models = authenticated_empty_v2_catalog();
|
|
let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect();
|
|
|
|
let known: Vec<&str> = crate::model_capabilities::databricks_v2_known_models()
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect();
|
|
assert_eq!(ids, known);
|
|
// `name` is the curated label + provenance suffix, not the raw id.
|
|
assert!(models.iter().all(|model| {
|
|
let label = crate::model_capabilities::databricks_registry_label(&model.id)
|
|
.unwrap_or_else(|| model.id.clone());
|
|
model.name == format!("{label}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}")
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn v2_parse_curates_known_name_and_passes_unknown_through() {
|
|
// buzz-agent's real discovery contract: the endpoint id IS the name the
|
|
// API returns. A known id gets its manifest label; an unknown id stays raw.
|
|
let json = serde_json::json!({
|
|
"endpoints": [
|
|
{"name": "databricks-gpt-5-5"},
|
|
{"name": "custom-unlisted-endpoint"},
|
|
]
|
|
});
|
|
let (models, _) = parse_v2_endpoints_page(&json).unwrap();
|
|
let by_id: std::collections::HashMap<&str, &str> = models
|
|
.iter()
|
|
.map(|m| (m.entry.id.as_str(), m.entry.name.as_str()))
|
|
.collect();
|
|
assert_eq!(by_id["databricks-gpt-5-5"], "GPT-5.5");
|
|
assert_eq!(
|
|
by_id["custom-unlisted-endpoint"],
|
|
"custom-unlisted-endpoint"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn v1_parse_curates_known_name_and_passes_unknown_through() {
|
|
let json = serde_json::json!({
|
|
"endpoints": [
|
|
{"name": "databricks-gpt-5-5", "task": "llm/v1/chat"},
|
|
{"name": "custom-unlisted-endpoint", "task": "llm/v1/chat"},
|
|
]
|
|
});
|
|
let models = parse_v1_endpoints(&json).unwrap();
|
|
let by_id: std::collections::HashMap<&str, &str> = models
|
|
.iter()
|
|
.map(|m| (m.id.as_str(), m.name.as_str()))
|
|
.collect();
|
|
assert_eq!(by_id["databricks-gpt-5-5"], "GPT-5.5");
|
|
assert_eq!(
|
|
by_id["custom-unlisted-endpoint"],
|
|
"custom-unlisted-endpoint"
|
|
);
|
|
}
|
|
}
|