use crate::formats::openai::{ extract_reasoning_effort, is_openai_responses_model, openai_reasoning_effort_for_thinking, }; use crate::images::ImageFormat; use anyhow::Result; use async_trait::async_trait; use serde_json::Value; use std::collections::HashSet; use std::sync::LazyLock; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use crate::api_client::{ApiClient, AuthMethod, TlsConfig}; use crate::base::{ConfigKey, MessageStream, ModelInfo, Provider, ProviderMetadata}; const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 600; use crate::conversation::message::Message; use crate::databricks_auth::{ DatabricksAuth, DatabricksAuthProvider, DatabricksOauthTokenProvider, DatabricksRefreshHook, DatabricksSessionIdProvider, DatabricksTokenResolver, }; use crate::errors::ProviderError; use crate::formats::databricks::create_request_for_provider; pub use crate::formats::databricks::DATABRICKS_PROVIDER_NAME; use crate::formats::openai_responses::create_responses_request; use crate::model::ModelConfig; use crate::openai_compatible::{ handle_status, map_http_error_to_provider_error, sanitize_url, stream_openai_compat, stream_responses_compat, }; use crate::request_log::{start_log, LoggerHandleExt}; use crate::retry::ProviderRetry; use crate::retry::{ RetryConfig, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_INITIAL_RETRY_INTERVAL_MS, DEFAULT_MAX_RETRIES, DEFAULT_MAX_RETRY_INTERVAL_MS, }; use rmcp::model::Tool; use serde_json::json; #[derive(Debug, Clone)] struct DatabricksEndpointInfo { name: String, upstream_model_name: Option, upstream_model_provider: Option, reasoning: Option, supports_responses_api: bool, } #[derive(Debug, Clone)] struct DatabricksUpstreamModel { name: String, provider: Option, } #[derive(Debug, Clone)] struct CachedDatabricksEndpointInfo { info: Option, fetched_at: Instant, } #[derive(Clone, Copy, PartialEq, Eq)] enum EndpointMetadataLookup { ContextDiscovery, InferenceRouting, } impl CachedDatabricksEndpointInfo { fn applies_to(&self, lookup: EndpointMetadataLookup) -> bool { self.fetched_at.elapsed() < Duration::from_secs(DATABRICKS_ENDPOINT_METADATA_TTL_SECS) && (lookup == EndpointMetadataLookup::ContextDiscovery || self.info.is_some()) } } const DATABRICKS_ENDPOINT_METADATA_TIMEOUT_SECS: u64 = 5; const DATABRICKS_ENDPOINT_METADATA_TTL_SECS: u64 = 60; static DATABRICKS_ENDPOINT_INFO_CACHE: LazyLock< Mutex>, > = LazyLock::new(|| Mutex::new(std::collections::HashMap::new())); pub const DATABRICKS_DEFAULT_MODEL: &str = "databricks-claude-sonnet-4"; pub const DATABRICKS_DEFAULT_FAST_MODEL: &str = "databricks-claude-haiku-4-5"; pub const DATABRICKS_KNOWN_MODELS: &[&str] = &[ "databricks-claude-sonnet-4-5", "databricks-meta-llama-3-3-70b-instruct", "databricks-meta-llama-3-1-405b-instruct", ]; pub const DATABRICKS_DOC_URL: &str = "https://docs.databricks.com/en/generative-ai/external-models/index.html"; #[derive(serde::Serialize)] pub struct DatabricksProvider { #[serde(skip)] api_client: ApiClient, #[serde(skip)] host: String, auth: DatabricksAuth, image_format: ImageFormat, #[serde(skip)] retry_config: RetryConfig, #[serde(skip)] name: String, #[serde(skip)] token_cache: Arc>>, #[serde(skip)] instance_id: Option, #[serde(skip)] refresh_hook: Option, #[serde(skip)] session_id_provider: Option, } impl DatabricksProvider { pub async fn cleanup() -> Result<()> { Ok(()) } #[allow(clippy::too_many_arguments)] pub fn new( host: String, auth: DatabricksAuth, retry_config: RetryConfig, tls_config: Option, oauth_token_provider: Option, token_resolver: Option, request_builder: Option, instance_id: Option, refresh_hook: Option, session_id_provider: Option, ) -> Result { let token_cache = Arc::new(Mutex::new(match &auth { DatabricksAuth::Token(t) => Some(t.clone()), _ => None, })); let auth_method = AuthMethod::Custom(Box::new(DatabricksAuthProvider { auth: auth.clone(), token_cache: token_cache.clone(), oauth_token_provider, token_resolver, })); let mut api_client = ApiClient::with_timeout_and_tls( host.clone(), auth_method, Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), tls_config, )?; if let Some(request_builder) = request_builder { api_client = api_client.with_request_builder(request_builder); } Ok(Self { api_client, host, auth, image_format: ImageFormat::OpenAi, retry_config, name: DATABRICKS_PROVIDER_NAME.to_string(), token_cache, instance_id, refresh_hook, session_id_provider, }) } pub fn load_retry_config(get_param: impl Fn(&str) -> Option) -> RetryConfig { let max_retries = get_param("DATABRICKS_MAX_RETRIES") .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_MAX_RETRIES); let initial_interval_ms = get_param("DATABRICKS_INITIAL_RETRY_INTERVAL_MS") .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_INITIAL_RETRY_INTERVAL_MS); let backoff_multiplier = get_param("DATABRICKS_BACKOFF_MULTIPLIER") .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_BACKOFF_MULTIPLIER); let max_interval_ms = get_param("DATABRICKS_MAX_RETRY_INTERVAL_MS") .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_MAX_RETRY_INTERVAL_MS); RetryConfig::new( max_retries, initial_interval_ms, backoff_multiplier, max_interval_ms, ) } fn is_claude_model(model_name: &str) -> bool { model_name.to_lowercase().contains("claude") } fn is_reasoning_capable_model_name(model_name: &str) -> bool { Self::is_claude_model(model_name) || is_openai_responses_model(model_name) } fn uses_responses_api( endpoint_info: Option<&DatabricksEndpointInfo>, model_names: &[&str], ) -> bool { match endpoint_info { Some(info) => info.supports_responses_api, None => model_names .iter() .any(|name| is_openai_responses_model(name)), } } fn endpoint_model_candidates(value: &Value) -> Vec { let mut candidates: Vec = Vec::new(); fn get_string_at(value: &Value, path: &[&str]) -> Option { path.iter() .try_fold(value, |current, key| current.get(*key)) .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(ToString::to_string) } fn push_candidate( name: Option, provider: Option, candidates: &mut Vec, ) { if let Some(name) = name { if !candidates.iter().any(|candidate| candidate.name == name) { candidates.push(DatabricksUpstreamModel { name, provider }); } } } for config_key in ["config", "pending_config"] { let Some(config) = value.get(config_key) else { continue; }; for collection_key in ["served_entities", "served_models"] { let Some(entities) = config.get(collection_key).and_then(|v| v.as_array()) else { continue; }; for entity in entities { push_candidate( get_string_at(entity, &["external_model", "name"]), get_string_at(entity, &["external_model", "provider"]), &mut candidates, ); push_candidate( get_string_at(entity, &["foundation_model", "name"]), get_string_at(entity, &["foundation_model", "provider"]), &mut candidates, ); push_candidate( get_string_at(entity, &["entity_name"]), None, &mut candidates, ); } } } candidates } fn endpoint_info_from_value(endpoint: &Value) -> Option { let name = endpoint.get("name")?.as_str()?.to_string(); let supports_responses_api = Self::endpoint_supports_responses_api(endpoint); let upstream_model = Self::endpoint_model_candidates(endpoint) .into_iter() .find(|candidate| candidate.name != name); let upstream_model_name = upstream_model.as_ref().map(|model| model.name.clone()); let upstream_model_provider = upstream_model.and_then(|model| model.provider); let reasoning = upstream_model_name .as_deref() .map(Self::is_reasoning_capable_model_name) .or_else(|| Some(Self::is_reasoning_capable_model_name(&name))); Some(DatabricksEndpointInfo { name, upstream_model_name, upstream_model_provider, reasoning, supports_responses_api, }) } fn endpoint_supports_responses_api(endpoint: &Value) -> bool { fn value_contains_responses_api(value: &Value) -> bool { match value { Value::Object(map) => { map.get("api_types") .and_then(|api_types| api_types.as_array()) .is_some_and(|api_types| { api_types .iter() .any(|api_type| api_type.as_str() == Some("openai/v1/responses")) }) || map.values().any(value_contains_responses_api) } Value::Array(values) => values.iter().any(value_contains_responses_api), _ => false, } } let Some(config) = endpoint.get("config") else { return false; }; for collection_key in ["served_entities", "served_models"] { let Some(entities) = config.get(collection_key).and_then(|v| v.as_array()) else { continue; }; if entities.iter().any(value_contains_responses_api) { return true; } } false } async fn fetch_endpoint_info( &self, endpoint_name: &str, ) -> Result { let response = self .api_client .request(&format!( "api/2.0/serving-endpoints/{}", urlencoding::encode(endpoint_name) )) .response_get() .await .map_err(|e| { ProviderError::RequestFailed(format!( "Failed to fetch Databricks endpoint metadata: {}", e )) })?; if !response.status().is_success() { let status = response.status(); let detail = response.text().await.unwrap_or_default(); return Err(ProviderError::RequestFailed(format!( "Failed to fetch Databricks endpoint metadata: {} {}", status, detail ))); } let json: Value = response.json().await.map_err(|e| { ProviderError::RequestFailed(format!( "Failed to parse Databricks endpoint metadata: {}", e )) })?; Self::endpoint_info_from_value(&json).ok_or_else(|| { ProviderError::RequestFailed( "Unexpected response format from Databricks endpoint metadata".to_string(), ) }) } async fn resolve_endpoint_info( &self, endpoint_name: &str, ) -> Result { const MAX_MODEL_SERVING_HOPS: usize = 4; let original_endpoint_name = endpoint_name.to_string(); let mut current_endpoint_name = endpoint_name.to_string(); let mut visited = HashSet::new(); let mut last_info: Option = None; let mut first_hop_supports_responses_api: Option = None; for _ in 0..MAX_MODEL_SERVING_HOPS { if !visited.insert(current_endpoint_name.clone()) { break; } let info = self.fetch_endpoint_info(¤t_endpoint_name).await?; let supports_responses_api = *first_hop_supports_responses_api.get_or_insert(info.supports_responses_api); let next_endpoint_name = match ( info.upstream_model_provider.as_deref(), info.upstream_model_name.as_deref(), ) { (Some("databricks-model-serving"), Some(next_endpoint_name)) if !visited.contains(next_endpoint_name) => { Some(next_endpoint_name.to_string()) } _ => None, }; if let Some(next_endpoint_name) = next_endpoint_name { last_info = Some(info); current_endpoint_name = next_endpoint_name; continue; } let mut resolved_info = if info.name == original_endpoint_name { info } else { let upstream_model_name = info .upstream_model_name .clone() .or_else(|| Some(info.name.clone())); DatabricksEndpointInfo { name: original_endpoint_name, upstream_model_name, upstream_model_provider: info.upstream_model_provider.clone(), reasoning: info.reasoning, supports_responses_api, } }; resolved_info.supports_responses_api = supports_responses_api; return Ok(resolved_info); } last_info .map(|info| DatabricksEndpointInfo { name: original_endpoint_name, upstream_model_name: info.upstream_model_name, upstream_model_provider: info.upstream_model_provider, reasoning: info.reasoning, supports_responses_api: first_hop_supports_responses_api.unwrap_or(false), }) .ok_or_else(|| { ProviderError::RequestFailed( "Failed to resolve Databricks endpoint metadata".to_string(), ) }) } async fn resolve_endpoint_info_cached( &self, endpoint_name: &str, lookup: EndpointMetadataLookup, ) -> Result { let cache_key = format!("{}:{}", self.host, endpoint_name); let cached = DATABRICKS_ENDPOINT_INFO_CACHE .lock() .unwrap() .get(&cache_key) .cloned(); if let Some(cached) = cached { if cached.applies_to(lookup) { return cached.info.ok_or_else(|| { ProviderError::RequestFailed( "Databricks endpoint metadata is unavailable".to_string(), ) }); } } let info = tokio::time::timeout( Duration::from_secs(DATABRICKS_ENDPOINT_METADATA_TIMEOUT_SECS), self.resolve_endpoint_info(endpoint_name), ) .await .ok() .and_then(Result::ok); DATABRICKS_ENDPOINT_INFO_CACHE.lock().unwrap().insert( cache_key, CachedDatabricksEndpointInfo { info: info.clone(), fetched_at: Instant::now(), }, ); info.ok_or_else(|| { ProviderError::RequestFailed("Databricks endpoint metadata is unavailable".to_string()) }) } fn model_info_from_endpoint(info: DatabricksEndpointInfo) -> ModelInfo { let context_model = info.upstream_model_name.as_deref().unwrap_or(&info.name); let context_limit = crate::canonical::maybe_get_canonical_model(DATABRICKS_PROVIDER_NAME, context_model) .map(|model| model.limit.context); let reasoning = info .reasoning .unwrap_or_else(|| ModelConfig::new(context_model).is_reasoning_model()); ModelInfo { name: info.name, resolved_model: info.upstream_model_name, context_limit, input_token_cost: None, output_token_cost: None, currency: None, supports_cache_control: None, reasoning, thinking_preservation_format: None, request_params: None, } } fn get_endpoint_path(&self, model_name: &str, is_responses_model: bool) -> String { if is_responses_model { "serving-endpoints/responses".to_string() } else { let (clean_name, _) = extract_reasoning_effort(model_name); format!("serving-endpoints/{}/invocations", clean_name) } } fn build_client_request_id(&self, session_id: &str) -> Option { self.instance_id.as_ref().map(|instance_id| { json!({ "sessionId": format!("{}_{}", instance_id, session_id), }) .to_string() }) } } impl crate::base::ProviderDescriptor for DatabricksProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( DATABRICKS_PROVIDER_NAME, "Databricks", "Models on Databricks AI Gateway", DATABRICKS_DEFAULT_MODEL, DATABRICKS_KNOWN_MODELS.to_vec(), DATABRICKS_DOC_URL, vec![ ConfigKey::new("DATABRICKS_HOST", true, false, None, true), ConfigKey::new("DATABRICKS_TOKEN", false, true, None, true), ], ) .with_fast_model(DATABRICKS_DEFAULT_FAST_MODEL) } } #[async_trait] impl Provider for DatabricksProvider { fn get_name(&self) -> &str { &self.name } fn retry_config(&self) -> RetryConfig { self.retry_config.clone() } async fn get_context_limit(&self, model: &str, override_limit: Option) -> usize { crate::context_limit::ContextLimitResolver::new(self.get_name()) .resolve(model, override_limit, || async { self.fetch_model_info(model) .await .map(|info| info.context_limit) }) .await } async fn refresh_credentials(&self) -> Result<(), ProviderError> { if let Some(refresh_hook) = &self.refresh_hook { refresh_hook(); } *self.token_cache.lock().unwrap() = None; tracing::info!("Invalidated secrets cache and token cache for credential refresh"); Ok(()) } async fn stream( &self, model_config: &ModelConfig, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { let session_id = self .session_id_provider .as_ref() .and_then(|provider| provider()) .unwrap_or_default(); let (endpoint_name, _) = extract_reasoning_effort(&model_config.model_name); let endpoint_info = self .resolve_endpoint_info_cached(&endpoint_name, EndpointMetadataLookup::InferenceRouting) .await .ok(); let effective_model_name = endpoint_info .as_ref() .and_then(|info| info.upstream_model_name.as_deref()) .unwrap_or(&model_config.model_name); let is_responses_model = Self::uses_responses_api( endpoint_info.as_ref(), &[&model_config.model_name, effective_model_name], ); let path = if is_responses_model { "serving-endpoints/responses".to_string() } else { self.get_endpoint_path(&model_config.model_name, is_responses_model) }; let client_request_id = self.build_client_request_id(&session_id); if is_responses_model { let responses_model_config; let request_model_config = if effective_model_name != model_config.model_name { responses_model_config = { let mut config = model_config.clone(); config.model_name = effective_model_name.to_string(); config }; &responses_model_config } else { model_config }; let mut payload = create_responses_request(request_model_config, system, messages, tools)?; payload["model"] = Value::String(endpoint_name.clone()); if payload.get("reasoning").is_none() { if let Some(effort) = model_config.thinking_effort().and_then(|effort| { openai_reasoning_effort_for_thinking(effective_model_name, effort) }) { payload.as_object_mut().unwrap().insert( "reasoning".to_string(), json!({ "effort": effort, "summary": "auto", }), ); } } payload["stream"] = Value::Bool(true); if let Some(ref client_request_id) = client_request_id { payload["client_request_id"] = Value::String(client_request_id.clone()); } let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { let payload_clone = payload.clone(); let resp = self .api_client .request(&path) .model_headers(model_config)? .streaming(true) .response_post(&payload_clone) .await?; handle_status(resp).await }) .await .inspect_err(|e| { let _ = log.error(e); })?; stream_responses_compat(response, log) } else { let format_model_config; let request_model_config = if Self::is_claude_model(effective_model_name) && !Self::is_claude_model(&model_config.model_name) { format_model_config = { let mut config = model_config.clone(); config.model_name = effective_model_name.to_string(); config }; &format_model_config } else { model_config }; let mut payload = create_request_for_provider( DATABRICKS_PROVIDER_NAME, request_model_config, system, messages, tools, &self.image_format, )?; payload .as_object_mut() .expect("payload should have model key") .remove("model"); if let Some(client_request_id) = client_request_id { payload["client_request_id"] = Value::String(client_request_id); } payload .as_object_mut() .unwrap() .insert("stream".to_string(), Value::Bool(true)); if let Some(opts) = payload .get_mut("stream_options") .and_then(|v| v.as_object_mut()) { opts.entry("include_usage").or_insert(json!(true)); } else { payload .as_object_mut() .unwrap() .insert("stream_options".to_string(), json!({"include_usage": true})); } let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { let resp = self .api_client .request(&path) .model_headers(model_config)? .streaming(true) .response_post(&payload) .await?; if !resp.status().is_success() { let status = resp.status(); let url = sanitize_url(resp.url().as_str()); let error_text = crate::http_status::read_error_body(resp) .await .unwrap_or_default(); let json_payload = serde_json::from_str::(&error_text).ok(); return Err(map_http_error_to_provider_error(status, json_payload, &url)); } Ok(resp) }) .await; let response = match response { Err(e) if e.to_string().contains("stream_options") => { payload.as_object_mut().unwrap().remove("stream_options"); self.with_retry(|| async { let resp = self .api_client .request(&path) .model_headers(model_config)? .streaming(true) .response_post(&payload) .await?; if !resp.status().is_success() { let status = resp.status(); let url = sanitize_url(resp.url().as_str()); let error_text = crate::http_status::read_error_body(resp) .await .unwrap_or_default(); let json_payload = serde_json::from_str::(&error_text).ok(); return Err(map_http_error_to_provider_error( status, json_payload, &url, )); } Ok(resp) }) .await .inspect_err(|e| { let _ = log.error(e); })? } Err(e) => { let _ = log.error(&e); return Err(e); } Ok(resp) => resp, }; stream_openai_compat(response, log) } } async fn fetch_supported_models(&self) -> Result, ProviderError> { Ok(self .fetch_supported_model_info() .await? .into_iter() .map(|model| model.name) .collect()) } async fn fetch_supported_model_info(&self) -> Result, ProviderError> { let response = self .api_client .request("api/2.0/serving-endpoints") .response_get() .await .map_err(|e| { ProviderError::RequestFailed(format!("Failed to fetch Databricks models: {}", e)) })?; if !response.status().is_success() { let status = response.status(); let detail = response.text().await.unwrap_or_default(); return Err(ProviderError::RequestFailed(format!( "Failed to fetch Databricks models: {} {}", status, detail ))); } let json: Value = response.json().await.map_err(|e| { ProviderError::RequestFailed(format!("Failed to parse Databricks API response: {}", e)) })?; let endpoints = json .get("endpoints") .and_then(|v| v.as_array()) .ok_or_else(|| { ProviderError::RequestFailed( "Unexpected response format from Databricks API: missing 'endpoints' array" .to_string(), ) })?; let mut models = Vec::new(); for endpoint in endpoints { if let Some(endpoint_info) = Self::endpoint_info_from_value(endpoint) { models.push(Self::model_info_from_endpoint(endpoint_info)); } } Ok(models) } async fn fetch_model_info(&self, model_name: &str) -> Result { let (endpoint_name, _) = extract_reasoning_effort(model_name); let endpoint_info = self .resolve_endpoint_info_cached(&endpoint_name, EndpointMetadataLookup::ContextDiscovery) .await?; Ok(Self::model_info_from_endpoint(endpoint_info)) } async fn fetch_recommended_model_info( &self, _toolshim: bool, ) -> Result, ProviderError> { self.fetch_supported_model_info().await } } #[cfg(test)] mod tests { use super::*; #[test] fn routing_retries_cached_metadata_failures() { let cached = CachedDatabricksEndpointInfo { info: None, fetched_at: Instant::now(), }; assert!(cached.applies_to(EndpointMetadataLookup::ContextDiscovery)); assert!(!cached.applies_to(EndpointMetadataLookup::InferenceRouting)); } #[test] fn routing_reuses_cached_metadata_successes() { let cached = CachedDatabricksEndpointInfo { info: Some(DatabricksEndpointInfo { name: "production-chat".to_string(), upstream_model_name: Some("gpt-5".to_string()), upstream_model_provider: Some("openai".to_string()), reasoning: Some(true), supports_responses_api: true, }), fetched_at: Instant::now(), }; assert!(cached.applies_to(EndpointMetadataLookup::ContextDiscovery)); assert!(cached.applies_to(EndpointMetadataLookup::InferenceRouting)); } #[test] fn endpoint_metadata_marks_reasoning_alias_from_external_model() { let endpoint = json!({ "name": "goose", "config": { "served_entities": [{ "name": "current", "external_model": { "name": "claude-opus-4.6", "provider": "anthropic", "task": "llm/v1/chat" } }] } }); let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); assert_eq!(info.name, "goose"); assert_eq!(info.upstream_model_name.as_deref(), Some("claude-opus-4.6")); assert_eq!(info.reasoning, Some(true)); assert!(!info.supports_responses_api); let model_info = DatabricksProvider::model_info_from_endpoint(info); assert_eq!(model_info.name, "goose"); assert_eq!( model_info.resolved_model.as_deref(), Some("claude-opus-4.6") ); assert!(model_info.reasoning); } #[test] fn endpoint_metadata_captures_databricks_model_serving_hop() { let endpoint = json!({ "name": "goose", "config": { "served_entities": [{ "external_model": { "name": "databricks-claude-opus-4-6", "provider": "databricks-model-serving", "task": "llm/v1/chat" } }] } }); let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); assert_eq!(info.name, "goose"); assert_eq!( info.upstream_model_name.as_deref(), Some("databricks-claude-opus-4-6") ); assert_eq!( info.upstream_model_provider.as_deref(), Some("databricks-model-serving") ); assert_eq!(info.reasoning, Some(true)); } #[test] fn endpoint_metadata_marks_reasoning_alias_from_pending_gpt_model() { let endpoint = json!({ "name": "goose", "pending_config": { "served_entities": [{ "external_model": { "name": "gpt-5.5", "provider": "openai", "task": "llm/v1/chat" } }] } }); let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); assert_eq!(info.name, "goose"); assert_eq!(info.upstream_model_name.as_deref(), Some("gpt-5.5")); assert_eq!(info.reasoning, Some(true)); } #[test] fn endpoint_metadata_uses_endpoint_name_when_no_upstream_model_exists() { let endpoint = json!({ "name": "goose-gpt-5-5" }); let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); assert_eq!(info.name, "goose-gpt-5-5"); assert_eq!(info.upstream_model_name, None); assert_eq!(info.reasoning, Some(true)); assert!(!info.supports_responses_api); } #[test] fn endpoint_metadata_detects_responses_api_from_foundation_model_api_types() { let endpoint = json!({ "name": "databricks-gpt-5-4", "config": { "served_entities": [{ "name": "databricks-gpt-5-4", "entity_name": "system.ai.databricks-gpt-5-4", "type": "FOUNDATION_MODEL", "foundation_model": { "name": "system.ai.databricks-gpt-5-4", "display_name": "GPT-5.4", "api_types": [ "mlflow/v1/chat/completions", "openai/v1/responses", "cursor/v1/chat/completions" ] } }] } }); let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); assert_eq!(info.name, "databricks-gpt-5-4"); assert_eq!( info.upstream_model_name.as_deref(), Some("system.ai.databricks-gpt-5-4") ); assert!(info.supports_responses_api); } #[test] fn endpoint_metadata_detects_responses_api_from_served_models() { let endpoint = json!({ "name": "databricks-gpt-5-4", "config": { "served_models": [{ "foundation_model": { "api_types": ["openai/v1/responses"] } }] } }); let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); assert!(info.supports_responses_api); } #[test] fn endpoint_metadata_ignores_pending_config_for_responses_routing() { let endpoint = json!({ "name": "databricks-gpt-5-4", "config": { "served_entities": [{ "foundation_model": { "api_types": ["mlflow/v1/chat/completions"] } }] }, "pending_config": { "served_entities": [{ "foundation_model": { "api_types": ["openai/v1/responses"] } }] } }); let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); assert!(!info.supports_responses_api); } #[test] fn responses_routing_prefers_metadata_over_model_name() { let responses_info = DatabricksEndpointInfo { name: "custom".into(), upstream_model_name: None, upstream_model_provider: None, reasoning: None, supports_responses_api: true, }; assert!(DatabricksProvider::uses_responses_api( Some(&responses_info), &["databricks-claude-sonnet-4"] )); let chat_info = DatabricksEndpointInfo { supports_responses_api: false, ..responses_info }; assert!(!DatabricksProvider::uses_responses_api( Some(&chat_info), &["gpt-5.4"] )); } #[test] fn responses_routing_falls_back_to_model_name_without_metadata() { assert!(DatabricksProvider::uses_responses_api(None, &["gpt-5.4"])); assert!(DatabricksProvider::uses_responses_api( None, &["databricks-claude-sonnet-4", "gpt-5.4"] )); assert!(!DatabricksProvider::uses_responses_api( None, &["databricks-claude-sonnet-4"] )); } }