omnigent-ai--omnigent
1027 行
41 KiB
Python
1027 行
41 KiB
Python
"""DatabricksExecutor: real LLM execution via the Databricks FM API.
|
|
|
|
Uses the OpenAI-compatible chat completions API served by Databricks
|
|
Model Serving. Works with any model hosted on the serving endpoint
|
|
(Claude, Llama, DBRX, etc.).
|
|
|
|
Environment:
|
|
DATABRICKS_CONFIG_PROFILE – optional Databricks profile selector
|
|
~/.databrickscfg – host + token profile for Databricks access
|
|
(or)
|
|
OPENAI_API_KEY + OPENAI_BASE_URL – direct override for any OpenAI-compatible
|
|
endpoint (useful for local dev / testing)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
from collections.abc import AsyncIterator, Generator
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Any, TypeAlias
|
|
|
|
import httpx
|
|
|
|
if TYPE_CHECKING:
|
|
from openai import OpenAI, Stream
|
|
from openai.types.chat import ChatCompletionChunk
|
|
|
|
from .async_utils import run_sync_on_thread
|
|
from .executor import (
|
|
Executor,
|
|
ExecutorConfig,
|
|
ExecutorError,
|
|
ExecutorEvent,
|
|
Message,
|
|
TextChunk,
|
|
ToolCallRequest,
|
|
ToolSpec,
|
|
TurnComplete,
|
|
iterate_blocking_stream,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# OpenAI chat.completions.create(**kwargs) builds up a heterogeneous
|
|
# request body dynamically from cfg.extra; the SDK accepts many typed
|
|
# parameters but the splat site needs an open dict.
|
|
OpenAIKwargs: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
|
|
|
|
_API_CALL_TIMEOUT_SECONDS = 30.0
|
|
_STREAM_IDLE_TIMEOUT_SECONDS = 60.0
|
|
|
|
_SESSION_ONLY_EXECUTOR_EXTRA_KEYS = {
|
|
"new_user_messages_flushed",
|
|
"stepwise_internal_turns",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class _DatabricksSessionState:
|
|
active_stream: Stream[ChatCompletionChunk] | None = None
|
|
interrupt_requested: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DatabricksCredentials:
|
|
"""Resolved Databricks workspace credentials.
|
|
|
|
``host`` is the workspace URL; ``token`` is a bearer usable as
|
|
``Authorization: Bearer <token>``. Callers that receive an instance
|
|
can rely on both fields being non-empty — the credential readers
|
|
return ``None`` when nothing is configured rather than a sentinel
|
|
object with empty strings.
|
|
"""
|
|
|
|
host: str
|
|
token: str
|
|
|
|
|
|
def _read_databrickscfg(profile: str | None = None) -> DatabricksCredentials | None:
|
|
"""
|
|
Resolve Databricks ``(host, bearer_token)`` for *profile* using the
|
|
databricks-sdk's unified credential resolver.
|
|
|
|
The SDK supports every ``auth_type`` shipped in ``~/.databrickscfg``
|
|
(``pat``, ``databricks-cli`` / OAuth-U2M, service-principal OAuth,
|
|
Azure CLI, env-OIDC, metadata-service, etc.) and always returns a
|
|
freshly-minted bearer — critically, for OAuth profiles it mints a
|
|
new access token rather than returning the stale ``token`` field
|
|
that the CLI may have left behind. Prior to this fix, the function
|
|
read the ``token`` field directly, which silently broke
|
|
``auth_type: databricks-cli`` profiles (every Databricks-backed
|
|
harness returned HTTP 403).
|
|
|
|
:param profile: Databricks config profile name. When ``None``, the SDK
|
|
itself honors the standard resolution order
|
|
(``DATABRICKS_CONFIG_PROFILE`` env var, then the ``DEFAULT``
|
|
section). Example values: ``None``, ``"DEFAULT"``, ``"<your-profile>"``.
|
|
When a named profile is given but cannot be found on the current
|
|
machine (e.g. the spec was authored locally with ``profile: myprofile``
|
|
but now runs on a Databricks App container with no
|
|
``~/.databrickscfg``), the function falls back to the ambient
|
|
credential chain (``DATABRICKS_HOST`` + ``DATABRICKS_TOKEN`` env
|
|
vars, metadata-service OIDC, etc.) before giving up.
|
|
:returns: :class:`DatabricksCredentials` with ``host`` (workspace URL,
|
|
e.g. ``"https://example.databricks.com"``) and
|
|
``token`` (bearer usable as ``Authorization: Bearer <token>``),
|
|
or ``None`` when no credentials are configured.
|
|
|
|
:raises: Never. SDK-level failures are swallowed and fall back to
|
|
the legacy file-reading path (``_read_databrickscfg_file_fallback``)
|
|
so exotic setups that predate the SDK's support matrix continue
|
|
to work for plain-PAT configurations.
|
|
|
|
.. note::
|
|
|
|
Executors that need per-request token refresh should use
|
|
:func:`_resolve_databricks_auth` instead, which returns an
|
|
httpx Auth callback backed by ``Config.authenticate()``.
|
|
This function still returns a static snapshot and is used
|
|
by non-executor callers that only need a one-shot credential
|
|
check.
|
|
"""
|
|
try:
|
|
from databricks.sdk.config import Config
|
|
except ImportError:
|
|
# databricks-sdk should always be present (pinned in pyproject.toml),
|
|
# but if it isn't we gracefully degrade to file reading.
|
|
return _read_databrickscfg_file_fallback(profile)
|
|
|
|
# ``None`` means "let the SDK decide" (env var / DEFAULT section).
|
|
sdk_profile = profile or os.environ.get("DATABRICKS_CONFIG_PROFILE")
|
|
try:
|
|
cfg = Config(profile=sdk_profile)
|
|
headers = cfg.authenticate()
|
|
except ValueError as profile_exc:
|
|
# ValueError is what Config raises for every user-facing resolution
|
|
# failure (missing profile, malformed file, no credentials in env,
|
|
# unknown auth_type, etc.). Anything else (e.g. network errors
|
|
# fetching OAuth tokens) should propagate.
|
|
logger.debug(
|
|
"databricks-sdk credential resolution failed for profile %r: %s",
|
|
sdk_profile,
|
|
profile_exc,
|
|
)
|
|
if sdk_profile is not None:
|
|
# Profile not found; fall back to ambient credentials
|
|
# (env vars, OIDC) so the spec works on App servers too.
|
|
logger.debug(
|
|
"profile %r not found; trying ambient Databricks credentials", sdk_profile
|
|
)
|
|
try:
|
|
cfg = Config()
|
|
headers = cfg.authenticate()
|
|
except ValueError:
|
|
return _read_databrickscfg_file_fallback(profile)
|
|
else:
|
|
return _read_databrickscfg_file_fallback(profile)
|
|
|
|
host = cfg.host
|
|
auth = headers.get("Authorization")
|
|
if not host or not auth or not auth.startswith("Bearer "):
|
|
# Non-Bearer auth schemes (e.g. Basic) or missing host/auth are
|
|
# treated as unresolved. None of our harnesses support non-Bearer
|
|
# today.
|
|
return None
|
|
return DatabricksCredentials(host=host, token=auth.removeprefix("Bearer "))
|
|
|
|
|
|
def _read_databrickscfg_file_fallback(profile: str | None = None) -> DatabricksCredentials | None:
|
|
"""
|
|
Legacy fallback: read ``host`` and ``token`` directly from
|
|
``~/.databrickscfg``.
|
|
|
|
Only used when the databricks-sdk cannot initialize a ``Config``
|
|
for the requested profile (see :func:`_read_databrickscfg`). This
|
|
preserves backward compatibility for plain-PAT setups whose
|
|
config files the SDK rejects for unrelated reasons.
|
|
|
|
Profile resolution order:
|
|
1. Explicit *profile* argument
|
|
2. ``DATABRICKS_CONFIG_PROFILE`` env var
|
|
3. ``DEFAULT`` section (if it has both host and token)
|
|
4. First section that has both host and token
|
|
|
|
:param profile: Databricks config profile name, or ``None`` for the
|
|
default resolution order. Example: ``"DEFAULT"``.
|
|
:returns: :class:`DatabricksCredentials` with both fields populated,
|
|
or ``None`` when the file is absent or no matching section has
|
|
both fields.
|
|
"""
|
|
import configparser
|
|
from pathlib import Path
|
|
|
|
cfg_path = Path(os.environ.get("DATABRICKS_CONFIG_FILE") or (Path.home() / ".databrickscfg"))
|
|
if not cfg_path.exists():
|
|
return None
|
|
|
|
config = configparser.ConfigParser()
|
|
config.read(cfg_path)
|
|
|
|
resolved_profile = profile or os.environ.get("DATABRICKS_CONFIG_PROFILE")
|
|
if resolved_profile and resolved_profile in config:
|
|
host = config[resolved_profile].get("host")
|
|
token = config[resolved_profile].get("token")
|
|
if host and token:
|
|
return DatabricksCredentials(host=host, token=token)
|
|
|
|
# Try DEFAULT section
|
|
default = config.defaults()
|
|
default_host = default.get("host")
|
|
default_token = default.get("token")
|
|
if default_host and default_token:
|
|
return DatabricksCredentials(host=default_host, token=default_token)
|
|
|
|
# Try first section with both host and token
|
|
for section in config.sections():
|
|
host = config[section].get("host")
|
|
token = config[section].get("token")
|
|
if host and token:
|
|
logger.info("Using Databricks profile [%s] from ~/.databrickscfg", section)
|
|
return DatabricksCredentials(host=host, token=token)
|
|
|
|
return None
|
|
|
|
|
|
def _read_databrickscfg_host(profile: str | None = None) -> str | None:
|
|
"""
|
|
Read only the workspace host from the Databricks config file.
|
|
|
|
Codex gateway launches use the cfg profile as a host selector and delegate
|
|
bearer refresh to ``databricks auth token --profile ...`` via Codex's
|
|
``auth.command``. That startup path must work even when ``databricks-sdk``
|
|
is unavailable in the runner environment, because raw
|
|
``auth_type=databricks-cli`` sections often have a host but no static
|
|
token.
|
|
|
|
Profile resolution order is intentionally narrower than
|
|
:func:`_read_databrickscfg_file_fallback`: an explicit missing profile
|
|
returns ``None`` instead of falling through to another profile, because
|
|
the generated auth command will still be pinned to the explicit profile.
|
|
|
|
:param profile: Databricks config profile name, or ``None`` to use
|
|
``DATABRICKS_CONFIG_PROFILE``, ``[DEFAULT]``, then the first section
|
|
with a host.
|
|
:returns: Workspace host URL, or ``None`` when no host can be resolved.
|
|
"""
|
|
import configparser
|
|
from pathlib import Path
|
|
|
|
cfg_path = Path(os.environ.get("DATABRICKS_CONFIG_FILE") or (Path.home() / ".databrickscfg"))
|
|
if not cfg_path.exists():
|
|
return None
|
|
|
|
config = configparser.ConfigParser()
|
|
config.read(cfg_path)
|
|
|
|
resolved_profile = profile or os.environ.get("DATABRICKS_CONFIG_PROFILE")
|
|
if resolved_profile:
|
|
if resolved_profile in config:
|
|
host = config[resolved_profile].get("host")
|
|
return host or None
|
|
return None
|
|
|
|
default_host = config.defaults().get("host")
|
|
if default_host:
|
|
return default_host
|
|
|
|
for section in config.sections():
|
|
host = config[section].get("host")
|
|
if host:
|
|
logger.info("Using Databricks host from profile [%s] in ~/.databrickscfg", section)
|
|
return host
|
|
|
|
return None
|
|
|
|
|
|
class DatabricksAuthError(OSError):
|
|
"""Raised when Databricks credential resolution or token refresh fails.
|
|
|
|
Carries an actionable message pointing the user to
|
|
``databricks auth login``.
|
|
"""
|
|
|
|
|
|
class _DatabricksBearerAuth(httpx.Auth):
|
|
"""httpx Auth that calls ``Config.authenticate()`` on every HTTP request.
|
|
|
|
Unlike the snapshot approach (read a token once, set ``api_key``),
|
|
this delegates token lifecycle to the Databricks SDK. OAuth tokens
|
|
are refreshed transparently via the SDK's refresh-token exchange,
|
|
so sessions that run longer than the 1-hour access-token lifetime
|
|
survive without manual intervention.
|
|
|
|
Inspired by ``databricks-ai-bridge``'s ``BearerAuth``.
|
|
|
|
:param config: A ``databricks.sdk.config.Config`` instance whose
|
|
``authenticate()`` method returns fresh ``Authorization``
|
|
headers on every call.
|
|
:param profile_name: Human-readable profile name for error
|
|
messages, e.g. ``"dev"``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config: Any, # type: ignore[explicit-any]
|
|
profile_name: str | None = None,
|
|
failure_message: str | None = None,
|
|
) -> None:
|
|
"""
|
|
:param config: Databricks SDK ``Config`` instance.
|
|
:param profile_name: Profile name shown in error messages.
|
|
:param failure_message: Full replacement error message for
|
|
auth failures, e.g. ``"Databricks authentication failed
|
|
for workspace https://example.databricks.com. Run: ..."``.
|
|
``None`` builds the default profile-flavored message.
|
|
"""
|
|
self._config = config
|
|
self._profile_name = profile_name
|
|
self._failure_message = failure_message
|
|
|
|
def _authenticate_headers(self) -> dict[str, str]:
|
|
"""
|
|
Return fresh ``Authorization`` headers from the reused Config.
|
|
|
|
Reusing the wrapped ``Config`` is what makes this cheap on repeat
|
|
calls: the SDK serves the cached OAuth token from memory and only
|
|
re-runs the Databricks CLI when the token nears expiry.
|
|
|
|
:returns: Header dict from ``Config.authenticate()``, e.g.
|
|
``{"Authorization": "Bearer dapi..."}``.
|
|
:raises DatabricksAuthError: When the SDK cannot mint a token
|
|
(expired refresh token, revoked credentials, etc.).
|
|
"""
|
|
try:
|
|
return self._config.authenticate()
|
|
except Exception as exc:
|
|
if self._failure_message is not None:
|
|
raise DatabricksAuthError(self._failure_message) from exc
|
|
profile_flag = f" -p {self._profile_name}" if self._profile_name else ""
|
|
raise DatabricksAuthError(
|
|
f"Databricks authentication failed for profile {self._profile_name!r}. "
|
|
f"Run: databricks auth login{profile_flag}"
|
|
) from exc
|
|
|
|
def current_token(self) -> str | None:
|
|
"""
|
|
Return the current bearer token, minting/refreshing via the SDK.
|
|
|
|
Backed by :meth:`_authenticate_headers`, so callers that invoke
|
|
this once per HTTP request pay the ~0.5s Databricks CLI shell-out
|
|
only on the first call (and on token refresh), not every call.
|
|
|
|
:returns: The bearer token string (no ``"Bearer "`` prefix), or
|
|
``None`` when the SDK returns a non-Bearer / empty
|
|
``Authorization`` header.
|
|
:raises DatabricksAuthError: When the SDK cannot mint a token.
|
|
"""
|
|
auth_value = self._authenticate_headers().get("Authorization", "")
|
|
if auth_value.startswith("Bearer "):
|
|
return auth_value.removeprefix("Bearer ")
|
|
return None
|
|
|
|
def auth_flow(
|
|
self,
|
|
request: httpx.Request,
|
|
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
"""Inject a fresh ``Authorization: Bearer`` header.
|
|
|
|
:param request: The outgoing httpx request.
|
|
:yields: The request with the auth header set.
|
|
:raises DatabricksAuthError: When the SDK cannot mint a
|
|
token (expired refresh token, revoked credentials, etc.).
|
|
"""
|
|
auth_value = self._authenticate_headers().get("Authorization", "")
|
|
if auth_value:
|
|
request.headers["Authorization"] = auth_value
|
|
yield request
|
|
|
|
|
|
def _resolve_databricks_auth(
|
|
profile: str | None = None,
|
|
*,
|
|
host: str | None = None,
|
|
) -> tuple[_DatabricksBearerAuth, str]:
|
|
"""Resolve Databricks credentials and return per-request auth + host.
|
|
|
|
Validates that authentication succeeds at call time. On success,
|
|
returns an httpx Auth that re-authenticates on every HTTP request
|
|
(surviving OAuth access-token expiry transparently) and the
|
|
workspace host URL.
|
|
|
|
:param profile: Databricks config profile name, e.g. ``"dev"``.
|
|
``None`` uses the SDK's default resolution order. Mutually
|
|
exclusive with ``host``.
|
|
:param host: Workspace host to authenticate against, e.g.
|
|
``"https://example.databricks.com"``. Used by the
|
|
``omnigent login <apps-url>`` pointer records, which name a
|
|
workspace rather than a profile; resolution is delegated to
|
|
:func:`_resolve_databricks_auth_for_host`. The ambient
|
|
profile/env fallback is NOT attempted in this mode — the
|
|
record asked for a specific workspace, so a credential miss
|
|
fails loud.
|
|
:returns: ``(auth, host)`` — an httpx Auth for injection into
|
|
``httpx.Client``/``httpx.AsyncClient`` and the workspace URL,
|
|
e.g. ``"https://example.cloud.databricks.com"``.
|
|
:raises DatabricksAuthError: When credentials are missing or
|
|
authentication fails.
|
|
:raises ImportError: When the ``databricks-sdk`` package is not
|
|
installed.
|
|
:raises ValueError: When both ``profile`` and ``host`` are given.
|
|
"""
|
|
try:
|
|
from databricks.sdk.config import Config
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"The 'databricks-sdk' package is required for Databricks authentication. "
|
|
"Install it with: pip install databricks-sdk"
|
|
) from exc
|
|
|
|
if host is not None:
|
|
if profile is not None:
|
|
raise ValueError("_resolve_databricks_auth takes profile or host, not both")
|
|
return _resolve_databricks_auth_for_host(host)
|
|
|
|
sdk_profile = profile or os.environ.get("DATABRICKS_CONFIG_PROFILE")
|
|
cfg = None
|
|
|
|
try:
|
|
cfg = Config(profile=sdk_profile)
|
|
cfg.authenticate()
|
|
except ValueError:
|
|
if profile is None and sdk_profile is not None:
|
|
# Profile name came from the DATABRICKS_CONFIG_PROFILE env var,
|
|
# not from an explicit profile argument. Fall back to the
|
|
# ambient credential chain (OIDC, metadata service, etc.) so CI
|
|
# environments that inject tokens via env vars but have no
|
|
# ~/.databrickscfg still work. Log a warning so the fallback is
|
|
# visible rather than silent.
|
|
#
|
|
# When the profile was explicit (profile is not None), we do NOT
|
|
# fall back — the user asked for a specific workspace and silently
|
|
# using a different one violates the "Fail loud" principle.
|
|
logger.warning(
|
|
"Databricks profile %r (from DATABRICKS_CONFIG_PROFILE) not found "
|
|
"in config file; falling back to ambient credential chain.",
|
|
sdk_profile,
|
|
)
|
|
try:
|
|
cfg = Config()
|
|
cfg.authenticate()
|
|
except ValueError:
|
|
cfg = None
|
|
else:
|
|
cfg = None
|
|
except Exception as exc:
|
|
profile_flag = f" -p {profile}" if profile else ""
|
|
raise DatabricksAuthError(
|
|
f"Databricks authentication failed for profile {profile!r}. "
|
|
f"Run: databricks auth login{profile_flag}"
|
|
) from exc
|
|
|
|
if cfg is not None and cfg.host:
|
|
return _DatabricksBearerAuth(cfg, profile_name=profile), cfg.host
|
|
|
|
# SDK-based resolution failed (simple PAT profile, missing auth_type,
|
|
# etc.). Fall back to reading ~/.databrickscfg directly — static PATs
|
|
# don't need per-request refresh.
|
|
creds = _read_databrickscfg(profile)
|
|
if creds is not None:
|
|
static_cfg = type(
|
|
"_StaticAuth",
|
|
(),
|
|
{
|
|
"authenticate": lambda _self: {"Authorization": f"Bearer {creds.token}"},
|
|
},
|
|
)()
|
|
return _DatabricksBearerAuth(static_cfg, profile_name=profile), creds.host
|
|
|
|
profile_flag = f" -p {profile}" if profile else ""
|
|
raise DatabricksAuthError(
|
|
f"Databricks profile {profile!r} is not authenticated. "
|
|
f"Run: databricks auth login{profile_flag}"
|
|
)
|
|
|
|
|
|
def _sdk_config(**kwargs: str) -> Any: # type: ignore[explicit-any] # SDK Config, imported lazily
|
|
"""Construct a databricks-sdk ``Config`` (test indirection point).
|
|
|
|
The SDK probes host metadata at construction time, which makes
|
|
offline unit tests against placeholder hosts impossible — tests
|
|
patch this helper with a stub instead of touching the SDK module.
|
|
|
|
:param kwargs: ``Config`` keyword arguments, e.g.
|
|
``profile="my-ws"`` or ``host=..., auth_type="databricks-cli"``.
|
|
:returns: The constructed ``databricks.sdk.config.Config``.
|
|
"""
|
|
from databricks.sdk.config import Config
|
|
|
|
# The SDK types ``Config.__init__`` as taking a CredentialsStrategy
|
|
# positionally; keyword config attributes are dynamically declared,
|
|
# so the kwargs expansion is untypeable here.
|
|
return Config(**kwargs) # type: ignore[arg-type]
|
|
|
|
|
|
def _resolve_databricks_auth_for_host(host: str) -> tuple[_DatabricksBearerAuth, str]:
|
|
"""Resolve per-request auth for a specific workspace host.
|
|
|
|
Prefers a ``~/.databrickscfg`` profile pinned to *host*:
|
|
``databricks auth login --host <host>`` saves one, and the CLI's
|
|
host-keyed token lookup (``databricks auth token --host``) is
|
|
unreliable across CLI versions — it can miss a grant the login
|
|
cached under the profile name, and some builds return a cached
|
|
grant for a *different* workspace. The profile path goes through
|
|
the SDK's full credential chain (OAuth refresh, PAT, …) for
|
|
exactly the requested host. The host-keyed ``databricks-cli``
|
|
lookup remains as the last resort for cfg-less setups.
|
|
|
|
:param host: Workspace host, e.g.
|
|
``"https://example.databricks.com"``.
|
|
:returns: ``(auth, host)`` — an httpx Auth and the workspace URL.
|
|
:raises DatabricksAuthError: When no credential source resolves
|
|
for the host.
|
|
"""
|
|
host_failure = (
|
|
f"Databricks authentication failed for workspace {host}. "
|
|
f"Run: databricks auth login --host {host}"
|
|
)
|
|
for profile_name in _databrickscfg_profiles_for_host(host):
|
|
try:
|
|
cfg = _sdk_config(profile=profile_name)
|
|
cfg.authenticate()
|
|
except Exception: # noqa: BLE001 — try the next matching profile
|
|
logger.debug("profile %r matched host %s but did not authenticate", profile_name, host)
|
|
continue
|
|
return _DatabricksBearerAuth(cfg, profile_name=profile_name), cfg.host or host
|
|
try:
|
|
host_cfg = _sdk_config(host=host, auth_type="databricks-cli")
|
|
host_cfg.authenticate()
|
|
except Exception as exc:
|
|
raise DatabricksAuthError(host_failure) from exc
|
|
return _DatabricksBearerAuth(host_cfg, failure_message=host_failure), host
|
|
|
|
|
|
def _databrickscfg_profiles_for_host(host: str) -> list[str]:
|
|
"""List ``~/.databrickscfg`` profile names whose ``host`` is *host*.
|
|
|
|
Comparison is scheme-insensitive and ignores trailing slashes, so
|
|
``my-ws.cloud.databricks.com`` in the cfg matches a
|
|
``https://my-ws.cloud.databricks.com`` query.
|
|
|
|
:param host: Workspace host to match, e.g.
|
|
``"https://example.databricks.com"``.
|
|
:returns: Matching section names in file order (the ``DEFAULT``
|
|
section included when it carries a matching host), or ``[]``
|
|
when the config file is missing or unparseable.
|
|
"""
|
|
import configparser
|
|
from pathlib import Path
|
|
|
|
def _norm(value: str) -> str:
|
|
value = value.strip().rstrip("/")
|
|
return value.split("://", 1)[-1].lower()
|
|
|
|
cfg_path = Path(os.environ.get("DATABRICKS_CONFIG_FILE") or (Path.home() / ".databrickscfg"))
|
|
if not cfg_path.exists():
|
|
return []
|
|
config = configparser.ConfigParser()
|
|
try:
|
|
config.read(cfg_path)
|
|
except configparser.Error:
|
|
return []
|
|
wanted = _norm(host)
|
|
matches = [
|
|
section
|
|
for section in config.sections()
|
|
if _norm(config[section].get("host", "")) == wanted
|
|
]
|
|
default_host = config.defaults().get("host")
|
|
if default_host and _norm(default_host) == wanted:
|
|
matches.append("DEFAULT")
|
|
return matches
|
|
|
|
|
|
def _get_openai_client(profile: str | None = None) -> OpenAI:
|
|
"""Lazily import and construct the OpenAI client.
|
|
|
|
Supports two configuration modes (in priority order):
|
|
1. Direct OpenAI-compatible: OPENAI_BASE_URL + OPENAI_API_KEY
|
|
2. Databricks config file: ~/.databrickscfg
|
|
"""
|
|
try:
|
|
from openai import OpenAI
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"The 'openai' package is required for DatabricksExecutor. "
|
|
"Install it with: pip install openai"
|
|
) from exc
|
|
|
|
# Direct OpenAI-compatible config takes precedence
|
|
if os.environ.get("OPENAI_BASE_URL"):
|
|
from .open_responses_sdk import _OPENAI_KEY_PLACEHOLDER
|
|
|
|
return OpenAI(
|
|
base_url=os.environ["OPENAI_BASE_URL"],
|
|
# See _OPENAI_KEY_PLACEHOLDER docstring in open_responses_sdk.
|
|
api_key=os.environ.get("OPENAI_API_KEY", _OPENAI_KEY_PLACEHOLDER),
|
|
)
|
|
|
|
from .open_responses_sdk import _OPENAI_KEY_PLACEHOLDER as _placeholder
|
|
|
|
auth, host = _resolve_databricks_auth(profile)
|
|
base_url = host.rstrip("/") + "/serving-endpoints"
|
|
return OpenAI(
|
|
base_url=base_url,
|
|
api_key=_placeholder,
|
|
http_client=httpx.Client(auth=auth),
|
|
)
|
|
|
|
|
|
def _convert_tools_to_openai(tools: list[ToolSpec]) -> list[ToolSpec]:
|
|
"""Convert our tool schemas to OpenAI function-calling format.
|
|
|
|
Our tool schema looks like:
|
|
{"name": "sql_query", "description": "...", "parameters": {...}}
|
|
|
|
OpenAI expects:
|
|
{"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}
|
|
"""
|
|
result: list[ToolSpec] = []
|
|
for tool in tools:
|
|
# ``Message`` / ``ToolSpec`` are JSON-typed ``dict[str, Any]``; the
|
|
# OpenAI SDK requires ``name``/``description`` as plain strings. The
|
|
# ternaries below are the boundary coercion — a missing key in the
|
|
# tool dict surfaces to OpenAI as an empty field rather than ``None``.
|
|
raw_name = tool.get("name")
|
|
raw_description = tool.get("description")
|
|
fn: ToolSpec = {
|
|
"name": raw_name if isinstance(raw_name, str) else "",
|
|
"description": raw_description if isinstance(raw_description, str) else "",
|
|
}
|
|
if "parameters" in tool:
|
|
fn["parameters"] = tool["parameters"]
|
|
else:
|
|
fn["parameters"] = {"type": "object", "properties": {}}
|
|
result.append({"type": "function", "function": fn})
|
|
return result
|
|
|
|
|
|
def _convert_messages(
|
|
messages: list[Message],
|
|
system_prompt: str,
|
|
) -> list[Message]:
|
|
"""Convert our internal message format to OpenAI chat messages.
|
|
|
|
Our internal format uses roles: user, assistant, tool_call, tool_result.
|
|
OpenAI expects: system, user, assistant (with optional tool_calls), tool.
|
|
"""
|
|
result: list[Message] = []
|
|
if system_prompt:
|
|
result.append({"role": "system", "content": system_prompt})
|
|
|
|
i = 0
|
|
while i < len(messages):
|
|
msg = messages[i]
|
|
# ``Message`` is ``dict[str, Any]``; role/content are untyped JSON.
|
|
# Narrow to ``str``/``""`` for the role dispatch below — a missing
|
|
# role short-circuits to the default-user branch, mirroring the
|
|
# fall-through for unknown roles.
|
|
raw_role = msg.get("role")
|
|
role = raw_role if isinstance(raw_role, str) else ""
|
|
content = msg.get("content") if msg.get("content") is not None else ""
|
|
|
|
if role == "user":
|
|
text = str(content) if content else "(empty)"
|
|
result.append({"role": "user", "content": text})
|
|
|
|
elif role == "assistant":
|
|
text = str(content) if content else "(empty)"
|
|
result.append({"role": "assistant", "content": text})
|
|
|
|
elif role == "tool_call":
|
|
# Our format stores tool_call + tool_result as adjacent pairs.
|
|
# OpenAI expects: assistant message with tool_calls array,
|
|
# followed by a tool message with the result.
|
|
if isinstance(content, str):
|
|
try:
|
|
content = json.loads(content)
|
|
except (json.JSONDecodeError, TypeError):
|
|
content = {}
|
|
if isinstance(content, dict):
|
|
raw_call_tool = content.get("tool")
|
|
tool_name = raw_call_tool if isinstance(raw_call_tool, str) else ""
|
|
tool_args = content.get("args", {})
|
|
else:
|
|
tool_name = ""
|
|
tool_args = {}
|
|
|
|
call_id = f"call_{i}"
|
|
result.append(
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": call_id,
|
|
"type": "function",
|
|
"function": {
|
|
"name": tool_name,
|
|
"arguments": json.dumps(tool_args)
|
|
if isinstance(tool_args, dict)
|
|
else str(tool_args),
|
|
},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
# Consume the following tool_result if present
|
|
if i + 1 < len(messages) and messages[i + 1].get("role") == "tool_result":
|
|
i += 1
|
|
# ``Message.content`` is untyped JSON; non-str values are
|
|
# json-encoded below. ``None`` / missing surfaces as ``""``.
|
|
raw_tr = messages[i].get("content")
|
|
tr_content = raw_tr if raw_tr is not None else ""
|
|
if not isinstance(tr_content, str):
|
|
tr_content = json.dumps(tr_content)
|
|
result.append(
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": call_id,
|
|
"content": tr_content,
|
|
}
|
|
)
|
|
|
|
elif role == "tool_result":
|
|
# Orphan tool_result without a preceding tool_call — skip or
|
|
# treat as a user message so the LLM sees it.
|
|
tr_content = content if isinstance(content, str) else json.dumps(content)
|
|
result.append({"role": "user", "content": f"[tool result] {tr_content}"})
|
|
|
|
else:
|
|
# Unknown role — pass through as user
|
|
result.append({"role": "user", "content": str(content)})
|
|
|
|
i += 1
|
|
|
|
return result
|
|
|
|
|
|
def _extract_stream_text_delta(content: Any) -> str:
|
|
"""
|
|
Extract assistant-visible text from a Chat Completions stream delta.
|
|
|
|
Some OpenAI-compatible Databricks models stream ``delta.content`` as
|
|
a list of typed content blocks instead of a plain string. Kimi, for
|
|
example, emits ``{"type": "reasoning", "summary": [...]}`` blocks
|
|
before the final answer. The legacy executor contract only supports
|
|
assistant-visible text chunks, so reasoning blocks are ignored while
|
|
recognized text blocks are concatenated.
|
|
|
|
:param content: Raw ``choice.delta.content`` value from the OpenAI SDK,
|
|
e.g. ``"hello"`` or ``[{"type": "text", "text": "hello"}]``.
|
|
:returns: Plain text to emit as a :class:`TextChunk`, or ``""`` when
|
|
the delta contains no assistant-visible text.
|
|
"""
|
|
if isinstance(content, str):
|
|
return content
|
|
if not isinstance(content, list):
|
|
return ""
|
|
|
|
pieces: list[str] = []
|
|
for block in content:
|
|
if isinstance(block, str):
|
|
pieces.append(block)
|
|
continue
|
|
if not isinstance(block, dict):
|
|
continue
|
|
if block.get("type") in {"text", "output_text"}:
|
|
text = block.get("text")
|
|
if isinstance(text, str):
|
|
pieces.append(text)
|
|
return "".join(pieces)
|
|
|
|
|
|
class DatabricksExecutor(Executor):
|
|
"""Execute agent turns using Databricks-hosted LLMs (or any OpenAI-compatible API).
|
|
|
|
This is a synchronous (non-streaming) implementation that makes a single
|
|
chat completions call per turn and maps the response to ExecutorEvents.
|
|
|
|
Streaming support can be added later by setting ``stream=True`` and
|
|
yielding TextChunk events as deltas arrive.
|
|
"""
|
|
|
|
def __init__(self, client: OpenAI | None = None, profile: str | None = None) -> None:
|
|
"""Create a DatabricksExecutor.
|
|
|
|
Args:
|
|
client: An OpenAI client instance. If ``None``, one is created
|
|
from OPENAI_BASE_URL/API_KEY or ~/.databrickscfg.
|
|
profile: Databricks config profile name to use from ~/.databrickscfg,
|
|
or ``None`` to let the SDK's standard resolution order decide.
|
|
|
|
Raises:
|
|
ImportError: If the ``openai`` package is not installed.
|
|
EnvironmentError: If no credentials are configured.
|
|
"""
|
|
self._profile = profile
|
|
self._client = client if client is not None else _get_openai_client(profile=profile)
|
|
self._session_states: dict[str, _DatabricksSessionState] = {}
|
|
|
|
def supports_streaming(self) -> bool:
|
|
return True
|
|
|
|
def supports_tool_calling(self) -> bool:
|
|
return True
|
|
|
|
def max_context_tokens(self) -> int | None:
|
|
return None # Let the model handle truncation
|
|
|
|
def _session_key(self, messages: list[Message]) -> str:
|
|
if messages:
|
|
if messages[-1].get("session_id"):
|
|
return str(messages[-1]["session_id"])
|
|
metadata = messages[-1].get("metadata", {})
|
|
if isinstance(metadata, dict) and metadata.get("session_id"):
|
|
return str(metadata["session_id"])
|
|
return "default"
|
|
|
|
def _get_or_create_session_state(self, session_key: str) -> _DatabricksSessionState:
|
|
state = self._session_states.get(session_key)
|
|
if state is None:
|
|
state = _DatabricksSessionState()
|
|
self._session_states[session_key] = state
|
|
return state
|
|
|
|
async def close_session(self, session_key: str) -> None:
|
|
self._session_states.pop(session_key, None)
|
|
|
|
async def interrupt_session(self, session_key: str) -> bool:
|
|
state = self._session_states.get(session_key)
|
|
if state is None or state.active_stream is None:
|
|
return False
|
|
state.interrupt_requested = True
|
|
await run_sync_on_thread(state.active_stream.close)
|
|
return True
|
|
|
|
async def run_turn(
|
|
self,
|
|
messages: list[Message],
|
|
tools: list[ToolSpec],
|
|
system_prompt: str,
|
|
config: ExecutorConfig | None = None,
|
|
) -> AsyncIterator[ExecutorEvent]:
|
|
"""Call the LLM with streaming and yield ExecutorEvents as they arrive.
|
|
|
|
Text content is yielded as TextChunk events in real time. Tool calls
|
|
are accumulated across stream chunks and yielded as ToolCallRequest
|
|
events once the stream signals ``finish_reason="tool_calls"``.
|
|
"""
|
|
cfg = config or ExecutorConfig()
|
|
model = cfg.model
|
|
if not model:
|
|
model = "databricks-claude-sonnet-4-6"
|
|
session_key = self._session_key(messages)
|
|
state = self._get_or_create_session_state(session_key)
|
|
state.interrupt_requested = False
|
|
|
|
client = self._client
|
|
|
|
oai_messages = _convert_messages(messages, system_prompt)
|
|
oai_tools = _convert_tools_to_openai(tools) if tools else None
|
|
|
|
kwargs: OpenAIKwargs = {
|
|
"model": model,
|
|
"messages": oai_messages,
|
|
"max_tokens": cfg.max_tokens,
|
|
"temperature": cfg.temperature,
|
|
"stream": True,
|
|
}
|
|
if oai_tools:
|
|
kwargs["tools"] = oai_tools
|
|
kwargs.update(
|
|
{
|
|
key: value
|
|
for key, value in cfg.extra.items()
|
|
if key not in _SESSION_ONLY_EXECUTOR_EXTRA_KEYS
|
|
}
|
|
)
|
|
|
|
try:
|
|
logger.debug(
|
|
"DatabricksExecutor: streaming %s with %d messages, %d tools",
|
|
model,
|
|
len(oai_messages),
|
|
len(tools),
|
|
)
|
|
create_fn = client.chat.completions.create
|
|
stream = await asyncio.wait_for(
|
|
run_sync_on_thread(create_fn, **kwargs),
|
|
timeout=_API_CALL_TIMEOUT_SECONDS,
|
|
)
|
|
state.active_stream = stream
|
|
except asyncio.TimeoutError:
|
|
logger.error(
|
|
"DatabricksExecutor: API call timed out after %ss", _API_CALL_TIMEOUT_SECONDS
|
|
)
|
|
yield ExecutorError(
|
|
message=f"LLM API call timed out after {int(_API_CALL_TIMEOUT_SECONDS)}s"
|
|
)
|
|
return
|
|
except Exception as exc: # noqa: BLE001 — executor boundary surfaces any error as an ExecutorError event
|
|
logger.error("DatabricksExecutor: API call failed: %s", exc)
|
|
# Append an env-var hint when stale ``DATABRICKS_*`` is set —
|
|
# those silently shadow the profile lookup and surface here as
|
|
# "Unable to load OAuth Config" / 401 with no other clue.
|
|
from omnigent.onboarding.setup import detect_conflicting_env_vars
|
|
|
|
conflicts = detect_conflicting_env_vars()
|
|
extra = ""
|
|
if conflicts:
|
|
extra = (
|
|
f"\nHint: these env vars are set and may be overriding "
|
|
f"your profile: {', '.join(conflicts)}. Unset them, or "
|
|
f"run via `env -u {' -u '.join(conflicts)} <command>`."
|
|
)
|
|
yield ExecutorError(message=f"LLM API error: {exc}{extra}")
|
|
return
|
|
|
|
full_text = ""
|
|
# Tool call arguments arrive in pieces; accumulate per index.
|
|
pending_tool_calls: dict[int, dict[str, str]] = {}
|
|
|
|
try:
|
|
stream_iter = iterate_blocking_stream(stream)
|
|
while True:
|
|
try:
|
|
chunk = await asyncio.wait_for(
|
|
anext(stream_iter),
|
|
timeout=_STREAM_IDLE_TIMEOUT_SECONDS,
|
|
)
|
|
except StopAsyncIteration:
|
|
break
|
|
except asyncio.TimeoutError as exc:
|
|
raise TimeoutError(
|
|
f"Databricks stream was idle for {int(_STREAM_IDLE_TIMEOUT_SECONDS)}s"
|
|
) from exc
|
|
if state.interrupt_requested:
|
|
return
|
|
if not chunk.choices:
|
|
continue
|
|
choice = chunk.choices[0]
|
|
delta = choice.delta
|
|
|
|
if delta:
|
|
text_delta = _extract_stream_text_delta(delta.content)
|
|
if text_delta:
|
|
yield TextChunk(text=text_delta)
|
|
full_text += text_delta
|
|
|
|
if delta and delta.tool_calls:
|
|
for tc_delta in delta.tool_calls:
|
|
idx = tc_delta.index
|
|
if idx not in pending_tool_calls:
|
|
pending_tool_calls[idx] = {"name": "", "arguments": ""}
|
|
if tc_delta.function:
|
|
if tc_delta.function.name:
|
|
pending_tool_calls[idx]["name"] = tc_delta.function.name
|
|
if tc_delta.function.arguments:
|
|
pending_tool_calls[idx]["arguments"] += tc_delta.function.arguments
|
|
|
|
if choice.finish_reason == "tool_calls":
|
|
for idx in sorted(pending_tool_calls):
|
|
tc = pending_tool_calls[idx]
|
|
try:
|
|
args = json.loads(tc["arguments"]) if tc["arguments"] else {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
args = {"raw": tc["arguments"]}
|
|
yield ToolCallRequest(name=tc["name"], args=args)
|
|
return
|
|
|
|
if choice.finish_reason == "stop":
|
|
if state.interrupt_requested:
|
|
return
|
|
yield TurnComplete(response=full_text)
|
|
return
|
|
except KeyboardInterrupt:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 — stream boundary converts any error into an ExecutorError event
|
|
if state.interrupt_requested:
|
|
return
|
|
logger.error("DatabricksExecutor: stream error: %s", exc)
|
|
yield ExecutorError(message=f"LLM stream error: {exc}")
|
|
return
|
|
finally:
|
|
state.active_stream = None
|
|
|
|
# Stream ended without an explicit finish_reason
|
|
if state.interrupt_requested:
|
|
return
|
|
if pending_tool_calls:
|
|
for idx in sorted(pending_tool_calls):
|
|
tc = pending_tool_calls[idx]
|
|
try:
|
|
args = json.loads(tc["arguments"]) if tc["arguments"] else {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
args = {"raw": tc["arguments"]}
|
|
yield ToolCallRequest(name=tc["name"], args=args)
|
|
elif full_text:
|
|
# Truncated stream that still produced content: surface what we got
|
|
# but warn — a missing finish_reason means the turn may be incomplete.
|
|
logger.warning(
|
|
"DatabricksExecutor: stream ended without finish_reason; "
|
|
"returning %d chars of partial content",
|
|
len(full_text),
|
|
)
|
|
yield TurnComplete(response=full_text)
|
|
else:
|
|
# No finish_reason, no content, no tool calls: the worker stream died
|
|
# mid-turn. Fail loudly instead of yielding a silent empty success
|
|
# that masks the aborted turn (#1118).
|
|
yield ExecutorError(message="Stream ended without finish_reason")
|