Files
litellm/tests/test_litellm/proxy/agent_endpoints/test_databricks_oauth.py
T
Mateo Wang 778a7f752d Support OAuth M2M for Databricks Apps A2A agents (#29586)
* Add OAuth M2M support for A2A agents targeting Databricks Apps

Databricks App endpoints reject static bearer tokens and require a
short-lived OAuth token minted via the workspace OIDC token endpoint.
A2A agents could previously only authenticate outbound with static_headers
or client header passthrough, so Databricks App agents could not be
registered.

Agents configured with a databricks_oauth block in litellm_params now mint
and cache a client_credentials token and attach it as the outbound
Authorization header on both message/send and message/stream calls,
overriding any statically configured Authorization.

* Add tests covering Databricks App OAuth token error paths

Cover the HTTP status error, transport error, non-object JSON body, and
invalid expires_in fallback branches in the token cache so the failure
handling is locked in by regression tests.

* Harden Databricks App OAuth token cache

Cap the cache TTL at the token's own lifetime so a token whose validity is
shorter than the refresh buffer is never cached and served stale; include a
digest of client_secret in the cache key so a rotated secret mints a fresh
token instead of reusing the old one; and prune the per-key lock when its
cached token is evicted so the lock map stays bounded by the live key set.

* Clear per-key locks on Databricks OAuth cache flush

* fix(a2a/databricks): mint OAuth token via Basic auth header, not unsupported auth= kwarg

litellm's AsyncHTTPHandler.post (what get_async_httpx_client returns) has no
auth parameter, so minting a Databricks App OAuth token raised
"AsyncHTTPHandler.post() got an unexpected keyword argument 'auth'" before any
network call ever left the proxy, breaking the feature end to end. The handler
also calls raise_for_status() internally and re-raises a MaskedHTTPStatusError
(a subclass of httpx.HTTPStatusError), so the explicit raise_for_status() after
post() was dead code.

Build the HTTP Basic Authorization header by hand and pass it via headers, which
is what the Databricks workspace OIDC token endpoint documents for client
authentication. The token-cache tests now model the real handler contract with
create_autospec so the rejected auth= signature is enforced; the previous mocks
accepted any kwargs and silently hid the bug.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* Prune Databricks OAuth lock on the short-lived-token path

When expires_in is below the refresh buffer the token is intentionally
not cached, so _remove_key never runs for that key and the per-key lock
created by _get_lock leaked permanently. Drop the lock in that branch so
_locks stays bounded by the live key set, and assert the cleanup in the
short-lived-token test

* Gate A2A Databricks OAuth on the databricks_oauth block at the call site

Make the gating explicit where the header is applied so it is clear that only
agents configured with a databricks_oauth block enter the OAuth path; every
other agent is left untouched. Add a regression test asserting a non-Databricks
agent never invokes the token resolver.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
2026-06-04 23:03:37 -07:00

497 lines
16 KiB
Python

"""
Unit tests for Databricks App OAuth M2M support for A2A agents.
Covers config parsing (including os.environ/ resolution and validation),
workspace token-URL construction, client_credentials token fetching, caching
with expiry buffering, and the public ``resolve_databricks_app_auth_header``
helper.
"""
import base64
from unittest.mock import MagicMock, create_autospec, patch
import httpx
import pytest
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy.agent_endpoints.databricks_oauth import (
DatabricksAppOAuthConfig,
DatabricksAppOAuthTokenCache,
parse_databricks_oauth_config,
resolve_databricks_app_auth_header,
)
def _expected_basic_auth(client_id: str, client_secret: str) -> str:
token = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
return f"Basic {token}"
def _mock_http_handler(access_token="tok-abc", expires_in=3600, post_error=None):
"""Return a mock that mirrors litellm's ``AsyncHTTPHandler`` contract.
Two properties of the real handler matter for these tests and were the
source of a runtime bug the original suite missed:
1. ``post`` does not accept an ``auth`` kwarg. ``create_autospec`` enforces
the real signature, so reintroducing HTTP Basic via ``auth=`` fails with
``TypeError`` instead of silently passing.
2. ``post`` calls ``raise_for_status`` internally and raises
``httpx.HTTPStatusError`` itself on non-2xx; callers never inspect the
returned response's status. Error-path tests therefore raise from
``post`` rather than from ``response.raise_for_status``.
"""
handler = create_autospec(AsyncHTTPHandler, instance=True)
if post_error is not None:
handler.post.side_effect = post_error
else:
response = MagicMock()
response.json = MagicMock(
return_value={"access_token": access_token, "expires_in": expires_in}
)
handler.post.return_value = response
return handler
# ---------------------------------------------------------------------------
# Config parsing
# ---------------------------------------------------------------------------
def test_parse_returns_none_without_block():
assert parse_databricks_oauth_config(None) is None
assert parse_databricks_oauth_config({}) is None
assert parse_databricks_oauth_config({"other": "value"}) is None
def test_parse_builds_config_and_token_url():
config = parse_databricks_oauth_config(
{
"databricks_oauth": {
"client_id": "cid",
"client_secret": "secret",
"workspace_url": "https://dbc-abc.cloud.databricks.com",
}
}
)
assert config == DatabricksAppOAuthConfig(
client_id="cid",
client_secret="secret",
token_url="https://dbc-abc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
def test_parse_strips_serving_endpoints_and_trailing_slash():
config = parse_databricks_oauth_config(
{
"databricks_oauth": {
"client_id": "cid",
"client_secret": "secret",
"workspace_url": "https://dbc-abc.cloud.databricks.com/serving-endpoints/",
}
}
)
assert config is not None
assert config.token_url == "https://dbc-abc.cloud.databricks.com/oidc/v1/token"
def test_parse_custom_scope():
config = parse_databricks_oauth_config(
{
"databricks_oauth": {
"client_id": "cid",
"client_secret": "secret",
"workspace_url": "https://dbc-abc.cloud.databricks.com",
"scope": "custom-scope",
}
}
)
assert config is not None
assert config.scope == "custom-scope"
@pytest.mark.parametrize(
"missing_field", ["client_id", "client_secret", "workspace_url"]
)
def test_parse_raises_on_missing_field(missing_field):
block = {
"client_id": "cid",
"client_secret": "secret",
"workspace_url": "https://dbc-abc.cloud.databricks.com",
}
block.pop(missing_field)
with pytest.raises(ValueError, match=missing_field):
parse_databricks_oauth_config({"databricks_oauth": block})
def test_parse_raises_on_non_mapping_block():
with pytest.raises(ValueError, match="mapping"):
parse_databricks_oauth_config({"databricks_oauth": "not-a-dict"})
def test_parse_resolves_os_environ_references(monkeypatch):
monkeypatch.setenv("MY_DBX_CLIENT_ID", "env-cid")
monkeypatch.setenv("MY_DBX_SECRET", "env-secret")
config = parse_databricks_oauth_config(
{
"databricks_oauth": {
"client_id": "os.environ/MY_DBX_CLIENT_ID",
"client_secret": "os.environ/MY_DBX_SECRET",
"workspace_url": "https://dbc-abc.cloud.databricks.com",
}
}
)
assert config is not None
assert config.client_id == "env-cid"
assert config.client_secret == "env-secret"
# ---------------------------------------------------------------------------
# Token fetching + caching
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fetch_token_posts_client_credentials_with_basic_auth():
cache = DatabricksAppOAuthTokenCache()
config = DatabricksAppOAuthConfig(
client_id="cid",
client_secret="secret",
token_url="https://dbc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
client = _mock_http_handler(access_token="tok-1")
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
token = await cache.async_get_token(config)
assert token == "tok-1"
client.post.assert_awaited_once()
call = client.post.call_args
assert call.args[0] == config.token_url
assert call.kwargs["data"] == {
"grant_type": "client_credentials",
"scope": "all-apis",
}
# Databricks authenticates the client with HTTP Basic; it must be sent as a
# header because litellm's AsyncHTTPHandler.post has no ``auth`` parameter.
assert call.kwargs["headers"]["Authorization"] == _expected_basic_auth(
"cid", "secret"
)
assert "auth" not in call.kwargs
@pytest.mark.asyncio
async def test_token_is_cached_across_calls():
cache = DatabricksAppOAuthTokenCache()
config = DatabricksAppOAuthConfig(
client_id="cid",
client_secret="secret",
token_url="https://dbc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
client = _mock_http_handler(access_token="tok-cached")
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
first = await cache.async_get_token(config)
second = await cache.async_get_token(config)
assert first == second == "tok-cached"
client.post.assert_awaited_once()
@pytest.mark.asyncio
async def test_distinct_clients_do_not_share_token():
cache = DatabricksAppOAuthTokenCache()
config_a = DatabricksAppOAuthConfig(
client_id="cid-a",
client_secret="secret",
token_url="https://dbc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
config_b = DatabricksAppOAuthConfig(
client_id="cid-b",
client_secret="secret",
token_url="https://dbc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
clients = [_mock_http_handler("tok-a"), _mock_http_handler("tok-b")]
def _next_client(*args, **kwargs):
return clients.pop(0)
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
side_effect=_next_client,
):
token_a = await cache.async_get_token(config_a)
token_b = await cache.async_get_token(config_b)
assert token_a == "tok-a"
assert token_b == "tok-b"
@pytest.mark.asyncio
async def test_ttl_applies_expiry_buffer():
cache = DatabricksAppOAuthTokenCache()
config = DatabricksAppOAuthConfig(
client_id="cid",
client_secret="secret",
token_url="https://dbc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
client = _mock_http_handler(access_token="tok", expires_in=600)
captured = {}
real_set = cache.set_cache
def _spy_set(key, value, **kwargs):
captured["ttl"] = kwargs.get("ttl")
return real_set(key, value, **kwargs)
with (
patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
),
patch.object(cache, "set_cache", side_effect=_spy_set),
):
await cache.async_get_token(config)
assert captured["ttl"] == 600 - 60
@pytest.mark.asyncio
async def test_missing_access_token_raises():
cache = DatabricksAppOAuthTokenCache()
config = DatabricksAppOAuthConfig(
client_id="cid",
client_secret="secret",
token_url="https://dbc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
client = _mock_http_handler()
client.post.return_value.json.return_value = {"not_a_token": "x"}
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
with pytest.raises(ValueError, match="access_token"):
await cache.async_get_token(config)
def _config():
return DatabricksAppOAuthConfig(
client_id="cid",
client_secret="secret",
token_url="https://dbc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
@pytest.mark.asyncio
async def test_http_status_error_raises_value_error():
cache = DatabricksAppOAuthTokenCache()
request = httpx.Request("POST", _config().token_url)
error_response = httpx.Response(status_code=401, request=request)
client = _mock_http_handler(
post_error=httpx.HTTPStatusError(
"unauthorized", request=request, response=error_response
)
)
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
with pytest.raises(ValueError, match="status 401"):
await cache.async_get_token(_config())
@pytest.mark.asyncio
async def test_transport_error_raises_value_error():
cache = DatabricksAppOAuthTokenCache()
client = _mock_http_handler(post_error=httpx.ConnectError("boom"))
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
with pytest.raises(ValueError, match="token request failed"):
await cache.async_get_token(_config())
@pytest.mark.asyncio
async def test_non_object_json_body_raises():
cache = DatabricksAppOAuthTokenCache()
client = _mock_http_handler()
client.post.return_value.json.return_value = ["not", "an", "object"]
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
with pytest.raises(ValueError, match="non-object JSON"):
await cache.async_get_token(_config())
@pytest.mark.asyncio
@pytest.mark.parametrize("expires_in", [None, "not-a-number"])
async def test_invalid_expires_in_falls_back_to_default_ttl(expires_in):
cache = DatabricksAppOAuthTokenCache()
client = _mock_http_handler(expires_in=expires_in)
captured = {}
real_set = cache.set_cache
def _spy_set(key, value, **kwargs):
captured["ttl"] = kwargs.get("ttl")
return real_set(key, value, **kwargs)
with (
patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
),
patch.object(cache, "set_cache", side_effect=_spy_set),
):
await cache.async_get_token(_config())
# default TTL (3600) minus the 60s expiry buffer
assert captured["ttl"] == 3600 - 60
@pytest.mark.asyncio
async def test_short_lived_token_not_cached():
"""A token whose lifetime is below the refresh buffer is never cached and
leaves no per-key lock behind."""
cache = DatabricksAppOAuthTokenCache()
config = _config()
client = _mock_http_handler(access_token="short", expires_in=30)
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
await cache.async_get_token(config)
await cache.async_get_token(config)
assert cache.get_cache(config.cache_key) is None
assert config.cache_key not in cache._locks
assert client.post.await_count == 2
@pytest.mark.asyncio
async def test_rotated_secret_forces_new_token():
"""Rotating client_secret changes the cache key so a fresh token is minted."""
cache = DatabricksAppOAuthTokenCache()
old = DatabricksAppOAuthConfig(
client_id="cid",
client_secret="old-secret",
token_url="https://dbc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
rotated = DatabricksAppOAuthConfig(
client_id="cid",
client_secret="new-secret",
token_url="https://dbc.cloud.databricks.com/oidc/v1/token",
scope="all-apis",
)
assert old.cache_key != rotated.cache_key
clients = [_mock_http_handler("old-token"), _mock_http_handler("new-token")]
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
side_effect=lambda *a, **k: clients.pop(0),
):
assert await cache.async_get_token(old) == "old-token"
assert await cache.async_get_token(rotated) == "new-token"
@pytest.mark.asyncio
async def test_lock_pruned_when_token_evicted():
"""The per-key lock is removed when its cached token is deleted/evicted."""
cache = DatabricksAppOAuthTokenCache()
config = _config()
client = _mock_http_handler("tok")
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
await cache.async_get_token(config)
assert config.cache_key in cache._locks
cache.delete_cache(config.cache_key)
assert config.cache_key not in cache._locks
@pytest.mark.asyncio
async def test_flush_cache_clears_locks():
"""flush_cache drops the per-key locks alongside the cached tokens."""
cache = DatabricksAppOAuthTokenCache()
config = _config()
client = _mock_http_handler("tok")
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
await cache.async_get_token(config)
assert config.cache_key in cache._locks
cache.flush_cache()
assert cache._locks == {}
assert cache.get_cache(config.cache_key) is None
# ---------------------------------------------------------------------------
# Public helper
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_returns_none_when_not_configured():
assert await resolve_databricks_app_auth_header(None) is None
assert await resolve_databricks_app_auth_header({"foo": "bar"}) is None
@pytest.mark.asyncio
async def test_resolve_returns_bearer_header():
from litellm.proxy.agent_endpoints.databricks_oauth import (
databricks_app_oauth_token_cache,
)
databricks_app_oauth_token_cache.flush_cache()
litellm_params = {
"databricks_oauth": {
"client_id": "resolve-cid",
"client_secret": "secret",
"workspace_url": "https://resolve.cloud.databricks.com",
}
}
client = _mock_http_handler(access_token="resolved-token")
with patch(
"litellm.proxy.agent_endpoints.databricks_oauth.get_async_httpx_client",
return_value=client,
):
header = await resolve_databricks_app_auth_header(litellm_params)
assert header == {"Authorization": "Bearer resolved-token"}