Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
410 changes: 410 additions & 0 deletions autobot-backend/api/provider_auth.py

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions autobot-backend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,11 @@ def _load_real_mod(name: str, path: "Path") -> None: # type: ignore[name-define
# in the lightweight run_credentials module; load it real so tests importing
# them don't hit the llm_shared MagicMock stub.
_load_real_mod("llm_shared.run_credentials", _llm_root / "run_credentials.py")
# #10551: provider auth abstraction — load real so tests can import the
# strategy classes without hitting the llm_shared MagicMock stub.
_load_real_mod("llm_shared.provider_auth", _llm_root / "provider_auth.py")
# #10551: base_provider — load real so BaseProvider tests can instantiate it.
_load_real_mod("llm_shared.base_provider", _llm_root / "base_provider.py")

# Stub llm_shared.optimization.model_inspector so complexity_router.py can
# load without the full optimization stack (inspect_model is only called in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ def get_cross_worker_load_results() -> Dict[str, Any]:
# ("api.execution_snapshots", "", ["execution", "snapshots"], "execution_snapshots"),
# Core workflow and batch processing
# Issue #6229: api.websockets and api.live_events promoted to core_routers
# #10551: Provider OAuth/device-code/session auth connect endpoints
("api.provider_auth", "", ["llm-auth"], "provider_auth"),
("api.workflow", "/workflow", ["workflow"], "workflow"),
# Issue #1287: batch.py consolidated into batch_jobs.py
(
Expand Down
1 change: 1 addition & 0 deletions autobot-backend/knowledge/connectors/oauth_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ async def _post_token(token_url: str, payload: dict) -> dict:
token_url,
data=payload,
headers={"Accept": "application/json"},
allow_redirects=False,
) as resp:
body = await resp.json(content_type=None)
if resp.status >= 400:
Expand Down
47 changes: 43 additions & 4 deletions autobot-backend/llm_shared/base_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
import asyncio
import time
from abc import ABC, abstractmethod
from typing import Any, AsyncIterator, Dict, List
from typing import Any, AsyncIterator, Dict, List, Optional

from autobot_shared.logging_manager import get_logger

from .cross_worker_rate_limiter import get_llm_rate_limiter
from .models import LLMRequest, LLMResponse
from .observability import registry as obs_registry
from .provider_auth import ApiKeyAuth, ProviderAuthError, ProviderAuthStrategy
from .rate_limit_backoff import get_backoff_handler, raise_if_rate_limited

logger = get_logger(__name__)
Expand All @@ -44,19 +45,36 @@ class BaseProvider(ABC):
#: Override in each subclass with the provider's string identifier.
provider_name: str = ""

def __init__(self, settings: Dict[str, Any] | None = None) -> None:
def __init__(
self,
settings: Dict[str, Any] | None = None,
auth_strategy: Optional[ProviderAuthStrategy] = None,
) -> None:
"""
Initialize the provider.

Args:
settings: Optional dict of provider-specific configuration values.
Keys are provider-specific (e.g., ``api_key``,
``base_url``, ``default_model``).
auth_strategy: Optional auth strategy (``ApiKeyAuth``, ``OAuthAuth``,
``DeviceCodeAuth``, ``SessionAuth``). When omitted, the
legacy ``settings["api_key"]`` path is used via
``ApiKeyAuth`` — fully backward-compatible.
"""
self.settings: Dict[str, Any] = settings or {}
self._total_requests = 0
self._total_errors = 0
logger.debug("Initialized %s provider", self.provider_name)
# Resolve auth strategy: explicit arg wins; fall back to ApiKeyAuth when an
# api_key is present in settings; leave None for local providers (Ollama etc.)
# that need no credential at all.
if auth_strategy is not None:
self._auth_strategy: Optional[ProviderAuthStrategy] = auth_strategy
elif self.settings.get("api_key"):
self._auth_strategy = ApiKeyAuth(self.settings["api_key"])
else:
self._auth_strategy = None
logger.debug("Initialized %s provider (auth=%s)", self.provider_name, type(self._auth_strategy).__name__)

async def chat_completion(self, request: LLMRequest) -> LLMResponse:
"""
Expand Down Expand Up @@ -149,12 +167,33 @@ def get_stats(self) -> Dict[str, Any]:
"total_requests": self._total_requests,
"total_errors": self._total_errors,
"error_rate": (self._total_errors / self._total_requests if self._total_requests else 0.0),
"auth_strategy": type(self._auth_strategy).__name__ if self._auth_strategy else "none",
}

def _get_setting(self, key: str, default: Any = None) -> Any:
"""Safely retrieve a value from the settings dict."""
return self.settings.get(key, default)

async def _get_auth_token(self, session: Any = None) -> str | None:
"""Return a valid auth token via the active strategy, or None.

Concrete providers call this instead of reading ``settings["api_key"]``
directly so that OAuth / device-code / session auth is transparent.

Args:
session: SQLAlchemy ``AsyncSession`` required by vault-backed strategies
(``OAuthAuth``, ``DeviceCodeAuth``, ``SessionAuth``).

Returns:
Token string, or None when no auth strategy is configured (e.g. Ollama).

Raises:
ProviderAuthError: When a vault-backed strategy cannot obtain a token.
"""
if self._auth_strategy is None:
return None
return await self._auth_strategy.resolve_token(session)

def _build_provider_metadata(
self,
model_api_name: str,
Expand Down Expand Up @@ -183,4 +222,4 @@ def _build_provider_metadata(
return metadata


__all__ = ["BaseProvider"]
__all__ = ["BaseProvider", "ProviderAuthError"]
Loading