mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-11 15:05:47 +00:00
778a7f752d
* 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>
497 lines
16 KiB
Python
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"}
|