This commit is contained in:
Nate Armstrong 2026-10-19 03:52:46 +00:00 • committed by GitHub
commit 27394d8151
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 291 additions and 3 deletions

View file

@ -22,6 +22,8 @@ pub enum LlmProviders {
BedrockMantle,
#[strum(serialize = "cohere")]
Cohere,
#[strum(serialize = "databricks")]
Databricks,
#[strum(serialize = "deepseek")]
Deepseek,
#[strum(serialize = "edenai")]

View file

@ -5,6 +5,7 @@ use litellm_host::interceptors::{ProviderIdentity, RequestContext};
use litellm_llms::{
azure::responses::transformation::AZURE_RESPONSES_CONFIG,
base_llm::responses::transformation::BaseResponsesApiConfig,
databricks::responses::transformation::DATABRICKS_RESPONSES_CONFIG,
fireworks_ai::responses::transformation::FIREWORKS_AI_RESPONSES_CONFIG,
hosted_vllm::responses::transformation::HOSTED_VLLM_RESPONSES_CONFIG,
openai::responses::transformation::OpenAiResponsesApiConfig,
@ -99,6 +100,7 @@ pub(super) fn resolve_provider(
Ok(LlmProviders::Openrouter) => (&OPENROUTER_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::Azure) => (&AZURE_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::Xai) => (&XAI_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::Databricks) => (&DATABRICKS_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::FireworksAi) => (&FIREWORKS_AI_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::HostedVllm) => (&HOSTED_VLLM_RESPONSES_CONFIG, resolved.into()),
_ => return Err(Error::Unsupported("native HTTP responses provider")),
@ -150,6 +152,12 @@ mod tests {
#[case::openrouter_native_id_implicit("openrouter/auto", None, "openrouter", "auto")]
#[case::azure_prefix("azure/my-deployment", None, "azure", "my-deployment")]
#[case::azure_explicit("my-deployment", Some("azure"), "azure", "my-deployment")]
#[case::databricks_prefix(
"databricks/databricks-gpt-5-nano",
None,
"databricks",
"databricks-gpt-5-nano"
)]
#[case::fireworks_prefix(
"fireworks_ai/accounts/fireworks/models/qwen3",
None,

View file

@ -58,7 +58,7 @@
- `AnthropicConfig`, `AmazonConverseConfig` and the non-OCR base traits are partial. Preserve their acceptance gates, passthrough and host fallback contracts
- `OpenAiResponsesApiConfig` implements WebSocket transformations and direct HTTP Responses, without Python's model-specific param rewriting or Responses-to-Chat emulation
- OpenAI-compatible Responses hosts (`openrouter`, `azure`, `xai`, `hosted_vllm`, `fireworks_ai`) reuse the OpenAI request and response transforms as free functions and own only their credentials, URL and host-specific item rules
- OpenAI-compatible Responses hosts (`openrouter`, `azure`, `xai`, `hosted_vllm`, `fireworks_ai`, `databricks`) reuse the OpenAI request and response transforms as free functions and own only their credentials, URL and host-specific item rules
- Chat `supported_openai_param_mappings` lists OpenAI/provider name pairs, unlike Python's name-only list. Audio `map_transcription_params` is a Rust filtering helper
- Auth resolution is split between configs and route preparation in `inference-<format>`

View file

@ -0,0 +1 @@
pub mod responses;

View file

@ -0,0 +1,10 @@
# rules
- Send the OpenAI Responses payload to `{api_base}/responses`, where the base is the workspace `/serving-endpoints` URL from `api_base` or `DATABRICKS_API_BASE`
- Authenticate with the personal access token from `api_key` or `DATABRICKS_API_KEY`. OAuth M2M and Databricks SDK auth stay on Python
- Only OpenAI GPT endpoints serve Responses; other models take Python's Chat Completions bridge
- No native WebSocket mode
# references
- https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/api-reference

View file

@ -0,0 +1 @@
pub mod transformation;

View file

@ -0,0 +1,177 @@
//! Databricks Responses for its OpenAI GPT serving endpoints, matching
//! `litellm/llms/databricks/responses/transformation.py`
use litellm_auth::{CredentialPlacement, SecretValue};
use litellm_llms_types::formats::responses::ResponsesApiResponse;
use serde_json::{Map, Value};
use crate::{
Error,
base_llm::{
auth::{AuthScheme, ValidatedEnvironment},
responses::transformation::BaseResponsesApiConfig,
},
openai::responses::{
request::ResponsesHost,
transformation::{responses_request, responses_response},
},
};
const API_KEY_ENV: &str = "DATABRICKS_API_KEY";
const API_BASE_ENV: &str = "DATABRICKS_API_BASE";
pub struct DatabricksResponsesApiConfig;
pub const DATABRICKS_RESPONSES_CONFIG: DatabricksResponsesApiConfig = DatabricksResponsesApiConfig;
impl BaseResponsesApiConfig for DatabricksResponsesApiConfig {
fn secret_names(
&self,
api_key: Option<&str>,
api_base: Option<&str>,
) -> &'static [&'static str] {
match (
api_key.is_some_and(|key| !key.is_empty()),
api_base.is_some_and(|base| !base.is_empty()),
) {
(true, true) => &[],
(true, false) => &[API_BASE_ENV],
(false, true) => &[API_KEY_ENV],
(false, false) => &[API_KEY_ENV, API_BASE_ENV],
}
}
fn validate_environment(
&self,
headers: Vec<(String, String)>,
api_key: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<ValidatedEnvironment, Error> {
let key =
resolved(api_key, lookup, API_KEY_ENV).ok_or(litellm_auth::Error::MissingApiKey {
provider: "Databricks",
environment_variable: API_KEY_ENV,
})?;
Ok(ValidatedEnvironment {
headers: litellm_http::request::with_default_headers(
headers,
&[("content-type", "application/json")],
),
auth: AuthScheme::Credential {
placement: CredentialPlacement::Bearer,
secret: SecretValue::new(key),
},
})
}
fn get_complete_url(
&self,
api_base: Option<&str>,
_api_version: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<String, Error> {
let api_base = resolved(api_base, lookup, API_BASE_ENV).ok_or(
litellm_auth::Error::MissingApiBase {
provider: "Databricks",
guidance: "Set `api_base` or the DATABRICKS_API_BASE environment variable",
},
)?;
Ok(format!("{}/responses", api_base.trim_end_matches('/')))
}
fn transform_responses_api_request(
&self,
model: &str,
input: Value,
params: Map<String, Value>,
) -> Result<Value, Error> {
let model = model.strip_prefix("databricks/").unwrap_or(model);
responses_request(model, input, params, ResponsesHost::Compatible)
}
fn transform_response_api_response(&self, body: Value) -> Result<ResponsesApiResponse, Error> {
responses_response(body)
}
}
/// Python's `value or os.getenv(name)`: an empty value falls through, and nothing is trimmed
fn resolved(
value: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
name: &str,
) -> Option<String> {
value
.filter(|value| !value.is_empty())
.map(str::to_owned)
.or_else(|| lookup(name).filter(|value| !value.is_empty()))
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use serde_json::json;
use super::*;
fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
move |name| {
pairs
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_owned())
}
}
#[rstest]
#[case::explicit(Some("https://w.cloud.databricks.com/serving-endpoints/"), &[], "https://w.cloud.databricks.com/serving-endpoints/responses")]
#[case::secret(None, &[("DATABRICKS_API_BASE", "https://s.cloud.databricks.com/serving-endpoints")], "https://s.cloud.databricks.com/serving-endpoints/responses")]
fn url_is_the_serving_endpoints_base_plus_responses(
#[case] api_base: Option<&str>,
#[case] secrets: &'static [(&'static str, &'static str)],
#[case] expected: &str,
) {
assert_eq!(
DATABRICKS_RESPONSES_CONFIG
.get_complete_url(api_base, None, &env(secrets))
.unwrap(),
expected
);
}
#[test]
fn url_needs_an_api_base() {
assert!(matches!(
DATABRICKS_RESPONSES_CONFIG.get_complete_url(None, None, &env(&[])),
Err(Error::Auth(litellm_auth::Error::MissingApiBase { .. }))
));
}
#[rstest]
#[case::explicit(Some("dapi-explicit"), &[("DATABRICKS_API_KEY", "dapi-env")], "dapi-explicit")]
#[case::secret(None, &[("DATABRICKS_API_KEY", "dapi-env")], "dapi-env")]
fn bearer_is_the_personal_access_token(
#[case] api_key: Option<&str>,
#[case] secrets: &'static [(&'static str, &'static str)],
#[case] expected: &str,
) {
let environment = DATABRICKS_RESPONSES_CONFIG
.validate_environment(vec![], api_key, &env(secrets))
.unwrap();
let AuthScheme::Credential { placement, secret } = environment.auth else {
panic!("expected a credential");
};
assert_eq!(placement, CredentialPlacement::Bearer);
assert_eq!(secret.expose(), expected);
}
#[test]
fn a_repeated_provider_prefix_is_stripped() {
let body = DATABRICKS_RESPONSES_CONFIG
.transform_responses_api_request(
"databricks/databricks-gpt-5-nano",
json!("hi"),
Map::new(),
)
.unwrap();
assert_eq!(body["model"], json!("databricks-gpt-5-nano"));
}
}

View file

@ -6,6 +6,7 @@ pub mod base_llm;
pub mod bedrock;
pub mod bedrock_mantle;
pub mod cohere;
pub mod databricks;
pub mod deepseek;
pub mod edenai;
mod error;

View file

@ -47,7 +47,7 @@ fn run_responses(py: Python<'_>, call: NativeCall<'_>, asynchronous: bool) -> Py
None => !model.contains('/'),
Some(resolved) => match resolved.custom_llm_provider {
"openai" => !resolved.model.contains('/'),
"azure" | "xai" | "hosted_vllm" | "fireworks_ai" => true,
"azure" | "xai" | "hosted_vllm" | "fireworks_ai" | "databricks" => true,
_ => false,
},
};

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import os
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final, cast # noqa: TID251 # narrows caller json the checker cannot see into
@ -7,6 +8,7 @@ from typing import Final, cast # noqa: TID251 # narrows caller json the checke
import litellm
from litellm import get_llm_provider
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
from litellm.llms.databricks.responses.transformation import DatabricksResponsesAPIConfig
from litellm.llms.fireworks_ai.common_utils import get_fireworks_session_id
from litellm.llms.fireworks_ai.responses.transformation import FireworksAIResponsesAPIConfig
from litellm.llms.hosted_vllm.responses.transformation import HostedVLLMResponsesAPIConfig
@ -27,6 +29,7 @@ _CONFIGS: Final[Mapping[str, OpenAIResponsesAPIConfig]] = MappingProxyType(
"xai": XAIResponsesAPIConfig(),
"hosted_vllm": HostedVLLMResponsesAPIConfig(),
"fireworks_ai": FireworksAIResponsesAPIConfig(),
"databricks": DatabricksResponsesAPIConfig(),
}
)
_AZURE_KEY_ENVIRONMENT: Final = ("AZURE_OPENAI_API_KEY", "AZURE_API_KEY")
@ -37,6 +40,8 @@ def body_params(request: Mapping[str, object]) -> Mapping[str, object]:
def connection_defaults(provider: str) -> tuple[str | None, str | None]:
if provider == "databricks":
return None, None
provider_key: Final = {"openai": litellm.openai_key, "azure": litellm.azure_key}.get(provider)
return litellm.api_key or provider_key, litellm.api_base
@ -92,6 +97,8 @@ def decline_reason(request: Mapping[str, object]) -> str | None:
def _provider_decline_reason(provider: str, model: str, request: Mapping[str, object]) -> str | None:
if provider == "azure":
return _azure_decline_reason(model, request)
if provider == "databricks":
return _databricks_decline_reason(model, request)
if provider not in ("xai", "hosted_vllm", "fireworks_ai"):
return None
provider_key, provider_base = provider_connection(
@ -111,6 +118,23 @@ def _provider_decline_reason(provider: str, model: str, request: Mapping[str, ob
return None
def _databricks_decline_reason(model: str, request: Mapping[str, object]) -> str | None:
if "gpt" not in model.lower():
return "Databricks serves Responses only on GPT endpoints"
api_base: Final = (
optional_str(request.get("api_base"))
or optional_str(request.get("base_url"))
or os.getenv("DATABRICKS_API_BASE")
)
if not api_base:
return "native Databricks Responses does not implement Databricks SDK auth"
if os.getenv("DATABRICKS_CLIENT_ID") and os.getenv("DATABRICKS_CLIENT_SECRET"):
return "native Databricks Responses does not implement OAuth M2M"
if not (optional_str(request.get("api_key")) or os.getenv("DATABRICKS_API_KEY")):
return "native Databricks Responses does not implement Databricks SDK auth"
return None
def _azure_decline_reason(model: str, request: Mapping[str, object]) -> str | None:
if "o_series/" in model:
return "native Azure Responses does not implement the o-series config"

View file

@ -25,7 +25,14 @@ pytestmark = pytest.mark.requires_rust_extension
MODEL: Final = "openai/gpt-6.1-sol"
_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_LOGGED: Final = TypeAdapter(dict[str, object])
_COMPARED_HEADERS: Final = ("api-key", "authorization", "content-type", "openai-organization", "openai-project")
_COMPARED_HEADERS: Final = (
"api-key",
"authorization",
"content-type",
"openai-organization",
"openai-project",
"user-agent",
)
_COMPARED_LOG_FIELDS: Final = (
"call_type",
"custom_llm_provider",
@ -374,6 +381,7 @@ CASES: Final = (
{"model": FIREWORKS_MODEL, "input": "hi", "litellm_metadata": {"session_id": "sess-1"}},
falls_back=True,
),
Case("databricks_gpt_endpoint", {"model": "databricks/databricks-gpt-5-nano", "input": "hi"}),
Case("hosted_vllm_placeholder_key", {"model": HOSTED_VLLM_MODEL, "input": "hi", "api_key": None}),
Case(
"hosted_vllm_keeps_replayed_reasoning_and_ids",

View file

@ -260,3 +260,59 @@ def test_openai_file_search_stays_native() -> None:
"tools": [{"type": "file_search", "vector_store_ids": ["vs_1"]}],
}
assert decline_reason(request) is None
@pytest.mark.parametrize(
("request_fields", "environment", "expected"),
[
pytest.param({}, {}, None, id="personal_access_token"),
pytest.param(
{"model": "databricks/databricks-claude-sonnet"},
{},
"Databricks serves Responses only on GPT endpoints",
id="non_gpt_endpoint",
),
pytest.param(
{"api_key": None},
{},
"native Databricks Responses does not implement Databricks SDK auth",
id="no_token",
),
pytest.param(
{"api_key": None, "api_base": None},
{"DATABRICKS_API_KEY": "dapi-env"},
"native Databricks Responses does not implement Databricks SDK auth",
id="no_api_base",
),
pytest.param(
{},
{"DATABRICKS_CLIENT_ID": "id", "DATABRICKS_CLIENT_SECRET": "secret"},
"native Databricks Responses does not implement OAuth M2M",
id="oauth_m2m",
),
],
)
def test_databricks_requests_outside_the_native_config_stay_on_python(
monkeypatch: pytest.MonkeyPatch,
request_fields: dict[str, object],
environment: dict[str, str],
expected: str | None,
) -> None:
for name in ("DATABRICKS_API_KEY", "DATABRICKS_API_BASE", "DATABRICKS_CLIENT_ID", "DATABRICKS_CLIENT_SECRET"):
monkeypatch.delenv(name, raising=False)
for name, value in environment.items():
monkeypatch.setenv(name, value)
request: Final = {
"model": "databricks/databricks-gpt-5-nano",
"input": "hi",
"api_key": "dapi-explicit",
"api_base": "https://w.cloud.databricks.com/serving-endpoints",
**request_fields,
}
assert decline_reason(request) == expected
def test_databricks_connection_defaults_ignore_the_global_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "api_key", "global")
monkeypatch.setattr(litellm, "api_base", "https://global.invalid")
assert connection_defaults("databricks") == (None, None)