From f5ecf89e2353e2333286da72907a4f6a6790867a Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Thu, 2 Jul 2026 19:31:44 +0000 Subject: [PATCH 1/5] feat: updating gateway integration --- app/api/v1/api.py | 2 + app/api/v1/routes/aiproviders.py | 31 +- app/api/v1/routes/llm_gateway.py | 94 +++++ app/config.py | 30 ++ app/migrations/050_llm_gateway_settings.py | 48 +++ ..._rename_bifrost_gateway_settings_column.py | 72 ++++ app/models/database.py | 2 + app/models/schemas.py | 17 +- app/services/ai/llm_gateway.py | 336 ++++++++++++++++++ app/services/ai/llm_gateway_settings.py | 187 ++++++++++ app/services/ai/llm_service.py | 19 +- app/services/ai/stt_clients/google.py | 28 +- app/services/ai/transcription_service.py | 19 +- app/services/judge_alignment/gepa_bridge.py | 63 ++-- app/services/optimization/gepa_service.py | 29 +- app/services/optimization/lm_resolver.py | 16 +- app/workers/tasks/helpers/llm_diarisation.py | 4 + config.docker.yml | 9 + config.yml.example | 11 + .../docs/getting-started/integrations.mdx | 97 +++++ .../docs/getting-started/llm-gateway.mdx | 112 ++++++ .../content/docs/reference/configuration.mdx | 10 + frontend/public/geminiai.png | Bin 24746 -> 73628 bytes frontend/src/lib/api.ts | 41 +++ .../src/pages/configurations/Integrations.tsx | 331 ++++++++++++++++- frontend/src/types/api.ts | 4 +- tests/conftest.py | 2 + .../test_gateway_managed_credentials.py | 67 ++++ tests/test_api/test_llm_gateway_settings.py | 120 +++++++ .../test_gateway_managed_credentials.py | 83 +++++ .../test_services/test_ai/test_llm_gateway.py | 295 +++++++++++++++ .../test_services/test_ai/test_llm_service.py | 85 ++++- 32 files changed, 2188 insertions(+), 76 deletions(-) create mode 100644 app/api/v1/routes/llm_gateway.py create mode 100644 app/migrations/050_llm_gateway_settings.py create mode 100644 app/migrations/051_rename_bifrost_gateway_settings_column.py create mode 100644 app/services/ai/llm_gateway.py create mode 100644 app/services/ai/llm_gateway_settings.py create mode 100644 docs-fumadocs/content/docs/getting-started/llm-gateway.mdx create mode 100644 tests/test_api/test_gateway_managed_credentials.py create mode 100644 tests/test_api/test_llm_gateway_settings.py create mode 100644 tests/test_services/test_ai/test_gateway_managed_credentials.py create mode 100644 tests/test_services/test_ai/test_llm_gateway.py diff --git a/app/api/v1/api.py b/app/api/v1/api.py index fb97ecce..a63a703e 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -42,6 +42,7 @@ workspaces, workspace_iam, dashboard, + llm_gateway, ) api_router = APIRouter() @@ -87,3 +88,4 @@ api_router.include_router(workspaces.router) api_router.include_router(workspace_iam.router) api_router.include_router(dashboard.router) +api_router.include_router(llm_gateway.router) diff --git a/app/api/v1/routes/aiproviders.py b/app/api/v1/routes/aiproviders.py index 1cbe1d0f..0d44f90c 100644 --- a/app/api/v1/routes/aiproviders.py +++ b/app/api/v1/routes/aiproviders.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import desc, func from sqlalchemy.orm import Session -from typing import List +from typing import List, Optional from uuid import UUID from app.dependencies import get_db, get_organization_id @@ -22,11 +22,16 @@ ) from app.core.encryption import encrypt_api_key from app.services.credentials.resolver import clear_other_defaults +from app.services.ai.llm_gateway import ( + GATEWAY_MANAGED_KEY_SENTINEL, + gateway_managed_credentials_enabled, + is_gateway_managed_stored_key, +) router = APIRouter(prefix="/aiproviders", tags=["aiproviders"]) -def _scrub_for_response(db: Session, instance: AIProvider) -> AIProvider: +def _scrub_for_response(db: Session, instance: AIProvider) -> AIProviderResponse: """Detach the row from the session before clearing ``api_key``. The same SQLAlchemy session is reused across requests in tests; if we @@ -34,9 +39,27 @@ def _scrub_for_response(db: Session, instance: AIProvider) -> AIProvider: treats it as a pending UPDATE and tries to flush ``api_key=NULL`` on the next request — which violates the column's NOT NULL constraint. """ + gateway_managed = is_gateway_managed_stored_key(instance.api_key) db.expunge(instance) instance.api_key = None - return instance + response = AIProviderResponse.model_validate(instance) + return response.model_copy(update={"gateway_managed": gateway_managed}) + + +def _encrypt_provider_api_key(api_key: Optional[str]) -> str: + trimmed = (api_key or "").strip() + if trimmed: + return encrypt_api_key(trimmed) + if gateway_managed_credentials_enabled(): + return encrypt_api_key(GATEWAY_MANAGED_KEY_SENTINEL) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + "api_key is required. When LLM gateway-managed credentials " + "are enabled (passthrough_provider_keys: false), you can omit " + "the key and provider secrets will be resolved by the gateway." + ), + ) @router.post("", response_model=AIProviderResponse, status_code=status.HTTP_201_CREATED, operation_id="createAIProvider") @@ -63,7 +86,7 @@ async def create_aiprovider( requested_default = bool(aiprovider.is_default) will_be_default = requested_default or existing_default is None - encrypted_api_key = encrypt_api_key(aiprovider.api_key) + encrypted_api_key = _encrypt_provider_api_key(aiprovider.api_key) db_aiprovider = AIProvider( organization_id=organization_id, provider=provider_value, diff --git a/app/api/v1/routes/llm_gateway.py b/app/api/v1/routes/llm_gateway.py new file mode 100644 index 00000000..65924b8f --- /dev/null +++ b/app/api/v1/routes/llm_gateway.py @@ -0,0 +1,94 @@ +"""Organization-level LLM gateway settings.""" + +from typing import Literal, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.dependencies import get_db, get_organization_id +from app.services.ai.llm_gateway_settings import ( + GatewayMode, + GatewayTypeOverride, + get_org_settings, + set_org_settings, +) + +router = APIRouter(prefix="/organizations/llm-gateway", tags=["LLM Gateway"]) + + +class LLMGatewaySettingsResponse(BaseModel): + mode: GatewayMode + gateway_type: GatewayTypeOverride + base_url: Optional[str] = None + has_virtual_key: bool = False + has_master_key: bool = False + platform_enabled: bool = False + platform_gateway_type: Literal["bifrost", "litellm_proxy"] = "bifrost" + platform_base_url: Optional[str] = None + effective_routing: Literal["direct", "bifrost", "litellm_proxy"] + effective_gateway_type: Optional[Literal["bifrost", "litellm_proxy"]] = None + effective_base_url: Optional[str] = None + effective_has_virtual_key: bool = False + effective_has_master_key: bool = False + gateway_managed_credentials: bool = False + + +class LLMGatewaySettingsUpdate(BaseModel): + mode: GatewayMode = Field( + ..., + description="inherit: use platform default; enabled: force gateway; disabled: opt out", + ) + gateway_type: GatewayTypeOverride = Field( + default="inherit", + description="inherit: use platform gateway type; bifrost or litellm_proxy to override", + ) + base_url: Optional[str] = Field( + default=None, + description="Optional org-specific gateway URL override.", + ) + virtual_key: Optional[str] = Field( + default=None, + description="Optional Bifrost virtual key (x-bf-vk). Omit to keep existing.", + ) + master_key: Optional[str] = Field( + default=None, + description="Optional LiteLLM Proxy master key. Omit to keep existing.", + ) + clear_virtual_key: bool = Field( + default=False, + description="When true, remove the stored org virtual key.", + ) + clear_master_key: bool = Field( + default=False, + description="When true, remove the stored org master key.", + ) + + +@router.get("", response_model=LLMGatewaySettingsResponse) +def get_llm_gateway_settings( + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + return LLMGatewaySettingsResponse(**get_org_settings(organization_id, db)) + + +@router.put("", response_model=LLMGatewaySettingsResponse) +def update_llm_gateway_settings( + body: LLMGatewaySettingsUpdate, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + result = set_org_settings( + organization_id, + db, + mode=body.mode, + gateway_type=body.gateway_type, + base_url=body.base_url, + virtual_key=body.virtual_key, + master_key=body.master_key, + clear_virtual_key=body.clear_virtual_key, + clear_master_key=body.clear_master_key, + ) + return LLMGatewaySettingsResponse(**result) diff --git a/app/config.py b/app/config.py index 6ec3ebd6..356176ae 100644 --- a/app/config.py +++ b/app/config.py @@ -163,6 +163,14 @@ class Settings(BaseSettings): JUDGE_ALIGNMENT_ENABLED: bool = True JUDGE_ALIGNMENT_CSV_MAX_ROWS: int = 5000 + # LLM gateway (optional platform-wide proxy for batch LLM calls). + LLM_GATEWAY_ENABLED: bool = False + LLM_GATEWAY_TYPE: str = "bifrost" # bifrost | litellm_proxy + LLM_GATEWAY_BASE_URL: Optional[str] = None + LLM_GATEWAY_VIRTUAL_KEY: Optional[str] = None + LLM_GATEWAY_MASTER_KEY: Optional[str] = None + LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS: bool = True + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", @@ -600,6 +608,28 @@ def load_config_from_file(config_path: str) -> None: if "csv_max_rows" in ja_cfg: settings.JUDGE_ALIGNMENT_CSV_MAX_ROWS = int(ja_cfg["csv_max_rows"]) + def _apply_llm_gateway_settings(gateway_cfg: dict, *, gateway_type: str) -> None: + if "enabled" in gateway_cfg: + settings.LLM_GATEWAY_ENABLED = bool(gateway_cfg["enabled"]) + settings.LLM_GATEWAY_TYPE = gateway_type + if gateway_cfg.get("base_url"): + settings.LLM_GATEWAY_BASE_URL = gateway_cfg["base_url"] + if gateway_cfg.get("virtual_key"): + settings.LLM_GATEWAY_VIRTUAL_KEY = gateway_cfg["virtual_key"] + if gateway_cfg.get("master_key"): + settings.LLM_GATEWAY_MASTER_KEY = gateway_cfg["master_key"] + if "passthrough_provider_keys" in gateway_cfg: + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS = bool( + gateway_cfg["passthrough_provider_keys"] + ) + + if "llm_gateway" in config_data: + llm_cfg = config_data["llm_gateway"] + gateway_type = (llm_cfg.get("type") or "bifrost").strip().lower() + if gateway_type not in ("bifrost", "litellm_proxy"): + gateway_type = "bifrost" + _apply_llm_gateway_settings(llm_cfg, gateway_type=gateway_type) + if "operational" in config_data: operational_config = config_data["operational"] if "public" in operational_config: diff --git a/app/migrations/050_llm_gateway_settings.py b/app/migrations/050_llm_gateway_settings.py new file mode 100644 index 00000000..dd3b72f2 --- /dev/null +++ b/app/migrations/050_llm_gateway_settings.py @@ -0,0 +1,48 @@ +""" +Migration: Add per-organization LLM gateway settings JSON column. +""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = "Add organizations.llm_gateway_settings JSON for per-org LLM gateway overrides" + + +def _column_exists(db: Session, column_name: str) -> bool: + row = db.execute( + text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'organizations' + AND column_name = :column_name + """ + ), + {"column_name": column_name}, + ).first() + return row is not None + + +def upgrade(db: Session): + if _column_exists(db, "llm_gateway_settings"): + print("Column llm_gateway_settings already exists on organizations, skipping...") + return + + db.execute( + text( + """ + ALTER TABLE organizations + ADD COLUMN llm_gateway_settings JSON + """ + ) + ) + + db.commit() + print("Added llm_gateway_settings column to organizations") + + +def downgrade(db: Session): + db.execute( + text("ALTER TABLE organizations DROP COLUMN IF EXISTS llm_gateway_settings") + ) + db.commit() diff --git a/app/migrations/051_rename_bifrost_gateway_settings_column.py b/app/migrations/051_rename_bifrost_gateway_settings_column.py new file mode 100644 index 00000000..bfc482d5 --- /dev/null +++ b/app/migrations/051_rename_bifrost_gateway_settings_column.py @@ -0,0 +1,72 @@ +""" +Migration: Rename legacy organizations.bifrost_gateway_settings column. + +For databases that applied an earlier 050 migration before the column was +renamed to llm_gateway_settings. +""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = ( + "Rename organizations.bifrost_gateway_settings to llm_gateway_settings" +) + + +def _column_exists(db: Session, column_name: str) -> bool: + row = db.execute( + text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'organizations' + AND column_name = :column_name + """ + ), + {"column_name": column_name}, + ).first() + return row is not None + + +def upgrade(db: Session): + if _column_exists(db, "llm_gateway_settings"): + print("Column llm_gateway_settings already exists, skipping rename...") + return + + if not _column_exists(db, "bifrost_gateway_settings"): + print("Legacy bifrost_gateway_settings column not found, skipping rename...") + return + + db.execute( + text( + """ + ALTER TABLE organizations + RENAME COLUMN bifrost_gateway_settings TO llm_gateway_settings + """ + ) + ) + db.commit() + print( + "Renamed organizations.bifrost_gateway_settings " + "to llm_gateway_settings" + ) + + +def downgrade(db: Session): + if _column_exists(db, "bifrost_gateway_settings"): + print("Column bifrost_gateway_settings already exists, skipping rename...") + return + + if not _column_exists(db, "llm_gateway_settings"): + print("Column llm_gateway_settings not found, skipping rename...") + return + + db.execute( + text( + """ + ALTER TABLE organizations + RENAME COLUMN llm_gateway_settings TO bifrost_gateway_settings + """ + ) + ) + db.commit() diff --git a/app/models/database.py b/app/models/database.py index 476fb7ed..baf9be8d 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -54,6 +54,8 @@ class Organization(Base): # Shape: {"min_labels_to_evaluate": int, "min_labels_to_optimize": int} # Falls back to system defaults (20 / 50) when null. judge_alignment_settings = Column(JSON, nullable=True) + # Per-org LLM gateway overrides (enabled, gateway_type, base_url, keys). + llm_gateway_settings = Column(JSON, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/app/models/schemas.py b/app/models/schemas.py index eaf315c2..ace77f74 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -669,7 +669,13 @@ class S3UploadResponse(BaseModel): class AIProviderCreate(BaseModel): """Schema for creating an AI Provider.""" provider: ModelProvider - api_key: str = Field(..., min_length=1) + api_key: Optional[str] = Field( + None, + description=( + "Provider API key. Optional when the platform uses Bifrost " + "gateway-managed credentials (passthrough_provider_keys: false)." + ), + ) name: Optional[str] = None is_default: Optional[bool] = Field( None, @@ -679,6 +685,14 @@ class AIProviderCreate(BaseModel): ), ) + @field_validator("api_key") + @classmethod + def validate_api_key(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + trimmed = v.strip() + return trimmed or None + class AIProviderUpdate(BaseModel): """Schema for updating an AI Provider.""" @@ -695,6 +709,7 @@ class AIProviderResponse(BaseModel): name: Optional[str] is_active: bool is_default: bool = False + gateway_managed: bool = False created_at: datetime updated_at: datetime last_tested_at: Optional[datetime] diff --git a/app/services/ai/llm_gateway.py b/app/services/ai/llm_gateway.py new file mode 100644 index 00000000..6c244a69 --- /dev/null +++ b/app/services/ai/llm_gateway.py @@ -0,0 +1,336 @@ +""" +LLM gateway resolver for batch/eval LiteLLM workloads. + +Supports Bifrost (/litellm proxy) and self-hosted LiteLLM Proxy gateways. +Platform defaults live in ``config.yml``; per-org overrides are stored in +``organizations.llm_gateway_settings`` JSON. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Literal, Optional +from urllib.parse import urlparse +from uuid import UUID + +from loguru import logger +from sqlalchemy.orm import Session + +from app.config import settings + + +GatewayType = Literal["bifrost", "litellm_proxy"] +EffectiveRouting = Literal["direct", "bifrost", "litellm_proxy"] + +# Backward-compatible alias used by stored AI provider credentials. +GATEWAY_MANAGED_KEY_SENTINEL = "__bifrost_gateway_managed__" +# LiteLLM's OpenAI client path requires a non-empty api_key even when +# api_base points at a gateway. Gateways authenticate via headers or master keys. +LITELLM_GATEWAY_PLACEHOLDER_API_KEY = "gateway-managed" + + +def gateway_managed_credentials_enabled() -> bool: + """True when the platform expects provider keys to live in the gateway only.""" + return not bool(settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS) + + +def is_gateway_managed_stored_key(encrypted_api_key: str) -> bool: + """Return True when the stored credential is the gateway-managed placeholder.""" + try: + from app.core.encryption import decrypt_api_key + + return decrypt_api_key(encrypted_api_key) == GATEWAY_MANAGED_KEY_SENTINEL + except Exception: + return False + + +def resolve_litellm_api_key( + organization_id: UUID, + db: Session, + ai_provider: Any, +) -> Optional[str]: + """Decrypt the org credential, or return None when the gateway supplies the key.""" + from app.core.encryption import decrypt_api_key + + try: + raw_key = decrypt_api_key(ai_provider.api_key) + except Exception as exc: + raise RuntimeError( + f"Failed to decrypt API key for provider {ai_provider.provider}: {exc}" + ) from exc + + if raw_key != GATEWAY_MANAGED_KEY_SENTINEL: + return raw_key + + gateway = resolve_llm_gateway(organization_id, db) + if gateway and not gateway.passthrough_provider_keys: + return None + + raise RuntimeError( + "AI provider is configured for gateway-managed credentials, " + "but the LLM gateway is not active for this organization. " + "Enable the LLM Gateway or add a provider API key." + ) + + +@dataclass(frozen=True) +class LLMGatewayConfig: + """Resolved gateway settings for a single LiteLLM call.""" + + gateway_type: GatewayType + api_base: str + virtual_key: Optional[str] = None + master_key: Optional[str] = None + passthrough_provider_keys: bool = True + + +def normalize_bifrost_url(base_url: str) -> str: + """Ensure the Bifrost LiteLLM proxy URL is well-formed.""" + url = (base_url or "").strip().rstrip("/") + if not url: + return "" + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + raise ValueError( + "Bifrost base_url must be an absolute URL including scheme and host " + "(e.g. http://localhost:8080/litellm)." + ) + if not url.endswith("/litellm"): + logger.warning( + "Bifrost base_url '{}' does not end with '/litellm'; appending suffix.", + url, + ) + url = f"{url}/litellm" + return url + + +def normalize_litellm_proxy_url(base_url: str) -> str: + """Ensure a LiteLLM Proxy URL is well-formed.""" + url = (base_url or "").strip().rstrip("/") + if not url: + return "" + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + raise ValueError( + "LiteLLM proxy base_url must be an absolute URL including scheme and host " + "(e.g. http://localhost:4000)." + ) + return url + + +def _normalize_base_url(base_url: str, gateway_type: GatewayType) -> str: + if gateway_type == "bifrost": + return normalize_bifrost_url(base_url) + return normalize_litellm_proxy_url(base_url) + + +def _platform_config() -> Dict[str, Any]: + gateway_type = (settings.LLM_GATEWAY_TYPE or "bifrost").strip().lower() + if gateway_type not in ("bifrost", "litellm_proxy"): + gateway_type = "bifrost" + return { + "enabled": bool(settings.LLM_GATEWAY_ENABLED), + "gateway_type": gateway_type, + "base_url": (settings.LLM_GATEWAY_BASE_URL or "").strip() or None, + "virtual_key": (settings.LLM_GATEWAY_VIRTUAL_KEY or "").strip() or None, + "master_key": (settings.LLM_GATEWAY_MASTER_KEY or "").strip() or None, + "passthrough_provider_keys": bool(settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS), + } + + +def _get_org_raw_settings(organization_id: UUID, db: Session) -> Dict[str, Any]: + from app.models.database import Organization + + org = db.query(Organization).filter(Organization.id == organization_id).first() + if not org: + return {} + raw = org.llm_gateway_settings + return dict(raw) if isinstance(raw, dict) else {} + + +def _decrypt_org_secret(raw: Dict[str, Any], field: str) -> Optional[str]: + encrypted = raw.get(field) + if not encrypted: + return None + try: + from app.core.encryption import decrypt_api_key + + return decrypt_api_key(encrypted) + except Exception as exc: + logger.warning("Failed to decrypt org LLM gateway {}: {}", field, exc) + return None + + +def _decrypt_org_virtual_key(raw: Dict[str, Any]) -> Optional[str]: + return _decrypt_org_secret(raw, "virtual_key") + + +def _decrypt_org_master_key(raw: Dict[str, Any]) -> Optional[str]: + return _decrypt_org_secret(raw, "master_key") + + +def _resolve_gateway_type(org: Dict[str, Any], platform: Dict[str, Any]) -> GatewayType: + org_type = org.get("gateway_type") + if org_type in ("bifrost", "litellm_proxy"): + return org_type + platform_type = platform.get("gateway_type", "bifrost") + if platform_type in ("bifrost", "litellm_proxy"): + return platform_type + return "bifrost" + + +def resolve_llm_gateway( + organization_id: UUID, + db: Session, +) -> Optional[LLMGatewayConfig]: + """Return effective LLM gateway config, or ``None`` for direct routing.""" + platform = _platform_config() + org = _get_org_raw_settings(organization_id, db) + + org_enabled = org.get("enabled") + if org_enabled is False: + return None + + if org_enabled is True: + use_gateway = True + elif platform["enabled"]: + use_gateway = True + else: + return None + + if not use_gateway: + return None + + gateway_type = _resolve_gateway_type(org, platform) + + base_url = (org.get("base_url") or platform["base_url"] or "").strip() + if not base_url: + logger.warning( + "LLM gateway ({}) enabled for org {} but no base_url is configured; " + "falling back to direct provider routing.", + gateway_type, + organization_id, + ) + return None + + try: + api_base = _normalize_base_url(base_url, gateway_type) + except ValueError as exc: + logger.warning( + "Invalid LLM gateway base_url for org {} ({}): {}; falling back to direct routing.", + organization_id, + gateway_type, + exc, + ) + return None + + virtual_key = _decrypt_org_virtual_key(org) or platform["virtual_key"] + master_key = _decrypt_org_master_key(org) or platform["master_key"] + + return LLMGatewayConfig( + gateway_type=gateway_type, + api_base=api_base, + virtual_key=virtual_key, + master_key=master_key, + passthrough_provider_keys=platform["passthrough_provider_keys"], + ) + + +# Providers whose LiteLLM handlers build native API paths (e.g. Gemini +# ``:generateContent``) when ``api_base`` is set. Gateways like Bifrost and +# LiteLLM Proxy expect OpenAI-compatible ``/v1/chat/completions`` instead. +_NATIVE_GATEWAY_MODEL_PREFIXES = ( + "gemini/", + "google/", + "vertex/", + "vertex_ai/", +) + + +def _model_uses_native_provider_path(model: Any) -> bool: + model_str = str(model or "").lower() + return any(model_str.startswith(prefix) for prefix in _NATIVE_GATEWAY_MODEL_PREFIXES) + + +def _apply_proxy_compatible_routing(call_kwargs: Dict[str, Any]) -> Dict[str, Any]: + """Route native-path providers through the gateway's chat-completions API.""" + result = dict(call_kwargs) + if _model_uses_native_provider_path(result.get("model")): + result["custom_llm_provider"] = "openai" + return result + + +def _strip_provider_keys(result: Dict[str, Any]) -> None: + stored_key = result.get("api_key") + if stored_key == GATEWAY_MANAGED_KEY_SENTINEL: + result.pop("api_key", None) + elif stored_key is not None: + result.pop("api_key", None) + + +def _apply_bifrost_gateway(call_kwargs: Dict[str, Any], config: LLMGatewayConfig) -> Dict[str, Any]: + result = dict(call_kwargs) + result["api_base"] = config.api_base + + if config.virtual_key: + extra_headers = dict(result.get("extra_headers") or {}) + extra_headers["x-bf-vk"] = config.virtual_key + result["extra_headers"] = extra_headers + + if not config.passthrough_provider_keys: + _strip_provider_keys(result) + + if not result.get("api_key"): + result["api_key"] = config.virtual_key or LITELLM_GATEWAY_PLACEHOLDER_API_KEY + + model = result.get("model", "") + if model and "/" not in str(model): + logger.warning( + "Routing model '{}' through Bifrost without a provider prefix; " + "ensure the model is supported by both LiteLLM and Bifrost.", + model, + ) + + return _apply_proxy_compatible_routing(result) + + +def _apply_litellm_proxy_gateway(call_kwargs: Dict[str, Any], config: LLMGatewayConfig) -> Dict[str, Any]: + result = dict(call_kwargs) + result["api_base"] = config.api_base + + if not config.passthrough_provider_keys: + _strip_provider_keys(result) + + if not result.get("api_key"): + result["api_key"] = config.master_key or LITELLM_GATEWAY_PLACEHOLDER_API_KEY + + return _apply_proxy_compatible_routing(result) + + +def apply_llm_gateway( + call_kwargs: Dict[str, Any], + *, + organization_id: UUID, + db: Session, +) -> Dict[str, Any]: + """Merge gateway proxy settings into LiteLLM ``completion`` kwargs.""" + config = resolve_llm_gateway(organization_id, db) + if config is None: + return call_kwargs + + if config.gateway_type == "bifrost": + return _apply_bifrost_gateway(call_kwargs, config) + return _apply_litellm_proxy_gateway(call_kwargs, config) + + +def litellm_completion( + *, + organization_id: UUID, + db: Session, + **kwargs: Any, +): + """Call ``litellm.completion`` with LLM gateway settings applied.""" + import litellm + + kwargs = apply_llm_gateway(kwargs, organization_id=organization_id, db=db) + return litellm.completion(**kwargs) diff --git a/app/services/ai/llm_gateway_settings.py b/app/services/ai/llm_gateway_settings.py new file mode 100644 index 00000000..42662ddc --- /dev/null +++ b/app/services/ai/llm_gateway_settings.py @@ -0,0 +1,187 @@ +""" +Per-organization LLM gateway settings. + +Backed by ``organizations.llm_gateway_settings`` JSON. +Virtual keys and LiteLLM proxy master keys are encrypted at rest. +""" + +from __future__ import annotations + +from typing import Any, Dict, Literal, Optional +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.database import Organization +from app.services.ai.llm_gateway import ( + EffectiveRouting, + GatewayType, + _platform_config, + gateway_managed_credentials_enabled, + normalize_bifrost_url, + normalize_litellm_proxy_url, + resolve_llm_gateway, +) + +GatewayMode = Literal["inherit", "enabled", "disabled"] +GatewayTypeOverride = Literal["inherit", "bifrost", "litellm_proxy"] + + +def _mode_from_enabled(enabled: Any) -> GatewayMode: + if enabled is True: + return "enabled" + if enabled is False: + return "disabled" + return "inherit" + + +def _enabled_from_mode(mode: GatewayMode) -> Optional[bool]: + if mode == "enabled": + return True + if mode == "disabled": + return False + return None + + +def _gateway_type_from_stored(raw: Dict[str, Any]) -> GatewayTypeOverride: + stored = raw.get("gateway_type") + if stored in ("bifrost", "litellm_proxy"): + return stored + return "inherit" + + +def _effective_routing(resolved: Any) -> EffectiveRouting: + if resolved is None: + return "direct" + return resolved.gateway_type + + +def _normalize_url_for_type(base_url: str, gateway_type: GatewayType) -> str: + if gateway_type == "bifrost": + return normalize_bifrost_url(base_url) + return normalize_litellm_proxy_url(base_url) + + +def _resolve_effective_gateway_type( + org_raw: Dict[str, Any], platform: Dict[str, Any] +) -> GatewayType: + org_type = org_raw.get("gateway_type") + if org_type in ("bifrost", "litellm_proxy"): + return org_type + platform_type = platform.get("gateway_type", "bifrost") + if platform_type in ("bifrost", "litellm_proxy"): + return platform_type + return "bifrost" + + +def get_org_settings(organization_id: UUID, db: Session) -> Dict[str, Any]: + """Return stored org settings merged with effective resolved routing.""" + org = db.query(Organization).filter(Organization.id == organization_id).first() + raw = (org.llm_gateway_settings or {}) if org else {} + + mode = _mode_from_enabled(raw.get("enabled")) + stored_base_url = raw.get("base_url") + has_virtual_key = bool(raw.get("virtual_key")) + has_master_key = bool(raw.get("master_key")) + gateway_type = _gateway_type_from_stored(raw) + + resolved = resolve_llm_gateway(organization_id, db) + platform = _platform_config() + effective_type = _resolve_effective_gateway_type(raw, platform) + + return { + "mode": mode, + "gateway_type": gateway_type, + "base_url": stored_base_url, + "has_virtual_key": has_virtual_key, + "has_master_key": has_master_key, + "platform_enabled": platform["enabled"], + "platform_gateway_type": platform["gateway_type"], + "platform_base_url": platform["base_url"], + "effective_routing": _effective_routing(resolved), + "effective_gateway_type": effective_type if resolved else None, + "effective_base_url": resolved.api_base if resolved else None, + "effective_has_virtual_key": bool(resolved and resolved.virtual_key), + "effective_has_master_key": bool(resolved and resolved.master_key), + "gateway_managed_credentials": gateway_managed_credentials_enabled(), + } + + +def set_org_settings( + organization_id: UUID, + db: Session, + *, + mode: GatewayMode, + gateway_type: GatewayTypeOverride = "inherit", + base_url: Optional[str] = None, + virtual_key: Optional[str] = None, + master_key: Optional[str] = None, + clear_virtual_key: bool = False, + clear_master_key: bool = False, +) -> Dict[str, Any]: + """Validate and persist org-level LLM gateway overrides.""" + org = db.query(Organization).filter(Organization.id == organization_id).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + existing = dict(org.llm_gateway_settings or {}) + platform = _platform_config() + payload: Dict[str, Any] = { + "enabled": _enabled_from_mode(mode), + } + + if gateway_type == "inherit": + payload["gateway_type"] = None + else: + payload["gateway_type"] = gateway_type + + effective_type_for_validation: GatewayType + if gateway_type in ("bifrost", "litellm_proxy"): + effective_type_for_validation = gateway_type + else: + effective_type_for_validation = _resolve_effective_gateway_type({}, platform) + + if base_url is not None: + trimmed = base_url.strip() + if trimmed: + try: + _normalize_url_for_type(trimmed, effective_type_for_validation) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + payload["base_url"] = trimmed + else: + payload["base_url"] = None + elif "base_url" in existing: + payload["base_url"] = existing.get("base_url") + + if clear_virtual_key: + payload["virtual_key"] = None + elif virtual_key is not None: + trimmed_key = virtual_key.strip() + if trimmed_key: + from app.core.encryption import encrypt_api_key + + payload["virtual_key"] = encrypt_api_key(trimmed_key) + else: + payload["virtual_key"] = None + elif "virtual_key" in existing: + payload["virtual_key"] = existing.get("virtual_key") + + if clear_master_key: + payload["master_key"] = None + elif master_key is not None: + trimmed_key = master_key.strip() + if trimmed_key: + from app.core.encryption import encrypt_api_key + + payload["master_key"] = encrypt_api_key(trimmed_key) + else: + payload["master_key"] = None + elif "master_key" in existing: + payload["master_key"] = existing.get("master_key") + + org.llm_gateway_settings = payload + db.commit() + + return get_org_settings(organization_id, db) diff --git a/app/services/ai/llm_service.py b/app/services/ai/llm_service.py index 407ee51d..38b1228f 100644 --- a/app/services/ai/llm_service.py +++ b/app/services/ai/llm_service.py @@ -20,6 +20,7 @@ from app.models.database import ModelProvider, AIProvider from app.services.credentials import resolve_ai_provider from app.services.ai.llm_generation_config import build_litellm_kwargs +from app.services.ai.llm_gateway import apply_llm_gateway, resolve_litellm_api_key # LiteLLM will silently drop params the target provider doesn't support # rather than raising an error. @@ -216,14 +217,7 @@ def generate_response( f"AI provider {llm_provider} not configured for this organization." ) - from app.core.encryption import decrypt_api_key - - try: - api_key = decrypt_api_key(ai_provider.api_key) - except Exception as e: - raise RuntimeError( - f"Failed to decrypt API key for provider {llm_provider}: {e}" - ) + api_key = resolve_litellm_api_key(organization_id, db, ai_provider) # --- call LiteLLM -------------------------------------------------- model_str = self._litellm_model_name(llm_provider, llm_model) @@ -231,9 +225,10 @@ def generate_response( call_kwargs: Dict[str, Any] = { "model": model_str, "messages": messages, - "api_key": api_key, "temperature": temperature, } + if api_key is not None: + call_kwargs["api_key"] = api_key # Gemini "thinking" families (2.5 + 3.x) ship with reasoning # enabled by default. For structured-JSON workloads (the # diariser and evaluator) chain-of-thought is wasted output @@ -265,6 +260,12 @@ def generate_response( if config: call_kwargs.update(config) + call_kwargs = apply_llm_gateway( + call_kwargs, + organization_id=organization_id, + db=db, + ) + try: response = litellm.completion(**call_kwargs) except Exception as e: diff --git a/app/services/ai/stt_clients/google.py b/app/services/ai/stt_clients/google.py index 2c1b09d3..7e335818 100644 --- a/app/services/ai/stt_clients/google.py +++ b/app/services/ai/stt_clients/google.py @@ -24,6 +24,7 @@ import logging from pathlib import Path from typing import Any, Dict, Optional +from uuid import UUID logger = logging.getLogger(__name__) @@ -85,8 +86,11 @@ def _build_transcription_prompt(language: Optional[str]) -> str: def transcribe_google( audio_file_path: str, model: str, - api_key: str, + api_key: Optional[str] = None, language: Optional[str] = None, + *, + organization_id: Optional["UUID"] = None, + db: Optional[Any] = None, ) -> Dict[str, Any]: """Transcribe an audio file via Gemini through LiteLLM. @@ -122,12 +126,22 @@ def transcribe_google( ] try: - response = litellm.completion( - model=litellm_model, - messages=messages, - api_key=api_key, - temperature=0.0, - ) + call_kwargs: Dict[str, Any] = { + "model": litellm_model, + "messages": messages, + "temperature": 0.0, + } + if api_key is not None: + call_kwargs["api_key"] = api_key + if organization_id is not None and db is not None: + from app.services.ai.llm_gateway import apply_llm_gateway + + call_kwargs = apply_llm_gateway( + call_kwargs, + organization_id=organization_id, + db=db, + ) + response = litellm.completion(**call_kwargs) except Exception as e: logger.error( f"[transcribe_google] LiteLLM call failed for {litellm_model}: {e}" diff --git a/app/services/ai/transcription_service.py b/app/services/ai/transcription_service.py index b613a9c2..3fcb4d65 100644 --- a/app/services/ai/transcription_service.py +++ b/app/services/ai/transcription_service.py @@ -47,6 +47,7 @@ from pathlib import Path from app.models.database import ModelProvider, AIProvider, Integration +from app.core.encryption import decrypt_api_key from app.services.credentials import resolve_ai_provider, resolve_integration from app.services.storage.s3_service import s3_service from app.core.exceptions import StorageError @@ -89,13 +90,13 @@ def _get_api_key_for_provider( preferred in either table; otherwise the row marked ``is_default`` wins, with a back-compat fallback to the most recent active row. """ - from app.core.encryption import decrypt_api_key + from app.services.ai.bifrost_gateway import resolve_litellm_api_key ai_provider = self._get_ai_provider( provider, db, organization_id, credential_id=credential_id ) if ai_provider: - return decrypt_api_key(ai_provider.api_key) + return resolve_litellm_api_key(organization_id, db, ai_provider) integration = resolve_integration( provider, db, organization_id, credential_id=credential_id @@ -507,7 +508,7 @@ def transcribe_text_only( api_key = self._get_api_key_for_provider( stt_provider, db, organization_id, credential_id=credential_id ) - if not api_key: + if not api_key and stt_provider != ModelProvider.GOOGLE: logger.warning( f"[TranscriptionService] No API key found for {stt_provider} " f"(checked AIProvider and Integration tables) for org {organization_id}" @@ -531,7 +532,10 @@ def transcribe_text_only( elif stt_provider == ModelProvider.ELEVENLABS: result = transcribe_elevenlabs(audio_file_path, stt_model, api_key, language) elif stt_provider == ModelProvider.GOOGLE: - result = transcribe_google(audio_file_path, stt_model, api_key, language) + result = transcribe_google( + audio_file_path, stt_model, api_key, language, + organization_id=organization_id, db=db, + ) elif stt_provider == ModelProvider.SARVAM: result = transcribe_sarvam(audio_file_path, stt_model, api_key, language) elif stt_provider == ModelProvider.SMALLEST: @@ -570,7 +574,7 @@ def transcribe( api_key = self._get_api_key_for_provider( stt_provider, db, organization_id, credential_id=credential_id ) - if not api_key: + if not api_key and stt_provider != ModelProvider.GOOGLE: raise RuntimeError( f"No API key found for {stt_provider} (checked AIProvider and Integration tables). " f"Please configure the provider in Settings." @@ -608,7 +612,10 @@ def transcribe( # are routed through LiteLLM as multimodal completions. # Legacy ``google-speech-v2`` would also land here, but # we don't yet have a Cloud Speech client wired up. - result = transcribe_google(temp_file_path, stt_model, api_key, language) + result = transcribe_google( + temp_file_path, stt_model, api_key, language, + organization_id=organization_id, db=db, + ) elif stt_provider == ModelProvider.AZURE: raise NotImplementedError("Azure Speech Services not yet implemented") elif stt_provider == ModelProvider.AWS: diff --git a/app/services/judge_alignment/gepa_bridge.py b/app/services/judge_alignment/gepa_bridge.py index 84d8deb1..1ea8e967 100644 --- a/app/services/judge_alignment/gepa_bridge.py +++ b/app/services/judge_alignment/gepa_bridge.py @@ -25,13 +25,13 @@ from typing import Any, Dict, List, Optional, Tuple from uuid import UUID -import litellm from loguru import logger from sqlalchemy.orm import Session -from app.core.encryption import decrypt_api_key +from app.services.ai.llm_gateway import apply_llm_gateway, litellm_completion, resolve_litellm_api_key +from app.services.credentials import resolve_ai_provider + from app.models.database import ( - AIProvider, Evaluator, JudgeDataset, JudgeRun, @@ -59,19 +59,11 @@ def _resolve_litellm_model(provider: str, model: str) -> str: def _resolve_api_key( provider: str, organization_id: UUID, db: Session -) -> str: - ai_provider = ( - db.query(AIProvider) - .filter( - AIProvider.organization_id == organization_id, - AIProvider.provider == provider, - AIProvider.is_active == True, # noqa: E712 - ) - .first() - ) +) -> Optional[str]: + ai_provider = resolve_ai_provider(provider, db, organization_id) if not ai_provider: raise RuntimeError(f"No active AIProvider for {provider!r}") - return decrypt_api_key(ai_provider.api_key) + return resolve_litellm_api_key(organization_id, db, ai_provider) def start_gepa_for_dataset( @@ -247,13 +239,17 @@ def _evaluate_prompt(candidate_prompt: str) -> float: }, ] try: - resp = litellm.completion( - model=lm_identifier, - messages=messages, - api_key=api_key, - temperature=0.0, - max_tokens=300, - ) + sample_kwargs: Dict[str, Any] = { + "organization_id": run.organization_id, + "db": db, + "model": lm_identifier, + "messages": messages, + "temperature": 0.0, + "max_tokens": 300, + } + if api_key is not None: + sample_kwargs["api_key"] = api_key + resp = litellm_completion(**sample_kwargs) text = resp.choices[0].message.content if resp.choices else "" parsed = _parse_judge_response(text) except Exception as exc: @@ -300,10 +296,19 @@ def evaluator_fn(data: Dict[str, Any], response: str) -> Any: } ] + batch_kwargs: Dict[str, Any] = {} + if api_key is not None: + batch_kwargs["api_key"] = api_key + batch_kwargs = apply_llm_gateway( + batch_kwargs, + organization_id=run.organization_id, + db=db, + ) + adapter = DefaultAdapter( model=lm_identifier, evaluator=evaluator_fn, - litellm_batch_completion_kwargs={"api_key": api_key}, + litellm_batch_completion_kwargs=batch_kwargs, ) # Custom evaluator wrapper to inject the candidate prompt into `data` @@ -321,11 +326,15 @@ def _capturing_evaluator(data: Dict[str, Any], response: str): adapter.evaluator = _capturing_evaluator # type: ignore[attr-defined] def reflection_lm(prompt: str) -> str: - resp = litellm.completion( - model=lm_identifier, - messages=[{"role": "user", "content": prompt}], - api_key=api_key, - ) + reflection_kwargs: Dict[str, Any] = { + "organization_id": run.organization_id, + "db": db, + "model": lm_identifier, + "messages": [{"role": "user", "content": prompt}], + } + if api_key is not None: + reflection_kwargs["api_key"] = api_key + resp = litellm_completion(**reflection_kwargs) return resp.choices[0].message.content run.status = PromptOptimizationStatus.RUNNING.value diff --git a/app/services/optimization/gepa_service.py b/app/services/optimization/gepa_service.py index 35a344b4..d2ae9c8d 100644 --- a/app/services/optimization/gepa_service.py +++ b/app/services/optimization/gepa_service.py @@ -9,7 +9,6 @@ from typing import Any, Dict, List, Optional from uuid import UUID -import litellm from loguru import logger from app.models.database import ( @@ -23,6 +22,7 @@ from app.services.optimization.data_preparation import build_trainset from app.services.optimization.evaluator import build_evaluator from app.services.optimization.lm_resolver import resolve_api_key, resolve_lm +from app.services.ai.llm_gateway import apply_llm_gateway, litellm_completion _gepa_install_attempted = False @@ -96,7 +96,7 @@ def run_optimization( seed_prompt = agent.provider_prompt or agent.description or "" lm_identifier = resolve_lm(voice_bundle, evaluator) - api_key = resolve_api_key(lm_identifier, ai_providers) + api_key = resolve_api_key(lm_identifier, ai_providers, organization_id, db) trainset = build_trainset(training_data, metrics) if not trainset: @@ -109,18 +109,31 @@ def run_optimization( f"with {len(trainset)} training examples, LM={lm_identifier}" ) + batch_kwargs: Dict[str, Any] = {} + if api_key is not None: + batch_kwargs["api_key"] = api_key + batch_kwargs = apply_llm_gateway( + batch_kwargs, + organization_id=organization_id, + db=db, + ) + adapter = DefaultAdapter( model=lm_identifier, evaluator=evaluator_fn, - litellm_batch_completion_kwargs={"api_key": api_key}, + litellm_batch_completion_kwargs=batch_kwargs, ) def reflection_lm(prompt: str) -> str: - resp = litellm.completion( - model=lm_identifier, - messages=[{"role": "user", "content": prompt}], - api_key=api_key, - ) + reflection_kwargs: Dict[str, Any] = { + "organization_id": organization_id, + "db": db, + "model": lm_identifier, + "messages": [{"role": "user", "content": prompt}], + } + if api_key is not None: + reflection_kwargs["api_key"] = api_key + resp = litellm_completion(**reflection_kwargs) return resp.choices[0].message.content result = gepa_optimize( diff --git a/app/services/optimization/lm_resolver.py b/app/services/optimization/lm_resolver.py index 55e79534..18acaa8e 100644 --- a/app/services/optimization/lm_resolver.py +++ b/app/services/optimization/lm_resolver.py @@ -7,9 +7,12 @@ """ from typing import List, Optional +from uuid import UUID + +from sqlalchemy.orm import Session -from app.core.encryption import decrypt_api_key from app.models.database import AIProvider, Evaluator, VoiceBundle +from app.services.ai.llm_gateway import resolve_litellm_api_key def resolve_lm( @@ -27,17 +30,22 @@ def resolve_lm( return "openai/gpt-4o" -def resolve_api_key(lm_identifier: str, ai_providers: List[AIProvider]) -> str: +def resolve_api_key( + lm_identifier: str, + ai_providers: List[AIProvider], + organization_id: UUID, + db: Session, +) -> Optional[str]: """ Given ``"openai/gpt-5.4"`` and the org's provider list, decrypt and - return the matching API key. + return the matching API key, or None when Bifrost manages provider keys. """ provider_prefix = lm_identifier.split("/")[0].lower() for p in ai_providers: if not p.is_active or not p.api_key: continue if p.provider.lower() == provider_prefix: - return decrypt_api_key(p.api_key) + return resolve_litellm_api_key(organization_id, db, p) raise RuntimeError( f"No active AI provider matching '{provider_prefix}' found. " "Add one in Settings > AI Providers." diff --git a/app/workers/tasks/helpers/llm_diarisation.py b/app/workers/tasks/helpers/llm_diarisation.py index ef9c3673..20040cde 100644 --- a/app/workers/tasks/helpers/llm_diarisation.py +++ b/app/workers/tasks/helpers/llm_diarisation.py @@ -750,6 +750,10 @@ def _upload_audio_to_gemini_files( / retry surface as the eventual ``litellm.completion`` call, and the returned ``file.id`` is already in the exact shape LiteLLM's Gemini transformation expects to see in a ``file`` content part. + * Bifrost gateway routing is **not** applied here — the Gemini Files + API upload goes directly to Google. Only the subsequent + ``litellm.completion`` diarisation call is proxied when Bifrost + is enabled (via ``LLMService``). * The ``(filename, bytes, content_type)`` tuple form is mandatory — passing raw bytes makes LiteLLM default the Content-Type header to ``application/octet-stream``, which Gemini then rejects as diff --git a/config.docker.yml b/config.docker.yml index a1a7e964..b48d5c77 100644 --- a/config.docker.yml +++ b/config.docker.yml @@ -115,6 +115,15 @@ judge_alignment: enabled: true csv_max_rows: 5000 +# LLM gateway (optional). Per-org overrides in Integrations UI. +# llm_gateway: +# enabled: false +# type: bifrost # bifrost | litellm_proxy +# base_url: "http://localhost:8080/litellm" +# virtual_key: null +# master_key: null +# passthrough_provider_keys: true + # Enterprise License (JWT signed with RS256). Unlocks gated features like # oidc_sso, mfa_enforce, audit_export, voice_playground, gepa_optimization. # license: diff --git a/config.yml.example b/config.yml.example index 32a1dd02..a61a79a6 100644 --- a/config.yml.example +++ b/config.yml.example @@ -166,6 +166,17 @@ judge_alignment: enabled: true # operator-level kill switch csv_max_rows: 5000 # safety limit for CSV uploads +# LLM gateway (optional). Routes batch/eval LiteLLM calls through a proxy +# instead of directly to providers. Supports Bifrost and self-hosted LiteLLM Proxy. +# Per-org overrides (type, URL, keys, opt-out) are configured in Integrations UI. +# llm_gateway: +# enabled: false +# type: bifrost # bifrost | litellm_proxy +# base_url: "http://localhost:8080/litellm" +# virtual_key: null # Bifrost only (x-bf-vk header) +# master_key: null # LiteLLM Proxy only (LITELLM_MASTER_KEY) +# passthrough_provider_keys: true # false = keys in gateway; AI integrations can omit api_key + # Enterprise License (JWT signed with RS256). Unlocks gated features like # oidc_sso, mfa_enforce, audit_export, voice_playground, gepa_optimization, # call_imports. diff --git a/docs-fumadocs/content/docs/getting-started/integrations.mdx b/docs-fumadocs/content/docs/getting-started/integrations.mdx index 9c52bbd3..a3df025f 100644 --- a/docs-fumadocs/content/docs/getting-started/integrations.mdx +++ b/docs-fumadocs/content/docs/getting-started/integrations.mdx @@ -88,6 +88,103 @@ Telephony integrations connect your phone-network provider to EfficientAI. These integrations handle telephony-specific setup like credentials, number sync, and routing-related configuration used by call workflows. +## LLM Gateway (batch/eval routing) + +Batch and evaluation LLM workloads (evaluators, GEPA, call-import LLM steps) can be routed through an optional **LLM Gateway** instead of calling providers directly. Real-time voice agents are **not** routed through the gateway. + +Configure a **platform default** in `config.yml` and optional **per-organization overrides** from **Configurations → Integrations → LLM Gateway**. + +Supported gateway types: + +- **Bifrost** — point at your Bifrost LiteLLM proxy (URL must include the `/litellm` path, e.g. `http://localhost:8080/litellm`). +- **LiteLLM Proxy** — point at a self-hosted LiteLLM Proxy instance (e.g. `http://localhost:4000`). + +### Platform default (`config.yml`) + +```yaml +llm_gateway: + enabled: true + type: bifrost # bifrost | litellm_proxy + base_url: "http://localhost:8080/litellm" + virtual_key: null # Bifrost only (sent as x-bf-vk) + master_key: null # LiteLLM Proxy only + passthrough_provider_keys: false # false = provider keys live in the gateway +``` + +When `passthrough_provider_keys` is `false`, AI provider integrations can omit API keys and use a **Gateway managed** placeholder instead. That setting is **platform-wide only** — it cannot be overridden per organization. + +### Organization settings (Integrations UI) + +Each organization can choose how it uses the gateway. The modal shows an **Effective routing** badge after save — that is the merged result actually used for LLM calls (`Bifrost`, `LiteLLM Proxy`, or `Direct`). + +#### Organization mode + +| Mode | Behavior | +|------|----------| +| **Inherit platform default** | Follows the platform `llm_gateway.enabled` flag. When the platform has the gateway enabled, this org uses the gateway. Choose **Disabled** to opt out, or override type, URL, or keys below. | +| **Enabled (use gateway)** | Forces gateway routing for this org even if the platform default is disabled. You must have a resolvable base URL (org override or platform default). | +| **Disabled (direct to providers)** | Opts this org out entirely. LLM calls go straight to provider APIs; gateway type, URL, and key overrides are ignored. | + +#### Partial overrides (while inheriting) + +When organization mode is **Inherit** or **Enabled**, you can override individual fields without replacing the whole configuration. Anything left blank or set to **Inherit platform** falls back to the platform default. + +| Field | Inherit / blank | Org override | +|-------|-----------------|--------------| +| **Gateway type** | Platform `type` | Org value (`Bifrost` or `LiteLLM Proxy`) | +| **Base URL** | Platform `base_url` | Org URL | +| **Virtual key** (Bifrost) | Platform `virtual_key` | Org-stored key (org wins if set) | +| **Master key** (LiteLLM Proxy) | Platform `master_key` | Org-stored key (org wins if set) | +| **Passthrough provider keys** | Platform setting only | Not configurable per org | + +**Important:** Inherit mode does **not** mean “ignore my overrides.” You can inherit platform enablement while still overriding gateway type, base URL, or keys. Those overrides are applied on top of the inherited on/off decision. + +If organization mode is **Inherit** but the platform has the gateway **disabled**, the org routes **direct** regardless of any stored type or URL overrides (overrides remain saved but inactive until the platform enables the gateway or you switch the org to **Enabled**). + +### How settings merge + +Resolution is field-by-field: + +1. **On/off** — org **Disabled** → direct. Org **Enabled** → gateway on. Org **Inherit** → follow platform `enabled`. +2. **Gateway type** — org override if set, else platform `type`. +3. **Base URL** — org override if set, else platform `base_url`. If no URL resolves, the org falls back to direct routing. +4. **Keys** — org virtual key / master key if stored, else platform keys. + +### Examples + +Platform default: + +```yaml +llm_gateway: + enabled: true + type: bifrost + base_url: "http://localhost:8080/litellm" + passthrough_provider_keys: false +``` + +| Org mode | Gateway type | Base URL override | Effective result | +|----------|--------------|-------------------|------------------| +| Inherit | Inherit | *(blank)* | Bifrost @ platform URL, platform keys | +| Inherit | Inherit | `http://customer:9090/litellm` | Bifrost @ org URL, platform keys | +| Inherit | LiteLLM Proxy | `http://org-proxy:4000` | LiteLLM Proxy @ org URL | +| Inherit | LiteLLM Proxy | *(blank)* | LiteLLM Proxy type + **platform URL** — override the URL too if the platform URL is Bifrost-specific | +| Enabled | Bifrost | `http://customer:9090/litellm` | Gateway forced on even if platform is disabled | +| Disabled | *(any)* | *(any)* | Direct to providers | + +### Practical guidance + +- **Different Bifrost instance for one customer?** Keep mode and gateway type on inherit; set **Base URL override** only. +- **Different gateway product for one org?** Override **both** gateway type and base URL so they match. +- **Different auth for one org?** Set an org **virtual key** (Bifrost) or **master key** (LiteLLM Proxy). Org keys take precedence over platform keys. +- **Confirm behavior** — after saving, check the **Effective routing** badge and resolved base URL shown in the LLM Gateway modal. + +### Scope and limitations + +- Routes batch/eval LiteLLM calls only (evaluators, GEPA, call-import LLM steps). +- Does not route real-time voice agent sessions. +- Gemini and other native-path providers are automatically sent through the gateway’s OpenAI-compatible chat-completions API (not provider-native URLs like `:generateContent`). +- File uploads used by some Gemini diarisation flows (`litellm.create_file`) are not proxied through the gateway. + ## What integrations enable in practice Once linked, an agent can: diff --git a/docs-fumadocs/content/docs/getting-started/llm-gateway.mdx b/docs-fumadocs/content/docs/getting-started/llm-gateway.mdx new file mode 100644 index 00000000..60a77c4b --- /dev/null +++ b/docs-fumadocs/content/docs/getting-started/llm-gateway.mdx @@ -0,0 +1,112 @@ +--- +id: llm-gateway +title: LLM Gateway +sidebar_position: 5 +--- + +# LLM Gateway + +EfficientAI routes batch and evaluation LLM workloads through [LiteLLM](https://docs.litellm.ai/). You can optionally point those calls at an enterprise or self-hosted gateway instead of calling provider APIs directly. + +Supported gateway types: + +| Type | Description | +|------|-------------| +| **Direct** | Default — LiteLLM calls providers using org AI Provider credentials | +| **Bifrost** | [Bifrost](https://docs.getbifrost.ai/) `/litellm` proxy with optional `x-bf-vk` virtual key | +| **LiteLLM Proxy** | Self-hosted [LiteLLM Proxy](https://docs.litellm.ai/docs/proxy/quick_start) with optional master key | + +## What is routed through the gateway + +When enabled, these workloads use the configured gateway: + +- Evaluators and metrics +- Call import transcription, diarisation, and evaluation LLM steps +- GEPA prompt optimization +- Gemini batch STT via LiteLLM + +**Not affected:** real-time voice agents (they use the EfficientAI SDK directly, not LiteLLM). + +## Platform configuration + +Operators enable a gateway globally in `config.yml`: + +```yaml +llm_gateway: + enabled: true + type: bifrost # bifrost | litellm_proxy + base_url: "http://localhost:8080/litellm" + virtual_key: null # Bifrost only (x-bf-vk header) + master_key: null # LiteLLM Proxy only (LITELLM_MASTER_KEY) + passthrough_provider_keys: false +``` + +| Setting | Description | +|---------|-------------| +| `enabled` | Platform-wide toggle for orgs that inherit the default | +| `type` | Gateway implementation: `bifrost` or `litellm_proxy` | +| `base_url` | Gateway endpoint (Bifrost: must end with `/litellm`; LiteLLM Proxy: e.g. `http://localhost:4000`) | +| `virtual_key` | Optional Bifrost `x-bf-vk` header | +| `master_key` | Optional LiteLLM Proxy master key | +| `passthrough_provider_keys` | When `true`, org AI provider API keys are still sent through the gateway | + +When routing through a gateway, Gemini models automatically use OpenAI-compatible proxy endpoints (`/v1/chat/completions`) instead of native Gemini `:generateContent` URLs. This is required for Bifrost and LiteLLM Proxy compatibility. + +### Gateway-managed credentials + +When `passthrough_provider_keys` is `false`, provider secrets live in the gateway. You can add AI provider integrations in EfficientAI **without an API key** — the integration row marks which provider to use, and the gateway supplies the secret at call time. Integrations created this way show a **Gateway managed** badge in the UI. + +Requirements: + +1. Platform `passthrough_provider_keys: false` +2. LLM gateway active for the organization (platform and/or org Integrations settings) +3. Real provider keys configured in the gateway + +EfficientAI sends a harmless placeholder `api_key` to satisfy LiteLLM's OpenAI client constructor; the gateway ignores it when provider keys are configured server-side. + +Voice agents and non-LiteLLM paths still require direct provider credentials. + +## Organization overrides (Integrations UI) + +On **Configurations → Integrations**, open **LLM Gateway** to configure per-organization settings: + +- **Inherit platform default** — follow `config.yml` +- **Enabled** — force routing through the gateway (optionally with org-specific type, URL, and keys) +- **Disabled** — opt out and call providers directly, even when the platform gateway is enabled + +Org virtual keys and LiteLLM Proxy master keys are encrypted at rest like AI provider credentials. + +## Provider compatibility + +Only providers supported by **both** LiteLLM and your gateway deployment work through the proxy. EfficientAI's LiteLLM integration covers OpenAI, Anthropic, Google/Gemini, Azure, AWS Bedrock, DeepSeek, Groq, xAI, and Fireworks. + +## Known limitations + +- **Gemini Files API uploads** (`litellm.create_file` used during LLM diarisation for large audio) go directly to Google. Only the subsequent `litellm.completion` diarisation call is proxied. +- **Voice agents** are not routed through the LLM gateway. + +## Example call shapes + +**Bifrost:** + +```python +litellm.completion( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Hello!"}], + api_base="http://localhost:8080/litellm", + extra_headers={"x-bf-vk": "your-virtual-key"}, +) +``` + +**LiteLLM Proxy:** + +```python +litellm.completion( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Hello!"}], + api_base="http://localhost:4000", + api_key="your-master-key", +) +``` + +EfficientAI injects `api_base`, headers, and keys automatically when the gateway is active. diff --git a/docs-fumadocs/content/docs/reference/configuration.mdx b/docs-fumadocs/content/docs/reference/configuration.mdx index df018df0..15e2778a 100644 --- a/docs-fumadocs/content/docs/reference/configuration.mdx +++ b/docs-fumadocs/content/docs/reference/configuration.mdx @@ -127,6 +127,16 @@ auth: # voice_playground, gepa_optimization, scim_provisioning, saml_sso. # license: # key: "eyJhbGciOi..." + +# LLM gateway (optional). Routes batch/eval LLM calls through a proxy. +# Per-org overrides in Integrations UI. +# llm_gateway: +# enabled: false +# type: bifrost # bifrost | litellm_proxy +# base_url: "http://localhost:8080/litellm" +# virtual_key: null +# master_key: null +# passthrough_provider_keys: true ``` :::tip diff --git a/frontend/public/geminiai.png b/frontend/public/geminiai.png index d8b9f5c352dc8421e539aa27feebf4001deda2e5..cd78455bc53be8db22b1b035beb5fa3e8e44ab00 100644 GIT binary patch literal 73628 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 F{{i=r-01)S literal 24746 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 D^Fi0y diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 83086ab6..d0cc42ab 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -169,6 +169,37 @@ export interface OrganizationSummary { name: string } +export type LLMGatewayMode = 'inherit' | 'enabled' | 'disabled' +export type LLMGatewayType = 'inherit' | 'bifrost' | 'litellm_proxy' +export type LLMGatewayRouting = 'direct' | 'bifrost' | 'litellm_proxy' + +export interface LLMGatewaySettings { + mode: LLMGatewayMode + gateway_type: LLMGatewayType + base_url?: string | null + has_virtual_key: boolean + has_master_key: boolean + platform_enabled: boolean + platform_gateway_type: 'bifrost' | 'litellm_proxy' + platform_base_url?: string | null + effective_routing: LLMGatewayRouting + effective_gateway_type?: 'bifrost' | 'litellm_proxy' | null + effective_base_url?: string | null + effective_has_virtual_key: boolean + effective_has_master_key: boolean + gateway_managed_credentials: boolean +} + +export interface LLMGatewaySettingsUpdate { + mode: LLMGatewayMode + gateway_type?: LLMGatewayType + base_url?: string | null + virtual_key?: string | null + master_key?: string | null + clear_virtual_key?: boolean + clear_master_key?: boolean +} + export interface TelephonyIntegrationResponse { id: string organization_id: string @@ -1003,6 +1034,16 @@ class ApiClient { return response.data } + async getLLMGatewaySettings(): Promise { + const response = await this.client.get('/api/v1/organizations/llm-gateway') + return response.data + } + + async updateLLMGatewaySettings(data: LLMGatewaySettingsUpdate): Promise { + const response = await this.client.put('/api/v1/organizations/llm-gateway', data) + return response.data + } + // IAM endpoints async getOrganization(): Promise { const response = await this.client.get('/api/v1/iam/organization') diff --git a/frontend/src/pages/configurations/Integrations.tsx b/frontend/src/pages/configurations/Integrations.tsx index bec11b6a..5a238fcb 100644 --- a/frontend/src/pages/configurations/Integrations.tsx +++ b/frontend/src/pages/configurations/Integrations.tsx @@ -3,8 +3,13 @@ import { apiClient } from '../../lib/api' import type { TelephonyIntegrationResponse } from '../../lib/api' import { useState, useEffect, useRef, type ReactNode } from 'react' import { createPortal } from 'react-dom' -import { Plus, Trash2, X, AlertCircle, Plug, Edit, Brain, ChevronDown, Phone, Star } from 'lucide-react' +import { Plus, Trash2, X, AlertCircle, Plug, Edit, Brain, ChevronDown, Phone, Star, Network } from 'lucide-react' import { IntegrationCreate, IntegrationPlatform, Integration, AIProvider, AIProviderCreate, ModelProvider, TelephonyProvider } from '../../types/api' +import type { + LLMGatewayMode, + LLMGatewaySettings, + LLMGatewayType, +} from '../../lib/api' import Button from '../../components/Button' import { useToast } from '../../hooks/useToast' import { @@ -69,6 +74,42 @@ export default function Integrations() { const [telephonySipDomain, setTelephonySipDomain] = useState('') const [telephonyProviderFilter, setTelephonyProviderFilter] = useState(TelephonyProvider.PLIVO) + const [llmGatewayMode, setLlmGatewayMode] = useState('inherit') + const [llmGatewayType, setLlmGatewayType] = useState('inherit') + const [llmGatewayBaseUrl, setLlmGatewayBaseUrl] = useState('') + const [llmGatewayVirtualKey, setLlmGatewayVirtualKey] = useState('') + const [llmGatewayMasterKey, setLlmGatewayMasterKey] = useState('') + const [clearLlmGatewayVirtualKey, setClearLlmGatewayVirtualKey] = useState(false) + const [clearLlmGatewayMasterKey, setClearLlmGatewayMasterKey] = useState(false) + const [showLlmGatewayModal, setShowLlmGatewayModal] = useState(false) + + const syncLlmGatewayFormFromSettings = () => { + if (!llmGatewaySettings) return + setLlmGatewayMode(llmGatewaySettings.mode) + setLlmGatewayType(llmGatewaySettings.gateway_type) + setLlmGatewayBaseUrl(llmGatewaySettings.base_url || '') + setLlmGatewayVirtualKey('') + setLlmGatewayMasterKey('') + setClearLlmGatewayVirtualKey(false) + setClearLlmGatewayMasterKey(false) + } + + const openLlmGatewayModal = () => { + syncLlmGatewayFormFromSettings() + setShowLlmGatewayModal(true) + } + + const closeLlmGatewayModal = () => { + setShowLlmGatewayModal(false) + syncLlmGatewayFormFromSettings() + } + + const llmGatewayRoutingLabel = (routing: LLMGatewaySettings['effective_routing']) => { + if (routing === 'bifrost') return 'Bifrost' + if (routing === 'litellm_proxy') return 'LiteLLM Proxy' + return 'Direct' + } + const renderModal = (content: ReactNode) => { if (typeof document === 'undefined') return null return createPortal(content, document.body) @@ -90,6 +131,51 @@ export default function Integrations() { retry: false, }) + const { data: llmGatewaySettings } = useQuery({ + queryKey: ['llm-gateway-settings'], + queryFn: () => apiClient.getLLMGatewaySettings(), + }) + + const effectiveLlmGatewayType = + llmGatewayType !== 'inherit' + ? llmGatewayType + : llmGatewaySettings?.platform_gateway_type || 'bifrost' + + const showLlmGatewayConfigOptions = llmGatewayMode !== 'disabled' + + useEffect(() => { + if (!llmGatewaySettings || showLlmGatewayModal) return + syncLlmGatewayFormFromSettings() + }, [llmGatewaySettings, showLlmGatewayModal]) + + const updateLlmGatewayMutation = useMutation({ + mutationFn: () => + apiClient.updateLLMGatewaySettings({ + mode: llmGatewayMode, + gateway_type: llmGatewayType, + base_url: llmGatewayBaseUrl.trim() || null, + virtual_key: llmGatewayVirtualKey.trim() || undefined, + master_key: llmGatewayMasterKey.trim() || undefined, + clear_virtual_key: clearLlmGatewayVirtualKey, + clear_master_key: clearLlmGatewayMasterKey, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['llm-gateway-settings'] }) + showToast('LLM gateway settings saved', 'success') + setLlmGatewayVirtualKey('') + setLlmGatewayMasterKey('') + setClearLlmGatewayVirtualKey(false) + setClearLlmGatewayMasterKey(false) + setShowLlmGatewayModal(false) + }, + onError: (error: any) => { + showToast( + `Failed to save LLM gateway settings: ${error.response?.data?.detail || error.message}`, + 'error', + ) + }, + }) + // Pick the visible config: default-row preferred, then any row, scoped to // the dropdown filter at the top of the section. const telephonyConfigsForFilter = allTelephonyConfigs.filter( @@ -277,8 +363,19 @@ export default function Integrations() { if (Object.keys(updateData).length === 0) { resetForm(); return } updateAIProviderMutation.mutate({ id: selectedAIProvider.id, data: updateData }) } else { - if (!selectedProvider || !apiKey.trim()) { showToast('Please select a provider and enter an API key', 'error'); return } - createAIProviderMutation.mutate({ provider: selectedProvider, api_key: apiKey, name: name || null }) + if (!selectedProvider) { + showToast('Please select a provider', 'error') + return + } + if (!aiIntegrationGatewayManaged && !apiKey.trim()) { + showToast('Please enter an API key', 'error') + return + } + createAIProviderMutation.mutate({ + provider: selectedProvider, + api_key: apiKey.trim() || undefined, + name: name || null, + }) } } else if (integrationType === 'telephony_provider') { saveTelephonyConfigMutation.mutate() @@ -365,6 +462,10 @@ export default function Integrations() { aiIntegrationProviders.length > 0 || hasTelephony + const aiIntegrationGatewayManaged = Boolean( + llmGatewaySettings?.gateway_managed_credentials, + ) + const getPlatformInfo = (platformId: IntegrationPlatform) => { return platforms.find(p => p.id === platformId) } @@ -378,6 +479,24 @@ export default function Integrations() {

Connect with voice AI platforms, AI providers, and telephony providers

+
@@ -506,6 +625,11 @@ export default function Integrations() { Default )} + {provider.gateway_managed && ( + + Gateway managed + + )} {!provider.is_active && Inactive} @@ -623,6 +747,176 @@ export default function Integrations() { )} + {showLlmGatewayModal && renderModal( +
+
+
+
+ +

LLM Gateway

+
+ +
+
+

+ Route batch and evaluation LLM calls through Bifrost or a self-hosted LiteLLM Proxy. Real-time voice agents are unaffected. +

+ +
+ + Effective routing: {llmGatewayRoutingLabel(llmGatewaySettings?.effective_routing || 'direct')} + + {llmGatewaySettings?.effective_base_url && ( + + {llmGatewaySettings.effective_base_url} + + )} +
+ +
+ + +
+ + {showLlmGatewayConfigOptions ? ( + <> +
+ + +
+ +
+ + setLlmGatewayBaseUrl(e.target.value)} + placeholder={ + llmGatewaySettings?.platform_base_url || + (effectiveLlmGatewayType === 'litellm_proxy' + ? 'http://localhost:4000' + : 'http://localhost:8080/litellm') + } + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" + /> +

+ {effectiveLlmGatewayType === 'litellm_proxy' + ? 'LiteLLM Proxy base URL (e.g. http://localhost:4000). Leave blank to inherit the platform URL.' + : 'Bifrost URL must include the /litellm path. Leave blank to inherit the platform URL.'} +

+
+ + {effectiveLlmGatewayType === 'bifrost' && ( +
+ + setLlmGatewayVirtualKey(e.target.value)} + placeholder={ + llmGatewaySettings?.has_virtual_key + ? '•••••••• (stored — enter new value to replace)' + : 'Optional Bifrost virtual key' + } + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" + /> + {llmGatewaySettings?.has_virtual_key && ( + + )} +
+ )} + + {effectiveLlmGatewayType === 'litellm_proxy' && ( +
+ + setLlmGatewayMasterKey(e.target.value)} + placeholder={ + llmGatewaySettings?.has_master_key + ? '•••••••• (stored — enter new value to replace)' + : 'Optional LiteLLM Proxy master key' + } + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" + /> + {llmGatewaySettings?.has_master_key && ( + + )} +
+ )} + + {llmGatewaySettings?.gateway_managed_credentials && ( +

+ Gateway-managed credentials are enabled. AI provider integrations can omit API keys when the gateway is active. +

+ )} + + ) : ( +

+ This organization will call LLM providers directly. Enable the gateway or inherit the platform default to configure gateway type, URL, and keys. +

+ )} + +
+ + +
+
+
+
+ )} + {showModal && renderModal(
@@ -761,11 +1055,34 @@ export default function Integrations() {
- setApiKey(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500" - placeholder={isEditMode ? "Enter new API key (optional)" : "Enter API key"} /> -

Your API key will be encrypted and stored securely

+ setApiKey(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500" + placeholder={ + isEditMode + ? 'Enter new API key (optional)' + : aiIntegrationGatewayManaged + ? 'Leave blank to use gateway-managed credentials' + : 'Enter API key' + } + /> +

+ {aiIntegrationGatewayManaged + ? 'When blank, provider secrets are resolved by your LLM gateway. You can still override with a local key if needed.' + : 'Your API key will be encrypted and stored securely'} +

)} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 6aa38c77..2d6d8ecf 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -288,6 +288,8 @@ export interface AIProvider { is_active: boolean /** True if this row is the default credential for (org, provider). */ is_default?: boolean + /** True when provider secrets are resolved by the Bifrost gateway. */ + gateway_managed?: boolean created_at: string updated_at: string last_tested_at?: string | null @@ -295,7 +297,7 @@ export interface AIProvider { export interface AIProviderCreate { provider: ModelProvider - api_key: string + api_key?: string | null name?: string | null /** Mark the new credential as the default for (org, provider). */ is_default?: boolean diff --git a/tests/conftest.py b/tests/conftest.py index bf507bc0..895dde42 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -347,6 +347,7 @@ class _TaskResult: alerts, audio, auth, + llm_gateway, call_import_evaluations, call_import_schemas, call_import_tags, @@ -394,6 +395,7 @@ class _TaskResult: app.include_router(audio.router, prefix="/api/v1") app.include_router(integrations.router, prefix="/api/v1") app.include_router(aiproviders.router, prefix="/api/v1") + app.include_router(llm_gateway.router, prefix="/api/v1") app.include_router(metrics.router, prefix="/api/v1") app.include_router(evaluator_results.router, prefix="/api/v1") app.include_router(voicebundles.router, prefix="/api/v1") diff --git a/tests/test_api/test_gateway_managed_credentials.py b/tests/test_api/test_gateway_managed_credentials.py new file mode 100644 index 00000000..e7f17cb4 --- /dev/null +++ b/tests/test_api/test_gateway_managed_credentials.py @@ -0,0 +1,67 @@ +"""API tests for gateway-managed AI provider credentials.""" + +import pytest + +from app.config import settings +from app.models.database import AIProvider +from app.services.ai.llm_gateway import is_gateway_managed_stored_key + + +def _set_platform_gateway_passthrough(passthrough: bool): + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS = passthrough + + +@pytest.fixture(autouse=True) +def _reset_gateway_settings(): + original = ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) + yield + ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) = original + + +def test_create_aiprovider_without_key_when_gateway_managed( + authenticated_client, db_session, org_id +): + _set_platform_gateway_passthrough(False) + + response = authenticated_client.post( + "/api/v1/aiproviders", + json={"provider": "openai", "name": "OpenAI via gateway"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["provider"] == "openai" + assert data["gateway_managed"] is True + + row = ( + db_session.query(AIProvider) + .filter(AIProvider.organization_id == org_id) + .first() + ) + assert row is not None + assert is_gateway_managed_stored_key(row.api_key) is True + + +def test_create_aiprovider_without_key_rejected_when_passthrough_enabled( + authenticated_client, +): + _set_platform_gateway_passthrough(True) + + response = authenticated_client.post( + "/api/v1/aiproviders", + json={"provider": "openai"}, + ) + assert response.status_code == 400 + + +def test_llm_gateway_settings_expose_gateway_managed_flag(authenticated_client): + _set_platform_gateway_passthrough(False) + + response = authenticated_client.get("/api/v1/organizations/llm-gateway") + assert response.status_code == 200 + assert response.json()["gateway_managed_credentials"] is True diff --git a/tests/test_api/test_llm_gateway_settings.py b/tests/test_api/test_llm_gateway_settings.py new file mode 100644 index 00000000..947e66c6 --- /dev/null +++ b/tests/test_api/test_llm_gateway_settings.py @@ -0,0 +1,120 @@ +"""API tests for organization LLM gateway settings.""" + +import pytest + +from app.config import settings +from app.models.database import Organization + + +def _set_platform_gateway(**kwargs): + settings.LLM_GATEWAY_ENABLED = kwargs.get("enabled", False) + settings.LLM_GATEWAY_TYPE = kwargs.get("gateway_type", "bifrost") + settings.LLM_GATEWAY_BASE_URL = kwargs.get("base_url") + settings.LLM_GATEWAY_VIRTUAL_KEY = kwargs.get("virtual_key") + settings.LLM_GATEWAY_MASTER_KEY = kwargs.get("master_key") + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS = kwargs.get("passthrough", True) + + +@pytest.fixture(autouse=True) +def _reset_gateway_settings(): + original = ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_TYPE, + settings.LLM_GATEWAY_BASE_URL, + settings.LLM_GATEWAY_VIRTUAL_KEY, + settings.LLM_GATEWAY_MASTER_KEY, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) + yield + ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_TYPE, + settings.LLM_GATEWAY_BASE_URL, + settings.LLM_GATEWAY_VIRTUAL_KEY, + settings.LLM_GATEWAY_MASTER_KEY, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) = original + + +def test_get_llm_gateway_settings_defaults(authenticated_client): + _set_platform_gateway( + enabled=True, + base_url="http://platform-bifrost/litellm", + ) + + response = authenticated_client.get("/api/v1/organizations/llm-gateway") + assert response.status_code == 200 + data = response.json() + assert data["mode"] == "inherit" + assert data["gateway_type"] == "inherit" + assert data["platform_enabled"] is True + assert data["platform_gateway_type"] == "bifrost" + assert data["platform_base_url"] == "http://platform-bifrost/litellm" + assert data["effective_routing"] == "bifrost" + + +def test_update_llm_gateway_settings_persists_bifrost_override( + authenticated_client, db_session, org_id +): + _set_platform_gateway(enabled=False) + + response = authenticated_client.put( + "/api/v1/organizations/llm-gateway", + json={ + "mode": "enabled", + "gateway_type": "bifrost", + "base_url": "http://org-bifrost:8080/litellm", + "virtual_key": "org-virtual-key", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["mode"] == "enabled" + assert data["gateway_type"] == "bifrost" + assert data["base_url"] == "http://org-bifrost:8080/litellm" + assert data["has_virtual_key"] is True + assert data["effective_routing"] == "bifrost" + assert data["effective_base_url"] == "http://org-bifrost:8080/litellm" + + org = db_session.query(Organization).filter(Organization.id == org_id).first() + assert org.llm_gateway_settings["enabled"] is True + assert org.llm_gateway_settings["gateway_type"] == "bifrost" + assert org.llm_gateway_settings["virtual_key"] + + +def test_update_llm_gateway_settings_persists_litellm_proxy_override( + authenticated_client, db_session, org_id +): + _set_platform_gateway(enabled=False) + + response = authenticated_client.put( + "/api/v1/organizations/llm-gateway", + json={ + "mode": "enabled", + "gateway_type": "litellm_proxy", + "base_url": "http://org-proxy:4000", + "master_key": "org-master-key", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["gateway_type"] == "litellm_proxy" + assert data["has_master_key"] is True + assert data["effective_routing"] == "litellm_proxy" + assert data["effective_base_url"] == "http://org-proxy:4000" + + org = db_session.query(Organization).filter(Organization.id == org_id).first() + assert org.llm_gateway_settings["gateway_type"] == "litellm_proxy" + assert org.llm_gateway_settings["master_key"] + + +def test_update_llm_gateway_settings_rejects_invalid_url(authenticated_client): + response = authenticated_client.put( + "/api/v1/organizations/llm-gateway", + json={ + "mode": "enabled", + "gateway_type": "bifrost", + "base_url": "not-a-valid-url", + }, + ) + assert response.status_code == 400 diff --git a/tests/test_services/test_ai/test_gateway_managed_credentials.py b/tests/test_services/test_ai/test_gateway_managed_credentials.py new file mode 100644 index 00000000..d6ecf8eb --- /dev/null +++ b/tests/test_services/test_ai/test_gateway_managed_credentials.py @@ -0,0 +1,83 @@ +"""Tests for gateway-managed AI provider credentials.""" + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.config import settings +from app.core.encryption import encrypt_api_key +from app.services.ai.llm_gateway import ( + GATEWAY_MANAGED_KEY_SENTINEL, + gateway_managed_credentials_enabled, + is_gateway_managed_stored_key, + resolve_litellm_api_key, +) +from app.api.v1.routes import aiproviders as aiproviders_routes + + +def _sync_gateway_settings(**kwargs): + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS = kwargs.get("passthrough", True) + settings.LLM_GATEWAY_ENABLED = kwargs.get("enabled", False) + settings.LLM_GATEWAY_BASE_URL = kwargs.get("base_url") + + +@pytest.fixture(autouse=True) +def _reset_gateway_settings(): + original = ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_BASE_URL, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) + yield + ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_BASE_URL, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) = original + + +def test_gateway_managed_credentials_enabled_when_passthrough_disabled(): + _sync_gateway_settings(passthrough=False) + assert gateway_managed_credentials_enabled() is True + + +def test_is_gateway_managed_stored_key_detects_sentinel(): + encrypted = encrypt_api_key(GATEWAY_MANAGED_KEY_SENTINEL) + assert is_gateway_managed_stored_key(encrypted) is True + assert is_gateway_managed_stored_key(encrypt_api_key("sk-real")) is False + + +def test_resolve_litellm_api_key_returns_none_for_gateway_managed_credential(): + _sync_gateway_settings( + enabled=True, + base_url="http://localhost:8080/litellm", + passthrough=False, + ) + + org_id = uuid4() + provider = SimpleNamespace( + provider="openai", + api_key=encrypt_api_key(GATEWAY_MANAGED_KEY_SENTINEL), + ) + db = SimpleNamespace( + query=lambda *_args, **_kwargs: SimpleNamespace( + filter=lambda *_a, **_k: SimpleNamespace(first=lambda: SimpleNamespace(bifrost_gateway_settings={})) + ) + ) + + assert resolve_litellm_api_key(org_id, db, provider) is None + + +def test_encrypt_provider_api_key_uses_sentinel_when_missing_and_gateway_managed(): + _sync_gateway_settings(passthrough=False) + encrypted = aiproviders_routes._encrypt_provider_api_key(None) + assert is_gateway_managed_stored_key(encrypted) is True + + +def test_encrypt_provider_api_key_requires_value_when_passthrough_enabled(): + _sync_gateway_settings(passthrough=True) + with pytest.raises(HTTPException) as exc: + aiproviders_routes._encrypt_provider_api_key(None) + assert exc.value.status_code == 400 diff --git a/tests/test_services/test_ai/test_llm_gateway.py b/tests/test_services/test_ai/test_llm_gateway.py new file mode 100644 index 00000000..22c9028a --- /dev/null +++ b/tests/test_services/test_ai/test_llm_gateway.py @@ -0,0 +1,295 @@ +"""Tests for multi-gateway LLM resolver (Bifrost + LiteLLM Proxy).""" + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from app.config import settings +from app.services.ai import llm_gateway as gateway_module +from app.services.ai.llm_gateway import ( + apply_llm_gateway, + LITELLM_GATEWAY_PLACEHOLDER_API_KEY, + resolve_llm_gateway, +) + + +def _set_platform_gateway( + *, + enabled=False, + gateway_type="bifrost", + base_url=None, + virtual_key=None, + master_key=None, + passthrough=True, +): + settings.LLM_GATEWAY_ENABLED = enabled + settings.LLM_GATEWAY_TYPE = gateway_type + settings.LLM_GATEWAY_BASE_URL = base_url + settings.LLM_GATEWAY_VIRTUAL_KEY = virtual_key + settings.LLM_GATEWAY_MASTER_KEY = master_key + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS = passthrough + + +@pytest.fixture(autouse=True) +def _reset_gateway_settings(): + original = ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_TYPE, + settings.LLM_GATEWAY_BASE_URL, + settings.LLM_GATEWAY_VIRTUAL_KEY, + settings.LLM_GATEWAY_MASTER_KEY, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) + yield + ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_TYPE, + settings.LLM_GATEWAY_BASE_URL, + settings.LLM_GATEWAY_VIRTUAL_KEY, + settings.LLM_GATEWAY_MASTER_KEY, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) = original + + +def _org_db(org_settings=None): + org_id = uuid4() + org = SimpleNamespace( + id=org_id, + llm_gateway_settings=org_settings, + ) + + class _Query: + def filter(self, *_args, **_kwargs): + return self + + def first(self): + return org + + db = SimpleNamespace(query=lambda *_args, **_kwargs: _Query()) + return org_id, db + + +def test_resolve_returns_none_when_platform_and_org_disabled(): + _set_platform_gateway(enabled=False) + org_id, db = _org_db({"enabled": None}) + assert resolve_llm_gateway(org_id, db) is None + + +def test_resolve_bifrost_uses_platform_defaults(): + _set_platform_gateway( + enabled=True, + base_url="http://bifrost.example.com/litellm", + virtual_key="platform-vk", + ) + org_id, db = _org_db({"enabled": None}) + config = resolve_llm_gateway(org_id, db) + + assert config is not None + assert config.gateway_type == "bifrost" + assert config.api_base == "http://bifrost.example.com/litellm" + assert config.virtual_key == "platform-vk" + + +def test_org_opt_out_overrides_platform(): + _set_platform_gateway(enabled=True, base_url="http://bifrost.example.com/litellm") + org_id, db = _org_db({"enabled": False}) + assert resolve_llm_gateway(org_id, db) is None + + +def test_org_override_url_and_virtual_key(monkeypatch): + _set_platform_gateway(enabled=False) + org_id, db = _org_db( + { + "enabled": True, + "gateway_type": "bifrost", + "base_url": "http://customer-bifrost:9090/litellm", + "virtual_key": "encrypted-vk", + } + ) + monkeypatch.setattr( + gateway_module, + "_decrypt_org_virtual_key", + lambda _raw: "org-vk", + ) + + config = resolve_llm_gateway(org_id, db) + + assert config is not None + assert config.gateway_type == "bifrost" + assert config.api_base == "http://customer-bifrost:9090/litellm" + assert config.virtual_key == "org-vk" + + +def test_legacy_org_json_without_gateway_type_defaults_to_bifrost(): + _set_platform_gateway(enabled=True, base_url="http://bifrost.example.com/litellm") + org_id, db = _org_db({"enabled": True}) + config = resolve_llm_gateway(org_id, db) + assert config is not None + assert config.gateway_type == "bifrost" + + +def test_resolve_litellm_proxy_uses_platform_defaults(): + _set_platform_gateway( + enabled=True, + gateway_type="litellm_proxy", + base_url="http://proxy.example.com:4000", + master_key="platform-master", + ) + org_id, db = _org_db({"enabled": None}) + config = resolve_llm_gateway(org_id, db) + + assert config is not None + assert config.gateway_type == "litellm_proxy" + assert config.api_base == "http://proxy.example.com:4000" + assert config.master_key == "platform-master" + + +def test_org_gateway_type_overrides_platform_type(): + _set_platform_gateway( + enabled=True, + gateway_type="bifrost", + base_url="http://bifrost.example.com/litellm", + ) + org_id, db = _org_db( + { + "enabled": True, + "gateway_type": "litellm_proxy", + "base_url": "http://org-proxy:4000", + } + ) + config = resolve_llm_gateway(org_id, db) + assert config is not None + assert config.gateway_type == "litellm_proxy" + assert config.api_base == "http://org-proxy:4000" + + +def test_apply_bifrost_gateway_injects_placeholder_api_key_when_gateway_managed(): + _set_platform_gateway( + enabled=True, + base_url="http://localhost:8080/litellm", + passthrough=False, + ) + org_id, db = _org_db({"enabled": True}) + result = apply_llm_gateway( + {"model": "openai/gpt-4o-mini", "messages": []}, + organization_id=org_id, + db=db, + ) + + assert result["api_base"] == "http://localhost:8080/litellm" + assert result["api_key"] == LITELLM_GATEWAY_PLACEHOLDER_API_KEY + + +def test_apply_bifrost_gateway_injects_api_base_and_headers(): + _set_platform_gateway( + enabled=True, + base_url="http://localhost:8080/litellm", + virtual_key="vk-123", + ) + org_id, db = _org_db({"enabled": True}) + result = apply_llm_gateway( + {"model": "openai/gpt-4o-mini", "api_key": "sk-test", "messages": []}, + organization_id=org_id, + db=db, + ) + + assert result["api_base"] == "http://localhost:8080/litellm" + assert result["extra_headers"]["x-bf-vk"] == "vk-123" + assert result["api_key"] == "sk-test" + + +def test_apply_litellm_proxy_injects_master_key_when_not_passthrough(): + _set_platform_gateway( + enabled=True, + gateway_type="litellm_proxy", + base_url="http://localhost:4000", + master_key="proxy-master", + passthrough=False, + ) + org_id, db = _org_db({"enabled": True}) + result = apply_llm_gateway( + {"model": "openai/gpt-4o-mini", "api_key": "sk-test", "messages": []}, + organization_id=org_id, + db=db, + ) + + assert result["api_base"] == "http://localhost:4000" + assert result["api_key"] == "proxy-master" + + +def test_apply_litellm_proxy_keeps_org_key_when_passthrough(): + _set_platform_gateway( + enabled=True, + gateway_type="litellm_proxy", + base_url="http://localhost:4000", + master_key="proxy-master", + passthrough=True, + ) + org_id, db = _org_db({"enabled": True}) + result = apply_llm_gateway( + {"model": "openai/gpt-4o-mini", "api_key": "sk-test", "messages": []}, + organization_id=org_id, + db=db, + ) + + assert result["api_base"] == "http://localhost:4000" + assert result["api_key"] == "sk-test" + + +def test_apply_bifrost_gateway_forces_openai_compatible_routing_for_gemini(): + _set_platform_gateway( + enabled=True, + gateway_type="bifrost", + base_url="http://localhost:8080/litellm", + master_key="bifrost-key", + passthrough=False, + ) + org_id, db = _org_db({"enabled": True}) + result = apply_llm_gateway( + {"model": "gemini/gemini-2.5-flash", "api_key": "google-key", "messages": []}, + organization_id=org_id, + db=db, + ) + + assert result["api_base"] == "http://localhost:8080/litellm" + assert result["custom_llm_provider"] == "openai" + assert result["model"] == "gemini/gemini-2.5-flash" + + +def test_apply_litellm_proxy_forces_openai_compatible_routing_for_gemini(): + _set_platform_gateway( + enabled=True, + gateway_type="litellm_proxy", + base_url="http://localhost:4000", + master_key="proxy-master", + passthrough=False, + ) + org_id, db = _org_db({"enabled": True}) + result = apply_llm_gateway( + {"model": "gemini/gemini-2.5-flash", "api_key": "google-key", "messages": []}, + organization_id=org_id, + db=db, + ) + + assert result["custom_llm_provider"] == "openai" + assert result["model"] == "gemini/gemini-2.5-flash" + + +def test_apply_gateway_leaves_openai_models_unmodified(): + _set_platform_gateway( + enabled=True, + gateway_type="bifrost", + base_url="http://localhost:8080/litellm", + master_key="bifrost-key", + passthrough=False, + ) + org_id, db = _org_db({"enabled": True}) + result = apply_llm_gateway( + {"model": "openai/gpt-4o-mini", "api_key": "sk-test", "messages": []}, + organization_id=org_id, + db=db, + ) + + assert "custom_llm_provider" not in result + assert result["model"] == "openai/gpt-4o-mini" diff --git a/tests/test_services/test_ai/test_llm_service.py b/tests/test_services/test_ai/test_llm_service.py index 580bd2ad..7c6abd8a 100644 --- a/tests/test_services/test_ai/test_llm_service.py +++ b/tests/test_services/test_ai/test_llm_service.py @@ -12,6 +12,38 @@ llm_module = importlib.import_module("app.services.ai.llm_service") +def _mock_org_db(org_settings=None): + org = SimpleNamespace(llm_gateway_settings=org_settings) + return SimpleNamespace( + query=lambda *_args, **_kwargs: SimpleNamespace( + filter=lambda *_a, **_k: SimpleNamespace(first=lambda: org) + ) + ) + + +@pytest.fixture(autouse=True) +def _reset_llm_gateway_settings(): + from app.config import settings + + original = ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_BASE_URL, + settings.LLM_GATEWAY_VIRTUAL_KEY, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) + settings.LLM_GATEWAY_ENABLED = False + settings.LLM_GATEWAY_BASE_URL = None + settings.LLM_GATEWAY_VIRTUAL_KEY = None + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS = True + yield + ( + settings.LLM_GATEWAY_ENABLED, + settings.LLM_GATEWAY_BASE_URL, + settings.LLM_GATEWAY_VIRTUAL_KEY, + settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS, + ) = original + + def test_litellm_model_name_maps_known_provider_prefixes(): assert LLMService._litellm_model_name(ModelProvider.OPENAI, "gpt-4o") == "openai/gpt-4o" assert LLMService._litellm_model_name(ModelProvider.GOOGLE, "gemini-1.5-pro") == "gemini/gemini-1.5-pro" @@ -68,7 +100,7 @@ def test_generate_response_success_with_normalized_usage(monkeypatch): llm_provider=ModelProvider.OPENAI, llm_model="gpt-4o-mini", organization_id=uuid4(), - db=object(), + db=_mock_org_db(), temperature=0.2, ) @@ -78,6 +110,55 @@ def test_generate_response_success_with_normalized_usage(monkeypatch): assert result["processing_time"] >= 0 +def test_generate_response_applies_llm_gateway(monkeypatch): + from app.config import settings + + settings.LLM_GATEWAY_ENABLED = True + settings.LLM_GATEWAY_BASE_URL = "http://localhost:8080/litellm" + settings.LLM_GATEWAY_VIRTUAL_KEY = "test-vk" + + service = LLMService() + monkeypatch.setattr( + service, + "_get_ai_provider", + lambda *_args, **_kwargs: SimpleNamespace(api_key="encrypted-key"), + ) + + encryption_module = importlib.import_module("app.core.encryption") + monkeypatch.setattr(encryption_module, "decrypt_api_key", lambda value: f"decrypted::{value}") + + captured = {} + + def _fake_completion(**kwargs): + captured.update(kwargs) + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content="via bifrost"), + finish_reason="stop", + ) + ], + usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1, total_tokens=2), + ) + + monkeypatch.setattr(llm_module.litellm, "completion", _fake_completion) + + org_id = uuid4() + db = _mock_org_db() + + result = service.generate_response( + messages=[{"role": "user", "content": "hello"}], + llm_provider=ModelProvider.OPENAI, + llm_model="gpt-4o-mini", + organization_id=org_id, + db=db, + ) + + assert result["text"] == "via bifrost" + assert captured["api_base"] == "http://localhost:8080/litellm" + assert captured["extra_headers"]["x-bf-vk"] == "test-vk" + + def test_generate_response_wraps_litellm_errors(monkeypatch): service = LLMService() monkeypatch.setattr( @@ -100,5 +181,5 @@ def test_generate_response_wraps_litellm_errors(monkeypatch): llm_provider=ModelProvider.OPENAI, llm_model="gpt-4o-mini", organization_id=uuid4(), - db=object(), + db=_mock_org_db(), ) From 0d19329862f963557bd06a1f6b6f427b095fc6e5 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Fri, 3 Jul 2026 06:10:00 +0000 Subject: [PATCH 2/5] fix: updating tests --- .../test_gateway_managed_credentials.py | 2 +- .../test_optimization/test_lm_resolver.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/test_services/test_ai/test_gateway_managed_credentials.py b/tests/test_services/test_ai/test_gateway_managed_credentials.py index d6ecf8eb..b5f68323 100644 --- a/tests/test_services/test_ai/test_gateway_managed_credentials.py +++ b/tests/test_services/test_ai/test_gateway_managed_credentials.py @@ -63,7 +63,7 @@ def test_resolve_litellm_api_key_returns_none_for_gateway_managed_credential(): ) db = SimpleNamespace( query=lambda *_args, **_kwargs: SimpleNamespace( - filter=lambda *_a, **_k: SimpleNamespace(first=lambda: SimpleNamespace(bifrost_gateway_settings={})) + filter=lambda *_a, **_k: SimpleNamespace(first=lambda: SimpleNamespace(llm_gateway_settings={})) ) ) diff --git a/tests/test_services/test_optimization/test_lm_resolver.py b/tests/test_services/test_optimization/test_lm_resolver.py index 74786dd4..75852bd7 100644 --- a/tests/test_services/test_optimization/test_lm_resolver.py +++ b/tests/test_services/test_optimization/test_lm_resolver.py @@ -2,6 +2,7 @@ import importlib from types import SimpleNamespace +from uuid import uuid4 import pytest @@ -23,12 +24,23 @@ def test_resolve_api_key_returns_decrypted_key(monkeypatch): SimpleNamespace(provider="openai", api_key="enc-1", is_active=True), SimpleNamespace(provider="anthropic", api_key="enc-2", is_active=True), ] - monkeypatch.setattr(resolver_module, "decrypt_api_key", lambda v: f"dec::{v}") + monkeypatch.setattr( + resolver_module, + "resolve_litellm_api_key", + lambda _org_id, _db, provider: f"dec::{provider.api_key}", + ) - assert resolver_module.resolve_api_key("openai/gpt-4o", providers) == "dec::enc-1" + org_id = uuid4() + db = SimpleNamespace() + assert ( + resolver_module.resolve_api_key("openai/gpt-4o", providers, org_id, db) + == "dec::enc-1" + ) def test_resolve_api_key_raises_for_missing_provider(): providers = [SimpleNamespace(provider="anthropic", api_key="enc-2", is_active=True)] + org_id = uuid4() + db = SimpleNamespace() with pytest.raises(RuntimeError, match="No active AI provider"): - resolver_module.resolve_api_key("openai/gpt-4o", providers) + resolver_module.resolve_api_key("openai/gpt-4o", providers, org_id, db) From 9fc2bc9a614f7946e2d5e0bbb2f5a9064f7c1712 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Fri, 3 Jul 2026 11:53:20 +0530 Subject: [PATCH 3/5] Update app/services/ai/transcription_service.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- app/services/ai/transcription_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/ai/transcription_service.py b/app/services/ai/transcription_service.py index 3fcb4d65..c5263dbc 100644 --- a/app/services/ai/transcription_service.py +++ b/app/services/ai/transcription_service.py @@ -90,7 +90,7 @@ def _get_api_key_for_provider( preferred in either table; otherwise the row marked ``is_default`` wins, with a back-compat fallback to the most recent active row. """ - from app.services.ai.bifrost_gateway import resolve_litellm_api_key + from app.services.ai.llm_gateway import resolve_litellm_api_key ai_provider = self._get_ai_provider( provider, db, organization_id, credential_id=credential_id From caad938490c05f5041acaad6bb8e2d096d7b3e96 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Fri, 3 Jul 2026 07:13:59 +0000 Subject: [PATCH 4/5] fix: updating review tests --- app/services/judge_alignment/gepa_bridge.py | 2 +- app/services/optimization/gepa_service.py | 2 +- .../test_services/test_ai/test_llm_gateway.py | 36 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/services/judge_alignment/gepa_bridge.py b/app/services/judge_alignment/gepa_bridge.py index 1ea8e967..e2659605 100644 --- a/app/services/judge_alignment/gepa_bridge.py +++ b/app/services/judge_alignment/gepa_bridge.py @@ -296,7 +296,7 @@ def evaluator_fn(data: Dict[str, Any], response: str) -> Any: } ] - batch_kwargs: Dict[str, Any] = {} + batch_kwargs: Dict[str, Any] = {"model": lm_identifier} if api_key is not None: batch_kwargs["api_key"] = api_key batch_kwargs = apply_llm_gateway( diff --git a/app/services/optimization/gepa_service.py b/app/services/optimization/gepa_service.py index d2ae9c8d..48b6e523 100644 --- a/app/services/optimization/gepa_service.py +++ b/app/services/optimization/gepa_service.py @@ -109,7 +109,7 @@ def run_optimization( f"with {len(trainset)} training examples, LM={lm_identifier}" ) - batch_kwargs: Dict[str, Any] = {} + batch_kwargs: Dict[str, Any] = {"model": lm_identifier} if api_key is not None: batch_kwargs["api_key"] = api_key batch_kwargs = apply_llm_gateway( diff --git a/tests/test_services/test_ai/test_llm_gateway.py b/tests/test_services/test_ai/test_llm_gateway.py index 22c9028a..2edd3e3d 100644 --- a/tests/test_services/test_ai/test_llm_gateway.py +++ b/tests/test_services/test_ai/test_llm_gateway.py @@ -293,3 +293,39 @@ def test_apply_gateway_leaves_openai_models_unmodified(): assert "custom_llm_provider" not in result assert result["model"] == "openai/gpt-4o-mini" + + +def test_apply_gateway_without_model_skips_gemini_proxy_routing(): + """GEPA batch kwargs must include model for native-path routing.""" + _set_platform_gateway( + enabled=True, + gateway_type="bifrost", + base_url="http://localhost:8080/litellm", + passthrough=False, + ) + org_id, db = _org_db({"enabled": True}) + result = apply_llm_gateway( + {"api_key": "google-key"}, + organization_id=org_id, + db=db, + ) + + assert "custom_llm_provider" not in result + + +def test_apply_gateway_with_model_enables_gemini_proxy_routing_for_gepa_batch(): + _set_platform_gateway( + enabled=True, + gateway_type="bifrost", + base_url="http://localhost:8080/litellm", + passthrough=False, + ) + org_id, db = _org_db({"enabled": True}) + result = apply_llm_gateway( + {"model": "gemini/gemini-2.5-flash", "api_key": "google-key"}, + organization_id=org_id, + db=db, + ) + + assert result["custom_llm_provider"] == "openai" + assert result["model"] == "gemini/gemini-2.5-flash" From e03eb21a0bce05a2c84dc9e2fd1d4c0f0705bafc Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Fri, 3 Jul 2026 07:44:30 +0000 Subject: [PATCH 5/5] fix: updating gepa tests --- app/services/ai/llm_gateway.py | 41 +++++++++--- app/services/judge_alignment/gepa_bridge.py | 3 +- app/services/optimization/evaluator.py | 65 ++++++++++++++++--- app/services/optimization/gepa_service.py | 11 +++- .../test_services/test_ai/test_llm_gateway.py | 9 +-- .../test_optimization/test_evaluator.py | 34 ++++++++++ 6 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 tests/test_services/test_optimization/test_evaluator.py diff --git a/app/services/ai/llm_gateway.py b/app/services/ai/llm_gateway.py index 6c244a69..85d41b14 100644 --- a/app/services/ai/llm_gateway.py +++ b/app/services/ai/llm_gateway.py @@ -252,10 +252,15 @@ def _model_uses_native_provider_path(model: Any) -> bool: return any(model_str.startswith(prefix) for prefix in _NATIVE_GATEWAY_MODEL_PREFIXES) -def _apply_proxy_compatible_routing(call_kwargs: Dict[str, Any]) -> Dict[str, Any]: +def _apply_proxy_compatible_routing( + call_kwargs: Dict[str, Any], + *, + routing_model: Optional[str] = None, +) -> Dict[str, Any]: """Route native-path providers through the gateway's chat-completions API.""" result = dict(call_kwargs) - if _model_uses_native_provider_path(result.get("model")): + model_for_routing = result.get("model") or routing_model + if _model_uses_native_provider_path(model_for_routing): result["custom_llm_provider"] = "openai" return result @@ -268,7 +273,12 @@ def _strip_provider_keys(result: Dict[str, Any]) -> None: result.pop("api_key", None) -def _apply_bifrost_gateway(call_kwargs: Dict[str, Any], config: LLMGatewayConfig) -> Dict[str, Any]: +def _apply_bifrost_gateway( + call_kwargs: Dict[str, Any], + config: LLMGatewayConfig, + *, + routing_model: Optional[str] = None, +) -> Dict[str, Any]: result = dict(call_kwargs) result["api_base"] = config.api_base @@ -283,7 +293,7 @@ def _apply_bifrost_gateway(call_kwargs: Dict[str, Any], config: LLMGatewayConfig if not result.get("api_key"): result["api_key"] = config.virtual_key or LITELLM_GATEWAY_PLACEHOLDER_API_KEY - model = result.get("model", "") + model = result.get("model") or routing_model or "" if model and "/" not in str(model): logger.warning( "Routing model '{}' through Bifrost without a provider prefix; " @@ -291,10 +301,15 @@ def _apply_bifrost_gateway(call_kwargs: Dict[str, Any], config: LLMGatewayConfig model, ) - return _apply_proxy_compatible_routing(result) + return _apply_proxy_compatible_routing(result, routing_model=routing_model) -def _apply_litellm_proxy_gateway(call_kwargs: Dict[str, Any], config: LLMGatewayConfig) -> Dict[str, Any]: +def _apply_litellm_proxy_gateway( + call_kwargs: Dict[str, Any], + config: LLMGatewayConfig, + *, + routing_model: Optional[str] = None, +) -> Dict[str, Any]: result = dict(call_kwargs) result["api_base"] = config.api_base @@ -304,7 +319,7 @@ def _apply_litellm_proxy_gateway(call_kwargs: Dict[str, Any], config: LLMGateway if not result.get("api_key"): result["api_key"] = config.master_key or LITELLM_GATEWAY_PLACEHOLDER_API_KEY - return _apply_proxy_compatible_routing(result) + return _apply_proxy_compatible_routing(result, routing_model=routing_model) def apply_llm_gateway( @@ -312,15 +327,21 @@ def apply_llm_gateway( *, organization_id: UUID, db: Session, + model: Optional[str] = None, ) -> Dict[str, Any]: - """Merge gateway proxy settings into LiteLLM ``completion`` kwargs.""" + """Merge gateway proxy settings into LiteLLM ``completion`` kwargs. + + Pass ``model`` when the caller supplies the model separately (e.g. GEPA + ``DefaultAdapter``) so native-path routing still applies without putting + ``model`` in the returned kwargs. + """ config = resolve_llm_gateway(organization_id, db) if config is None: return call_kwargs if config.gateway_type == "bifrost": - return _apply_bifrost_gateway(call_kwargs, config) - return _apply_litellm_proxy_gateway(call_kwargs, config) + return _apply_bifrost_gateway(call_kwargs, config, routing_model=model) + return _apply_litellm_proxy_gateway(call_kwargs, config, routing_model=model) def litellm_completion( diff --git a/app/services/judge_alignment/gepa_bridge.py b/app/services/judge_alignment/gepa_bridge.py index e2659605..c6fe9694 100644 --- a/app/services/judge_alignment/gepa_bridge.py +++ b/app/services/judge_alignment/gepa_bridge.py @@ -296,13 +296,14 @@ def evaluator_fn(data: Dict[str, Any], response: str) -> Any: } ] - batch_kwargs: Dict[str, Any] = {"model": lm_identifier} + batch_kwargs: Dict[str, Any] = {} if api_key is not None: batch_kwargs["api_key"] = api_key batch_kwargs = apply_llm_gateway( batch_kwargs, organization_id=run.organization_id, db=db, + model=lm_identifier, ) adapter = DefaultAdapter( diff --git a/app/services/optimization/evaluator.py b/app/services/optimization/evaluator.py index 87ca1d53..bab71385 100644 --- a/app/services/optimization/evaluator.py +++ b/app/services/optimization/evaluator.py @@ -17,6 +17,58 @@ from app.models.database import AIProvider, Metric from app.workers.tasks.helpers.score_utils import get_metric_type_value +_DEFAULT_SCORING_MODELS = { + "openai": "gpt-4o-mini", + "anthropic": "claude-3-5-haiku-latest", + "google": "gemini-2.0-flash", +} + + +def _normalize_provider_prefix(prefix: str) -> str: + normalized = prefix.lower() + if normalized == "gemini": + return "google" + return normalized + + +def _resolve_scoring_lm( + lm_identifier: str | None, + ai_providers: List[AIProvider], +) -> tuple["ModelProvider", str] | tuple[None, None]: + """Pick provider + model for GEPA scoring calls.""" + from app.models.database import ModelProvider + + if lm_identifier and "/" in lm_identifier: + prefix, model = lm_identifier.split("/", 1) + provider_key = _normalize_provider_prefix(prefix) + provider = next( + ( + p + for p in ai_providers + if p.is_active and p.provider.lower() == provider_key + ), + None, + ) + if provider: + return ModelProvider(provider.provider.lower()), model + + provider = next( + (p for p in ai_providers if p.is_active and p.provider.lower() == "openai"), + None, + ) + if provider: + return ModelProvider.OPENAI, _DEFAULT_SCORING_MODELS["openai"] + + provider = next((p for p in ai_providers if p.is_active), None) + if not provider: + return None, None + + provider_key = provider.provider.lower() + return ( + ModelProvider(provider_key), + _DEFAULT_SCORING_MODELS.get(provider_key, _DEFAULT_SCORING_MODELS["openai"]), + ) + def _get_evaluation_result_class(): """Lazy-import EvaluationResult; gepa will already be installed by the time this is called.""" @@ -32,6 +84,7 @@ def build_evaluator( ai_providers: List[AIProvider], organization_id: UUID, db, + lm_identifier: str | None = None, ) -> Callable[[Dict[str, Any], str], Any]: """ Return a function with the signature GEPA's ``Evaluator`` protocol @@ -79,13 +132,9 @@ def evaluator_fn(data: Dict[str, Any], response: str) -> Any: ) from app.services.ai.llm_service import llm_service - from app.models.database import ModelProvider - provider = next( - (p for p in ai_providers if p.provider.lower() in ("openai", "anthropic")), - ai_providers[0] if ai_providers else None, - ) - if not provider: + llm_provider, llm_model = _resolve_scoring_lm(lm_identifier, ai_providers) + if not llm_provider or not llm_model: return EvaluationResult(score=0.5, feedback="No AI provider available") try: @@ -94,8 +143,8 @@ def evaluator_fn(data: Dict[str, Any], response: str) -> Any: {"role": "system", "content": "You are an expert voice AI evaluator. Respond with JSON only."}, {"role": "user", "content": eval_prompt}, ], - llm_provider=ModelProvider(provider.provider.lower()), - llm_model="gpt-4o", + llm_provider=llm_provider, + llm_model=llm_model, organization_id=organization_id, db=db, temperature=0.3, diff --git a/app/services/optimization/gepa_service.py b/app/services/optimization/gepa_service.py index 48b6e523..fe6bf2b1 100644 --- a/app/services/optimization/gepa_service.py +++ b/app/services/optimization/gepa_service.py @@ -102,20 +102,27 @@ def run_optimization( if not trainset: raise ValueError("No evaluator results with transcripts available for optimization") - evaluator_fn = build_evaluator(metrics, ai_providers, organization_id, db) + evaluator_fn = build_evaluator( + metrics, + ai_providers, + organization_id, + db, + lm_identifier=lm_identifier, + ) logger.info( f"[GEPA] Starting optimization for agent '{agent.name}' " f"with {len(trainset)} training examples, LM={lm_identifier}" ) - batch_kwargs: Dict[str, Any] = {"model": lm_identifier} + batch_kwargs: Dict[str, Any] = {} if api_key is not None: batch_kwargs["api_key"] = api_key batch_kwargs = apply_llm_gateway( batch_kwargs, organization_id=organization_id, db=db, + model=lm_identifier, ) adapter = DefaultAdapter( diff --git a/tests/test_services/test_ai/test_llm_gateway.py b/tests/test_services/test_ai/test_llm_gateway.py index 2edd3e3d..50a787a7 100644 --- a/tests/test_services/test_ai/test_llm_gateway.py +++ b/tests/test_services/test_ai/test_llm_gateway.py @@ -296,7 +296,7 @@ def test_apply_gateway_leaves_openai_models_unmodified(): def test_apply_gateway_without_model_skips_gemini_proxy_routing(): - """GEPA batch kwargs must include model for native-path routing.""" + """Without model in kwargs or routing param, native-path routing is skipped.""" _set_platform_gateway( enabled=True, gateway_type="bifrost", @@ -313,7 +313,7 @@ def test_apply_gateway_without_model_skips_gemini_proxy_routing(): assert "custom_llm_provider" not in result -def test_apply_gateway_with_model_enables_gemini_proxy_routing_for_gepa_batch(): +def test_apply_gateway_routing_model_enables_gemini_proxy_routing_for_gepa_batch(): _set_platform_gateway( enabled=True, gateway_type="bifrost", @@ -322,10 +322,11 @@ def test_apply_gateway_with_model_enables_gemini_proxy_routing_for_gepa_batch(): ) org_id, db = _org_db({"enabled": True}) result = apply_llm_gateway( - {"model": "gemini/gemini-2.5-flash", "api_key": "google-key"}, + {"api_key": "google-key"}, organization_id=org_id, db=db, + model="gemini/gemini-2.5-flash", ) assert result["custom_llm_provider"] == "openai" - assert result["model"] == "gemini/gemini-2.5-flash" + assert "model" not in result diff --git a/tests/test_services/test_optimization/test_evaluator.py b/tests/test_services/test_optimization/test_evaluator.py new file mode 100644 index 00000000..f65e95f2 --- /dev/null +++ b/tests/test_services/test_optimization/test_evaluator.py @@ -0,0 +1,34 @@ +"""Tests for GEPA optimization evaluator LM resolution.""" + +from types import SimpleNamespace + +from app.models.enums import ModelProvider +from app.services.optimization.evaluator import _resolve_scoring_lm + + +def _provider(name: str, *, active: bool = True): + return SimpleNamespace(provider=name, is_active=active, api_key="enc") + + +def test_resolve_scoring_lm_uses_optimization_lm_identifier(): + providers = [_provider("openai"), _provider("anthropic")] + llm_provider, llm_model = _resolve_scoring_lm("openai/gpt-4.1", providers) + + assert llm_provider == ModelProvider.OPENAI + assert llm_model == "gpt-4.1" + + +def test_resolve_scoring_lm_does_not_pair_anthropic_provider_with_openai_model(): + providers = [_provider("anthropic"), _provider("openai")] + llm_provider, llm_model = _resolve_scoring_lm("openai/gpt-4.1", providers) + + assert llm_provider == ModelProvider.OPENAI + assert llm_model == "gpt-4.1" + + +def test_resolve_scoring_lm_prefers_openai_when_lm_identifier_missing(): + providers = [_provider("anthropic"), _provider("openai")] + llm_provider, llm_model = _resolve_scoring_lm(None, providers) + + assert llm_provider == ModelProvider.OPENAI + assert llm_model == "gpt-4o-mini"