+ Bifrost custom model ID sent when routing via gateway. Leave blank to use the workload-selected model.
+
+
+ )}
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'
+ : aiProviderRequiresApiKey
+ ? 'Enter API key'
+ : 'Leave blank to use gateway-managed credentials'
}
/>
- {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'}
+ {credentialRoutingMode === 'direct'
+ ? 'Direct routing always requires a provider 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 2d6d8ecf..6754b2e3 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -232,6 +232,15 @@ export enum TelephonyProvider {
EXOTEL = 'exotel',
}
+export type CredentialRoutingMode = 'inherit' | 'gateway' | 'direct'
+
+export type EffectiveCredentialRouting =
+ | 'inherit'
+ | 'direct'
+ | 'gateway'
+ | 'bifrost'
+ | 'litellm_proxy'
+
export interface Integration {
id: string
organization_id: string
@@ -241,6 +250,8 @@ export interface Integration {
is_active: boolean
/** True if this row is the default credential for (org, platform). */
is_default?: boolean
+ routing_mode?: CredentialRoutingMode
+ effective_routing?: EffectiveCredentialRouting
created_at: string
updated_at: string
last_tested_at?: string | null
@@ -251,6 +262,7 @@ export interface IntegrationCreate {
api_key: string
public_key?: string
name?: string | null
+ routing_mode?: CredentialRoutingMode
/** Mark the new credential as the default for (org, platform). */
is_default?: boolean
}
@@ -288,8 +300,11 @@ export interface AIProvider {
is_active: boolean
/** True if this row is the default credential for (org, provider). */
is_default?: boolean
+ routing_mode?: CredentialRoutingMode
+ gateway_model?: string | null
/** True when provider secrets are resolved by the Bifrost gateway. */
gateway_managed?: boolean
+ effective_routing?: EffectiveCredentialRouting
created_at: string
updated_at: string
last_tested_at?: string | null
@@ -299,6 +314,8 @@ export interface AIProviderCreate {
provider: ModelProvider
api_key?: string | null
name?: string | null
+ routing_mode?: CredentialRoutingMode
+ gateway_model?: string | null
/** Mark the new credential as the default for (org, provider). */
is_default?: boolean
}
@@ -307,6 +324,8 @@ export interface AIProviderUpdate {
api_key?: string | null
name?: string | null
is_active?: boolean
+ routing_mode?: CredentialRoutingMode
+ gateway_model?: string | null
}
export enum VoiceBundleType {
diff --git a/tests/test_api/test_gateway_managed_credentials.py b/tests/test_api/test_gateway_managed_credentials.py
index e7f17cb4..2ef11568 100644
--- a/tests/test_api/test_gateway_managed_credentials.py
+++ b/tests/test_api/test_gateway_managed_credentials.py
@@ -15,11 +15,13 @@ def _set_platform_gateway_passthrough(passthrough: bool):
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
@@ -47,6 +49,48 @@ def test_create_aiprovider_without_key_when_gateway_managed(
assert is_gateway_managed_stored_key(row.api_key) is True
+def test_create_aiprovider_with_gateway_routing_and_custom_model(
+ authenticated_client, db_session, org_id
+):
+ _set_platform_gateway_passthrough(False)
+ settings.LLM_GATEWAY_ENABLED = True
+ settings.LLM_GATEWAY_BASE_URL = "http://bifrost.example.com/litellm"
+
+ response = authenticated_client.post(
+ "/api/v1/aiproviders",
+ json={
+ "provider": "openai",
+ "name": "OpenAI via Bifrost",
+ "routing_mode": "gateway",
+ "gateway_model": "production-gpt4",
+ },
+ )
+ assert response.status_code == 201
+ data = response.json()
+ assert data["routing_mode"] == "gateway"
+ assert data["gateway_model"] == "production-gpt4"
+ assert data["gateway_managed"] is True
+ assert data["effective_routing"] == "bifrost"
+
+ row = (
+ db_session.query(AIProvider)
+ .filter(AIProvider.organization_id == org_id)
+ .first()
+ )
+ assert row.routing_mode == "gateway"
+ assert row.gateway_model == "production-gpt4"
+
+
+def test_create_aiprovider_direct_requires_api_key(authenticated_client):
+ _set_platform_gateway_passthrough(True)
+
+ response = authenticated_client.post(
+ "/api/v1/aiproviders",
+ json={"provider": "openai", "routing_mode": "direct"},
+ )
+ assert response.status_code == 400
+
+
def test_create_aiprovider_without_key_rejected_when_passthrough_enabled(
authenticated_client,
):
diff --git a/tests/test_services/test_ai/test_llm_gateway.py b/tests/test_services/test_ai/test_llm_gateway.py
index 50a787a7..afc616e9 100644
--- a/tests/test_services/test_ai/test_llm_gateway.py
+++ b/tests/test_services/test_ai/test_llm_gateway.py
@@ -9,7 +9,10 @@
from app.services.ai import llm_gateway as gateway_module
from app.services.ai.llm_gateway import (
apply_llm_gateway,
+ CredentialRoutingContext,
LITELLM_GATEWAY_PLACEHOLDER_API_KEY,
+ resolve_effective_routing,
+ resolve_litellm_model,
resolve_llm_gateway,
)
@@ -330,3 +333,74 @@ def test_apply_gateway_routing_model_enables_gemini_proxy_routing_for_gepa_batch
assert result["custom_llm_provider"] == "openai"
assert "model" not in result
+
+
+def test_credential_direct_overrides_org_gateway():
+ _set_platform_gateway(
+ enabled=True,
+ base_url="http://bifrost.example.com/litellm",
+ )
+ org_id, db = _org_db({"enabled": True})
+ ctx = CredentialRoutingContext(routing_mode="direct")
+ config, effective = resolve_effective_routing(org_id, db, ctx)
+ assert config is None
+ assert effective == "direct"
+ assert resolve_llm_gateway(org_id, db, credential=ctx) is None
+
+
+def test_credential_gateway_overrides_org_opt_out():
+ _set_platform_gateway(
+ enabled=True,
+ base_url="http://bifrost.example.com/litellm",
+ virtual_key="platform-vk",
+ )
+ org_id, db = _org_db({"enabled": False})
+ ctx = CredentialRoutingContext(routing_mode="gateway")
+ config, effective = resolve_effective_routing(org_id, db, ctx)
+ assert config is not None
+ assert effective == "bifrost"
+ assert config.api_base == "http://bifrost.example.com/litellm"
+
+
+def test_credential_gateway_raises_when_no_base_url():
+ _set_platform_gateway(enabled=False)
+ org_id, db = _org_db({"enabled": False})
+ ctx = CredentialRoutingContext(routing_mode="gateway")
+ with pytest.raises(RuntimeError, match="no base_url"):
+ resolve_effective_routing(org_id, db, ctx)
+
+
+def test_resolve_litellm_model_uses_gateway_model_when_active():
+ ctx = CredentialRoutingContext(routing_mode="gateway", gateway_model="production-gpt4")
+ assert resolve_litellm_model(
+ workload_model_str="openai/gpt-4o",
+ gateway_active=True,
+ credential=ctx,
+ ) == "production-gpt4"
+
+
+def test_resolve_litellm_model_keeps_workload_model_when_direct():
+ ctx = CredentialRoutingContext(routing_mode="direct", gateway_model="production-gpt4")
+ assert resolve_litellm_model(
+ workload_model_str="openai/gpt-4o",
+ gateway_active=False,
+ credential=ctx,
+ ) == "openai/gpt-4o"
+
+
+def test_apply_gateway_skips_injection_for_direct_credential():
+ _set_platform_gateway(
+ enabled=True,
+ base_url="http://localhost:8080/litellm",
+ virtual_key="vk-123",
+ )
+ org_id, db = _org_db({"enabled": True})
+ ctx = CredentialRoutingContext(routing_mode="direct")
+ result = apply_llm_gateway(
+ {"model": "openai/gpt-4o-mini", "api_key": "sk-test", "messages": []},
+ organization_id=org_id,
+ db=db,
+ credential=ctx,
+ )
+ assert "api_base" not in result
+ assert result["api_key"] == "sk-test"
diff --git a/tests/test_services/test_optimization/test_lm_resolver.py b/tests/test_services/test_optimization/test_lm_resolver.py
index 75852bd7..0111600c 100644
--- a/tests/test_services/test_optimization/test_lm_resolver.py
+++ b/tests/test_services/test_optimization/test_lm_resolver.py
@@ -21,8 +21,24 @@ def test_resolve_lm_prioritizes_voice_bundle_then_evaluator_then_default():
def test_resolve_api_key_returns_decrypted_key(monkeypatch):
providers = [
- SimpleNamespace(provider="openai", api_key="enc-1", is_active=True),
- SimpleNamespace(provider="anthropic", api_key="enc-2", is_active=True),
+ SimpleNamespace(
+ id=uuid4(),
+ provider="openai",
+ api_key="enc-1",
+ is_active=True,
+ is_default=True,
+ routing_mode="inherit",
+ gateway_model=None,
+ ),
+ SimpleNamespace(
+ id=uuid4(),
+ provider="anthropic",
+ api_key="enc-2",
+ is_active=True,
+ is_default=True,
+ routing_mode="inherit",
+ gateway_model=None,
+ ),
]
monkeypatch.setattr(
resolver_module,
@@ -39,8 +55,59 @@ def test_resolve_api_key_returns_decrypted_key(monkeypatch):
def test_resolve_api_key_raises_for_missing_provider():
- providers = [SimpleNamespace(provider="anthropic", api_key="enc-2", is_active=True)]
+ providers = [
+ SimpleNamespace(
+ id=uuid4(),
+ provider="anthropic",
+ api_key="enc-2",
+ is_active=True,
+ is_default=True,
+ routing_mode="inherit",
+ gateway_model=None,
+ )
+ ]
org_id = uuid4()
db = SimpleNamespace()
with pytest.raises(RuntimeError, match="No active AI provider"):
resolver_module.resolve_api_key("openai/gpt-4o", providers, org_id, db)
+
+
+def test_resolve_lm_call_uses_gateway_model(monkeypatch):
+ credential_id = uuid4()
+ providers = [
+ SimpleNamespace(
+ id=credential_id,
+ provider="openai",
+ api_key="enc-1",
+ is_active=True,
+ is_default=True,
+ routing_mode="gateway",
+ gateway_model="production-gpt4",
+ ),
+ ]
+ bundle = SimpleNamespace(
+ llm_provider="openai",
+ llm_model="gpt-4o",
+ llm_credential_id=credential_id,
+ )
+ monkeypatch.setattr(
+ resolver_module,
+ "resolve_litellm_api_key",
+ lambda *_args, **_kwargs: None,
+ )
+ monkeypatch.setattr(
+ resolver_module,
+ "resolve_effective_routing",
+ lambda *_args, **_kwargs: (object(), "bifrost"),
+ )
+
+ model_str, api_key, ctx = resolver_module.resolve_lm_call(
+ bundle,
+ None,
+ providers,
+ uuid4(),
+ SimpleNamespace(),
+ )
+ assert model_str == "production-gpt4"
+ assert api_key is None
+ assert ctx.gateway_model == "production-gpt4"
From 7f37f9ecadec901cd0807326a160c1e13f0096e8 Mon Sep 17 00:00:00 2001
From: Tejas Narayan
Date: Fri, 10 Jul 2026 14:05:51 +0000
Subject: [PATCH 2/5] feat: updating logic of routing
---
app/api/v1/routes/agents.py | 36 +-
app/api/v1/routes/aiproviders.py | 108 ++++--
app/api/v1/routes/llm_gateway.py | 9 +
app/api/v1/routes/prompt_partials.py | 8 +-
app/config.py | 5 +
.../054_gateway_interface_settings.py | 62 +++
app/migrations/055_gateway_auth_overrides.py | 77 ++++
app/migrations/056_gateway_extra_headers.py | 44 +++
app/models/database.py | 12 +
app/models/enums.py | 7 +
app/models/schemas.py | 172 ++++++++-
app/services/ai/llm_gateway.py | 265 ++++++++++++-
app/services/ai/llm_gateway_settings.py | 52 ++-
app/services/ai/llm_resolver.py | 161 ++++++--
config.yml.example | 1 +
.../docs/getting-started/llm-gateway.mdx | 52 ++-
.../src/components/AIProviderModelPicker.tsx | 355 ++++++++++--------
.../providers/ProviderModelPicker.tsx | 137 ++++---
frontend/src/lib/api.ts | 19 +-
frontend/src/lib/gatewayRouting.ts | 43 +++
.../src/pages/configurations/Integrations.tsx | 244 +++++++++++-
.../pages/promptPartials/PromptPartials.tsx | 160 +++++---
frontend/src/types/api.ts | 21 ++
.../test_gateway_managed_credentials.py | 24 ++
.../test_gateway_managed_credentials.py | 39 +-
.../test_services/test_ai/test_llm_gateway.py | 186 +++++++++
.../test_ai/test_llm_resolver.py | 120 ++++++
27 files changed, 2025 insertions(+), 394 deletions(-)
create mode 100644 app/migrations/054_gateway_interface_settings.py
create mode 100644 app/migrations/055_gateway_auth_overrides.py
create mode 100644 app/migrations/056_gateway_extra_headers.py
create mode 100644 frontend/src/lib/gatewayRouting.ts
create mode 100644 tests/test_services/test_ai/test_llm_resolver.py
diff --git a/app/api/v1/routes/agents.py b/app/api/v1/routes/agents.py
index 812b4f93..bb486999 100644
--- a/app/api/v1/routes/agents.py
+++ b/app/api/v1/routes/agents.py
@@ -50,41 +50,7 @@ class GenerateAgentDescriptionRequest(BaseModel):
)
-def _get_llm_provider_and_model(
- organization_id: UUID,
- db: Session,
- provider: Optional[str] = None,
- model: Optional[str] = None,
-):
- """Resolve the LLM provider and model to use, falling back to org defaults."""
- from app.models.database import AIProvider
- from app.models.enums import ModelProvider
-
- if provider and model:
- try:
- provider_enum = ModelProvider(provider.lower())
- except ValueError:
- raise HTTPException(400, f"Unsupported LLM provider: {provider}")
- return provider_enum, model
-
- for prov in [ModelProvider.OPENAI, ModelProvider.ANTHROPIC, ModelProvider.GOOGLE]:
- ai_prov = db.query(AIProvider).filter(
- AIProvider.organization_id == organization_id,
- AIProvider.is_active == True,
- AIProvider.provider == prov.value,
- ).first()
- if ai_prov:
- default_models = {
- ModelProvider.OPENAI: "gpt-5-mini",
- ModelProvider.ANTHROPIC: "claude-sonnet-4-20250514",
- ModelProvider.GOOGLE: "gemini-2.0-flash",
- }
- return prov, model or default_models.get(prov, "gpt-5-mini")
-
- raise HTTPException(
- 400,
- "No active AI provider configured. Add an OpenAI, Anthropic, or Google provider in AI Providers settings.",
- )
+from app.services.ai.llm_resolver import get_llm_provider_and_model as _get_llm_provider_and_model
@router.post("/generate-description")
diff --git a/app/api/v1/routes/aiproviders.py b/app/api/v1/routes/aiproviders.py
index 50187b0f..3fb559f3 100644
--- a/app/api/v1/routes/aiproviders.py
+++ b/app/api/v1/routes/aiproviders.py
@@ -25,14 +25,31 @@
from app.services.credentials.resolver import clear_other_defaults
from app.services.ai.llm_gateway import (
GATEWAY_MANAGED_KEY_SENTINEL,
- gateway_managed_credentials_enabled,
+ get_credential_effective_gateway_interface,
get_credential_effective_routing_label,
is_gateway_managed_stored_key,
+ normalize_bifrost_native_url,
+ normalize_bifrost_url,
)
router = APIRouter(prefix="/aiproviders", tags=["aiproviders"])
+def _sanitize_gateway_base_url(
+ base_url: Optional[str],
+ gateway_interface: str,
+) -> Optional[str]:
+ trimmed = (base_url or "").strip()
+ if not trimmed:
+ return None
+ interface = (gateway_interface or "inherit").strip().lower()
+ if interface == "native_openai":
+ return normalize_bifrost_native_url(trimmed) or None
+ if interface == "litellm_shim":
+ return normalize_bifrost_url(trimmed) or None
+ return trimmed
+
+
def _scrub_for_response(
db: Session,
instance: AIProvider,
@@ -45,13 +62,22 @@ def _scrub_for_response(
db,
instance.routing_mode,
)
+ effective_gateway_interface = get_credential_effective_gateway_interface(
+ organization_id,
+ db,
+ getattr(instance, "gateway_interface", None),
+ )
+ has_gateway_auth_secret = bool(getattr(instance, "gateway_auth_secret", None))
db.expunge(instance)
instance.api_key = None
+ instance.gateway_auth_secret = None
response = AIProviderResponse.model_validate(instance)
return response.model_copy(
update={
"gateway_managed": gateway_managed,
"effective_routing": effective_routing,
+ "effective_gateway_interface": effective_gateway_interface,
+ "has_gateway_auth_secret": has_gateway_auth_secret,
}
)
@@ -73,27 +99,11 @@ def _validate_routing_and_api_key(
)
if mode == CredentialRoutingMode.GATEWAY.value:
- if gateway_managed_credentials_enabled():
- return
- if not trimmed_key and not has_existing_key:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=(
- "api_key is required when routing_mode is gateway and "
- "passthrough_provider_keys is enabled."
- ),
- )
+ return
if mode == CredentialRoutingMode.INHERIT.value:
- if not trimmed_key and not has_existing_key and not gateway_managed_credentials_enabled():
- 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."
- ),
- )
+ if not trimmed_key and not has_existing_key:
+ return
def _encrypt_provider_api_key(
@@ -109,16 +119,12 @@ def _encrypt_provider_api_key(
if mode in (
CredentialRoutingMode.GATEWAY.value,
CredentialRoutingMode.INHERIT.value,
- ) and 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."
- ),
+ detail="api_key is required when routing_mode is direct.",
)
@@ -150,6 +156,7 @@ async def create_aiprovider(
aiprovider.api_key,
routing_mode=aiprovider.routing_mode,
)
+ gateway_interface_value = aiprovider.gateway_interface.value
db_aiprovider = AIProvider(
organization_id=organization_id,
provider=provider_value,
@@ -158,6 +165,19 @@ async def create_aiprovider(
is_default=will_be_default,
routing_mode=aiprovider.routing_mode.value,
gateway_model=aiprovider.gateway_model,
+ gateway_interface=gateway_interface_value,
+ gateway_base_url=_sanitize_gateway_base_url(
+ aiprovider.gateway_base_url,
+ gateway_interface_value,
+ ),
+ gateway_auth_header=aiprovider.gateway_auth_header,
+ gateway_auth_secret_env=aiprovider.gateway_auth_secret_env,
+ gateway_auth_secret=(
+ encrypt_api_key(aiprovider.gateway_auth_secret)
+ if aiprovider.gateway_auth_secret
+ else None
+ ),
+ gateway_extra_headers=aiprovider.gateway_extra_headers,
)
db.add(db_aiprovider)
db.flush()
@@ -251,14 +271,36 @@ async def update_aiprovider(
has_existing_key=bool(db_aiprovider.api_key),
)
+ skip_fields = {
+ "api_key",
+ "routing_mode",
+ "gateway_interface",
+ "gateway_auth_secret",
+ "clear_gateway_auth_secret",
+ }
+
for field, value in update_data.items():
- if field == 'api_key' and value:
- encrypted_api_key = encrypt_api_key(value)
- db_aiprovider.api_key = encrypted_api_key
- elif field == 'routing_mode' and value is not None:
- db_aiprovider.routing_mode = value.value
- else:
- setattr(db_aiprovider, field, value)
+ if field in skip_fields:
+ continue
+ if field == "gateway_base_url":
+ interface = (
+ update_data["gateway_interface"].value
+ if update_data.get("gateway_interface") is not None
+ else db_aiprovider.gateway_interface
+ )
+ value = _sanitize_gateway_base_url(value, interface)
+ setattr(db_aiprovider, field, value)
+
+ if "api_key" in update_data and update_data["api_key"]:
+ db_aiprovider.api_key = encrypt_api_key(update_data["api_key"])
+ if "routing_mode" in update_data and update_data["routing_mode"] is not None:
+ db_aiprovider.routing_mode = update_data["routing_mode"].value
+ if "gateway_interface" in update_data and update_data["gateway_interface"] is not None:
+ db_aiprovider.gateway_interface = update_data["gateway_interface"].value
+ if update_data.get("clear_gateway_auth_secret"):
+ db_aiprovider.gateway_auth_secret = None
+ elif update_data.get("gateway_auth_secret"):
+ db_aiprovider.gateway_auth_secret = encrypt_api_key(update_data["gateway_auth_secret"])
db.commit()
db.refresh(db_aiprovider)
diff --git a/app/api/v1/routes/llm_gateway.py b/app/api/v1/routes/llm_gateway.py
index 65924b8f..248ab6e1 100644
--- a/app/api/v1/routes/llm_gateway.py
+++ b/app/api/v1/routes/llm_gateway.py
@@ -9,6 +9,7 @@
from app.dependencies import get_db, get_organization_id
from app.services.ai.llm_gateway_settings import (
+ GatewayInterfaceOverride,
GatewayMode,
GatewayTypeOverride,
get_org_settings,
@@ -21,14 +22,17 @@
class LLMGatewaySettingsResponse(BaseModel):
mode: GatewayMode
gateway_type: GatewayTypeOverride
+ gateway_interface: GatewayInterfaceOverride = "inherit"
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_gateway_interface: Literal["litellm_shim", "native_openai"] = "litellm_shim"
platform_base_url: Optional[str] = None
effective_routing: Literal["direct", "bifrost", "litellm_proxy"]
effective_gateway_type: Optional[Literal["bifrost", "litellm_proxy"]] = None
+ effective_gateway_interface: Optional[Literal["litellm_shim", "native_openai"]] = None
effective_base_url: Optional[str] = None
effective_has_virtual_key: bool = False
effective_has_master_key: bool = False
@@ -44,6 +48,10 @@ class LLMGatewaySettingsUpdate(BaseModel):
default="inherit",
description="inherit: use platform gateway type; bifrost or litellm_proxy to override",
)
+ gateway_interface: GatewayInterfaceOverride = Field(
+ default="inherit",
+ description="inherit: use platform default; litellm_shim or native_openai for Bifrost API surface",
+ )
base_url: Optional[str] = Field(
default=None,
description="Optional org-specific gateway URL override.",
@@ -85,6 +93,7 @@ def update_llm_gateway_settings(
db,
mode=body.mode,
gateway_type=body.gateway_type,
+ gateway_interface=body.gateway_interface,
base_url=body.base_url,
virtual_key=body.virtual_key,
master_key=body.master_key,
diff --git a/app/api/v1/routes/prompt_partials.py b/app/api/v1/routes/prompt_partials.py
index f1672597..b3365402 100644
--- a/app/api/v1/routes/prompt_partials.py
+++ b/app/api/v1/routes/prompt_partials.py
@@ -40,6 +40,7 @@ class GeneratePromptRequest(BaseModel):
format_style: Optional[str] = "structured"
provider: Optional[str] = None
model: Optional[str] = None
+ credential_id: Optional[UUID] = None
llm_config: Optional[Dict[str, Any]] = None
@@ -48,6 +49,7 @@ class ImprovePromptRequest(BaseModel):
instructions: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
+ credential_id: Optional[UUID] = None
llm_config: Optional[Dict[str, Any]] = None
@@ -192,7 +194,7 @@ async def generate_prompt_with_ai(
raise HTTPException(400, "Description is required")
provider_enum, model_str = _get_llm_provider_and_model(
- organization_id, db, data.provider, data.model
+ organization_id, db, data.provider, data.model, data.credential_id
)
user_prompt = (
@@ -217,6 +219,7 @@ async def generate_prompt_with_ai(
db=db,
llm_config=data.llm_config,
task_defaults={"temperature": 0.7, "max_tokens": 4000},
+ credential_id=data.credential_id,
)
return {"content": result["text"], "provider": provider_enum.value, "model": model_str}
except Exception as e:
@@ -238,7 +241,7 @@ async def improve_prompt_with_ai(
raise HTTPException(400, "Content is required")
provider_enum, model_str = _get_llm_provider_and_model(
- organization_id, db, data.provider, data.model
+ organization_id, db, data.provider, data.model, data.credential_id
)
user_content = data.content
@@ -259,6 +262,7 @@ async def improve_prompt_with_ai(
db=db,
llm_config=data.llm_config,
task_defaults={"temperature": 0.3, "max_tokens": 4000},
+ credential_id=data.credential_id,
)
return {"content": result["text"], "provider": provider_enum.value, "model": model_str}
except Exception as e:
diff --git a/app/config.py b/app/config.py
index 4bf5d008..8de0d124 100644
--- a/app/config.py
+++ b/app/config.py
@@ -175,6 +175,7 @@ class Settings(BaseSettings):
LLM_GATEWAY_VIRTUAL_KEY: Optional[str] = None
LLM_GATEWAY_MASTER_KEY: Optional[str] = None
LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS: bool = True
+ LLM_GATEWAY_INTERFACE: str = "litellm_shim" # litellm_shim | native_openai
model_config = SettingsConfigDict(
env_file=".env",
@@ -631,6 +632,10 @@ def _apply_llm_gateway_settings(gateway_cfg: dict, *, gateway_type: str) -> None
settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS = bool(
gateway_cfg["passthrough_provider_keys"]
)
+ if gateway_cfg.get("gateway_interface"):
+ interface = str(gateway_cfg["gateway_interface"]).strip().lower()
+ if interface in ("litellm_shim", "native_openai"):
+ settings.LLM_GATEWAY_INTERFACE = interface
if "llm_gateway" in config_data:
llm_cfg = config_data["llm_gateway"]
diff --git a/app/migrations/054_gateway_interface_settings.py b/app/migrations/054_gateway_interface_settings.py
new file mode 100644
index 00000000..73a5bcbc
--- /dev/null
+++ b/app/migrations/054_gateway_interface_settings.py
@@ -0,0 +1,62 @@
+"""
+Migration: Bifrost gateway interface and per-credential base URL overrides.
+
+Adds ``gateway_interface`` and ``gateway_base_url`` to aiproviders for
+native OpenAI-compatible Bifrost routing vs the /litellm shim.
+"""
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+description = (
+ "Add gateway_interface and gateway_base_url on aiproviders for "
+ "Bifrost native vs LiteLLM shim routing."
+)
+
+
+def _column_exists(db: Session, table_name: str, column_name: str) -> bool:
+ row = db.execute(
+ text(
+ """
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_name = :table_name AND column_name = :column_name
+ """
+ ),
+ {"table_name": table_name, "column_name": column_name},
+ ).first()
+ return row is not None
+
+
+def upgrade(db: Session):
+ if not _column_exists(db, "aiproviders", "gateway_interface"):
+ db.execute(
+ text(
+ """
+ ALTER TABLE aiproviders
+ ADD COLUMN gateway_interface VARCHAR(20) NOT NULL DEFAULT 'inherit'
+ """
+ )
+ )
+ print("Added aiproviders.gateway_interface (varchar, default 'inherit')")
+
+ if not _column_exists(db, "aiproviders", "gateway_base_url"):
+ db.execute(
+ text(
+ """
+ ALTER TABLE aiproviders
+ ADD COLUMN gateway_base_url VARCHAR(512) NULL
+ """
+ )
+ )
+ print("Added aiproviders.gateway_base_url (varchar, nullable)")
+
+
+def downgrade(db: Session):
+ if _column_exists(db, "aiproviders", "gateway_base_url"):
+ db.execute(text("ALTER TABLE aiproviders DROP COLUMN gateway_base_url"))
+ print("Dropped aiproviders.gateway_base_url")
+
+ if _column_exists(db, "aiproviders", "gateway_interface"):
+ db.execute(text("ALTER TABLE aiproviders DROP COLUMN gateway_interface"))
+ print("Dropped aiproviders.gateway_interface")
diff --git a/app/migrations/055_gateway_auth_overrides.py b/app/migrations/055_gateway_auth_overrides.py
new file mode 100644
index 00000000..7bf8c745
--- /dev/null
+++ b/app/migrations/055_gateway_auth_overrides.py
@@ -0,0 +1,77 @@
+"""
+Migration: Per-credential Bifrost gateway auth header and secret overrides.
+
+Allows each AI Provider to specify a custom auth header name, an environment
+variable name for the secret, or an encrypted inline gateway auth secret.
+"""
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+description = (
+ "Add gateway_auth_header, gateway_auth_secret_env, and gateway_auth_secret "
+ "on aiproviders for per-credential Bifrost authentication."
+)
+
+
+def _column_exists(db: Session, table_name: str, column_name: str) -> bool:
+ row = db.execute(
+ text(
+ """
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_name = :table_name AND column_name = :column_name
+ """
+ ),
+ {"table_name": table_name, "column_name": column_name},
+ ).first()
+ return row is not None
+
+
+def upgrade(db: Session):
+ if not _column_exists(db, "aiproviders", "gateway_auth_header"):
+ db.execute(
+ text(
+ """
+ ALTER TABLE aiproviders
+ ADD COLUMN gateway_auth_header VARCHAR(64) NULL
+ """
+ )
+ )
+ print("Added aiproviders.gateway_auth_header (varchar, nullable)")
+
+ if not _column_exists(db, "aiproviders", "gateway_auth_secret_env"):
+ db.execute(
+ text(
+ """
+ ALTER TABLE aiproviders
+ ADD COLUMN gateway_auth_secret_env VARCHAR(128) NULL
+ """
+ )
+ )
+ print("Added aiproviders.gateway_auth_secret_env (varchar, nullable)")
+
+ if not _column_exists(db, "aiproviders", "gateway_auth_secret"):
+ db.execute(
+ text(
+ """
+ ALTER TABLE aiproviders
+ ADD COLUMN gateway_auth_secret VARCHAR NULL
+ """
+ )
+ )
+ print("Added aiproviders.gateway_auth_secret (varchar, nullable, encrypted at rest)")
+
+
+def downgrade(db: Session):
+ if _column_exists(db, "aiproviders", "gateway_auth_secret"):
+ db.execute(text("ALTER TABLE aiproviders DROP COLUMN gateway_auth_secret"))
+ print("Dropped aiproviders.gateway_auth_secret")
+
+ if _column_exists(db, "aiproviders", "gateway_auth_secret_env"):
+ db.execute(text("ALTER TABLE aiproviders DROP COLUMN gateway_auth_secret_env"))
+ print("Dropped aiproviders.gateway_auth_secret_env")
+
+ if _column_exists(db, "aiproviders", "gateway_auth_header"):
+ db.execute(text("ALTER TABLE aiproviders DROP COLUMN gateway_auth_header"))
+ print("Dropped aiproviders.gateway_auth_header")
diff --git a/app/migrations/056_gateway_extra_headers.py b/app/migrations/056_gateway_extra_headers.py
new file mode 100644
index 00000000..a17fbed0
--- /dev/null
+++ b/app/migrations/056_gateway_extra_headers.py
@@ -0,0 +1,44 @@
+"""
+Migration: Arbitrary per-credential gateway HTTP headers.
+
+Adds ``gateway_extra_headers`` JSON on aiproviders for custom headers sent
+with every gateway-routed LiteLLM call.
+"""
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+description = "Add gateway_extra_headers JSON on aiproviders for custom gateway headers."
+
+
+def _column_exists(db: Session, table_name: str, column_name: str) -> bool:
+ row = db.execute(
+ text(
+ """
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_name = :table_name AND column_name = :column_name
+ """
+ ),
+ {"table_name": table_name, "column_name": column_name},
+ ).first()
+ return row is not None
+
+
+def upgrade(db: Session):
+ if not _column_exists(db, "aiproviders", "gateway_extra_headers"):
+ db.execute(
+ text(
+ """
+ ALTER TABLE aiproviders
+ ADD COLUMN gateway_extra_headers JSON NULL
+ """
+ )
+ )
+ print("Added aiproviders.gateway_extra_headers (json, nullable)")
+
+
+def downgrade(db: Session):
+ if _column_exists(db, "aiproviders", "gateway_extra_headers"):
+ db.execute(text("ALTER TABLE aiproviders DROP COLUMN gateway_extra_headers"))
+ print("Dropped aiproviders.gateway_extra_headers")
diff --git a/app/models/database.py b/app/models/database.py
index a8f6526c..d60e4d24 100644
--- a/app/models/database.py
+++ b/app/models/database.py
@@ -633,6 +633,18 @@ class AIProvider(Base):
routing_mode = Column(String(20), nullable=False, default="inherit", server_default="inherit")
# Bifrost custom model ID used when routing via gateway
gateway_model = Column(String(255), nullable=True)
+ # inherit | litellm_shim | native_openai — Bifrost API surface override
+ gateway_interface = Column(String(20), nullable=False, default="inherit", server_default="inherit")
+ # Optional per-credential Bifrost/gateway base URL override
+ gateway_base_url = Column(String(512), nullable=True)
+ # Optional auth header for Bifrost (e.g. x-bf-vk, Authorization, x-api-key)
+ gateway_auth_header = Column(String(64), nullable=True)
+ # Env var name whose value is sent as the gateway auth secret
+ gateway_auth_secret_env = Column(String(128), nullable=True)
+ # Encrypted inline gateway auth secret (alternative to env var)
+ gateway_auth_secret = Column(String, nullable=True)
+ # Arbitrary HTTP headers sent with gateway-routed LiteLLM calls
+ gateway_extra_headers = 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())
last_tested_at = Column(DateTime(timezone=True), nullable=True) # When API key was last validated
diff --git a/app/models/enums.py b/app/models/enums.py
index 63476a00..e0df56ca 100644
--- a/app/models/enums.py
+++ b/app/models/enums.py
@@ -116,6 +116,13 @@ class CredentialRoutingMode(str, enum.Enum):
DIRECT = "direct"
+class GatewayInterfaceMode(str, enum.Enum):
+ """How Bifrost is reached when gateway routing is active."""
+ INHERIT = "inherit"
+ LITELLM_SHIM = "litellm_shim"
+ NATIVE_OPENAI = "native_openai"
+
+
class ModelProvider(str, enum.Enum):
"""Model provider enumeration for extensibility."""
OPENAI = "openai"
diff --git a/app/models/schemas.py b/app/models/schemas.py
index 56737f14..d67d9e54 100644
--- a/app/models/schemas.py
+++ b/app/models/schemas.py
@@ -1,13 +1,14 @@
"""Pydantic schemas for request/response validation."""
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator, validator
+import re
from typing import Optional, List, Dict, Any, Literal
from datetime import date, datetime
from uuid import UUID
from app.models.enums import (
EvaluationType, EvaluationStatus, EvaluatorResultStatus, RoleEnum, InvitationStatus,
LanguageEnum, CallTypeEnum, CallMediumEnum, GenderEnum, AccentEnum, BackgroundNoiseEnum,
- IntegrationPlatform, ModelProvider, CredentialRoutingMode, VoiceBundleType, TestAgentConversationStatus,
+ IntegrationPlatform, ModelProvider, CredentialRoutingMode, GatewayInterfaceMode, VoiceBundleType, TestAgentConversationStatus,
MetricType, MetricCategory, MetricTrigger, CallRecordingStatus, AlertMetricType, AlertAggregation,
AlertOperator, AlertNotifyFrequency, AlertStatus, AlertHistoryStatus, CronJobStatus,
CallImportStatus, CallImportRowStatus, CallImportParameterType,
@@ -685,6 +686,38 @@ class S3UploadResponse(BaseModel):
# AIProvider Schemas
+_MAX_GATEWAY_EXTRA_HEADERS = 20
+
+
+def _validate_gateway_extra_headers(
+ value: Optional[Dict[str, Any]],
+) -> Optional[Dict[str, str]]:
+ if value is None:
+ return None
+ if not isinstance(value, dict):
+ raise ValueError("gateway_extra_headers must be a JSON object of string keys and values.")
+ if len(value) > _MAX_GATEWAY_EXTRA_HEADERS:
+ raise ValueError(
+ f"gateway_extra_headers supports at most {_MAX_GATEWAY_EXTRA_HEADERS} headers."
+ )
+ normalized: Dict[str, str] = {}
+ for raw_key, raw_val in value.items():
+ key = str(raw_key).strip()
+ if not key:
+ raise ValueError("gateway_extra_headers keys must be non-empty strings.")
+ if len(key) > 64 or any(ch.isspace() for ch in key):
+ raise ValueError(f"Invalid gateway header name: {key!r}")
+ if raw_val is None:
+ raise ValueError(f"gateway_extra_headers[{key!r}] must be a string value.")
+ val = str(raw_val).strip()
+ if not val:
+ raise ValueError(f"gateway_extra_headers[{key!r}] must be a non-empty string.")
+ if len(val) > 1024 or "\n" in val or "\r" in val:
+ raise ValueError(f"gateway_extra_headers[{key!r}] value is invalid.")
+ normalized[key] = val
+ return normalized or None
+
+
class AIProviderCreate(BaseModel):
"""Schema for creating an AI Provider."""
provider: ModelProvider
@@ -706,6 +739,33 @@ class AIProviderCreate(BaseModel):
max_length=255,
description="Bifrost custom model ID sent when routing via gateway.",
)
+ gateway_interface: GatewayInterfaceMode = Field(
+ GatewayInterfaceMode.INHERIT,
+ description="Bifrost API surface: inherit org default, LiteLLM shim, or native OpenAI-compatible.",
+ )
+ gateway_base_url: Optional[str] = Field(
+ None,
+ max_length=512,
+ description="Optional per-credential Bifrost/gateway base URL override.",
+ )
+ gateway_auth_header: Optional[str] = Field(
+ None,
+ max_length=64,
+ description="Auth header name for Bifrost (default x-bf-vk).",
+ )
+ gateway_auth_secret_env: Optional[str] = Field(
+ None,
+ max_length=128,
+ description="Environment variable name holding the gateway auth secret.",
+ )
+ gateway_auth_secret: Optional[str] = Field(
+ None,
+ description="Inline gateway auth secret (encrypted at rest). Alternative to env var.",
+ )
+ gateway_extra_headers: Optional[Dict[str, str]] = Field(
+ None,
+ description="Arbitrary HTTP headers sent with gateway-routed LiteLLM calls.",
+ )
is_default: Optional[bool] = Field(
None,
description=(
@@ -730,6 +790,53 @@ def validate_gateway_model(cls, v: Optional[str]) -> Optional[str]:
trimmed = v.strip()
return trimmed or None
+ @field_validator("gateway_base_url")
+ @classmethod
+ def validate_gateway_base_url(cls, v: Optional[str]) -> Optional[str]:
+ if v is None:
+ return v
+ trimmed = v.strip()
+ return trimmed or None
+
+ @field_validator("gateway_auth_header")
+ @classmethod
+ def validate_gateway_auth_header(cls, v: Optional[str]) -> Optional[str]:
+ if v is None:
+ return v
+ trimmed = v.strip()
+ if not trimmed:
+ return None
+ if len(trimmed) > 64 or any(ch.isspace() for ch in trimmed):
+ raise ValueError("gateway_auth_header must be a single non-empty header name.")
+ return trimmed
+
+ @field_validator("gateway_auth_secret_env")
+ @classmethod
+ def validate_gateway_auth_secret_env(cls, v: Optional[str]) -> Optional[str]:
+ if v is None:
+ return v
+ trimmed = v.strip()
+ if not trimmed:
+ return None
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", trimmed):
+ raise ValueError(
+ "gateway_auth_secret_env must be a valid environment variable name."
+ )
+ return trimmed
+
+ @field_validator("gateway_auth_secret")
+ @classmethod
+ def validate_gateway_auth_secret(cls, v: Optional[str]) -> Optional[str]:
+ if v is None:
+ return v
+ trimmed = v.strip()
+ return trimmed or None
+
+ @field_validator("gateway_extra_headers")
+ @classmethod
+ def validate_gateway_extra_headers(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
+ return _validate_gateway_extra_headers(v)
+
class AIProviderUpdate(BaseModel):
"""Schema for updating an AI Provider."""
@@ -738,6 +845,13 @@ class AIProviderUpdate(BaseModel):
is_active: Optional[bool] = None
routing_mode: Optional[CredentialRoutingMode] = None
gateway_model: Optional[str] = Field(None, min_length=1, max_length=255)
+ gateway_interface: Optional[GatewayInterfaceMode] = None
+ gateway_base_url: Optional[str] = Field(None, max_length=512)
+ gateway_auth_header: Optional[str] = Field(None, max_length=64)
+ gateway_auth_secret_env: Optional[str] = Field(None, max_length=128)
+ gateway_auth_secret: Optional[str] = None
+ clear_gateway_auth_secret: bool = False
+ gateway_extra_headers: Optional[Dict[str, str]] = None
@field_validator("gateway_model")
@classmethod
@@ -747,6 +861,55 @@ def validate_gateway_model(cls, v: Optional[str]) -> Optional[str]:
trimmed = v.strip()
return trimmed or None
+ @field_validator("gateway_base_url")
+ @classmethod
+ def validate_gateway_base_url_update(cls, v: Optional[str]) -> Optional[str]:
+ if v is None:
+ return v
+ trimmed = v.strip()
+ return trimmed or None
+
+ @field_validator("gateway_auth_header")
+ @classmethod
+ def validate_gateway_auth_header_update(cls, v: Optional[str]) -> Optional[str]:
+ if v is None:
+ return v
+ trimmed = v.strip()
+ if not trimmed:
+ return None
+ if len(trimmed) > 64 or any(ch.isspace() for ch in trimmed):
+ raise ValueError("gateway_auth_header must be a single non-empty header name.")
+ return trimmed
+
+ @field_validator("gateway_auth_secret_env")
+ @classmethod
+ def validate_gateway_auth_secret_env_update(cls, v: Optional[str]) -> Optional[str]:
+ if v is None:
+ return v
+ trimmed = v.strip()
+ if not trimmed:
+ return None
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", trimmed):
+ raise ValueError(
+ "gateway_auth_secret_env must be a valid environment variable name."
+ )
+ return trimmed
+
+ @field_validator("gateway_auth_secret")
+ @classmethod
+ def validate_gateway_auth_secret_update(cls, v: Optional[str]) -> Optional[str]:
+ if v is None:
+ return v
+ trimmed = v.strip()
+ return trimmed or None
+
+ @field_validator("gateway_extra_headers")
+ @classmethod
+ def validate_gateway_extra_headers_update(
+ cls, v: Optional[Dict[str, Any]]
+ ) -> Optional[Dict[str, str]]:
+ return _validate_gateway_extra_headers(v)
+
class AIProviderResponse(BaseModel):
"""Schema for AI Provider response."""
@@ -758,8 +921,15 @@ class AIProviderResponse(BaseModel):
is_default: bool = False
routing_mode: CredentialRoutingMode = CredentialRoutingMode.INHERIT
gateway_model: Optional[str] = None
+ gateway_interface: GatewayInterfaceMode = GatewayInterfaceMode.INHERIT
+ gateway_base_url: Optional[str] = None
+ gateway_auth_header: Optional[str] = None
+ gateway_auth_secret_env: Optional[str] = None
+ has_gateway_auth_secret: bool = False
+ gateway_extra_headers: Optional[Dict[str, str]] = None
gateway_managed: bool = False
effective_routing: Literal["inherit", "direct", "gateway", "bifrost", "litellm_proxy"] = "inherit"
+ effective_gateway_interface: Literal["litellm_shim", "native_openai"] = "litellm_shim"
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
index 0e270023..35fb749a 100644
--- a/app/services/ai/llm_gateway.py
+++ b/app/services/ai/llm_gateway.py
@@ -9,6 +9,7 @@
from __future__ import annotations
+import os
from dataclasses import dataclass
from typing import Any, Dict, Literal, Optional, Tuple
from urllib.parse import urlparse
@@ -21,6 +22,8 @@
GatewayType = Literal["bifrost", "litellm_proxy"]
+GatewayInterface = Literal["litellm_shim", "native_openai"]
+GatewayInterfaceOverride = Literal["inherit", "litellm_shim", "native_openai"]
RoutingMode = Literal["inherit", "gateway", "direct"]
EffectiveRouting = Literal["direct", "bifrost", "litellm_proxy"]
@@ -52,6 +55,12 @@ class CredentialRoutingContext:
routing_mode: RoutingMode = "inherit"
gateway_model: Optional[str] = None
+ gateway_interface: GatewayInterfaceOverride = "inherit"
+ gateway_base_url: Optional[str] = None
+ gateway_auth_header: Optional[str] = None
+ gateway_auth_secret_env: Optional[str] = None
+ gateway_auth_secret: Optional[str] = None
+ gateway_extra_headers: Optional[Dict[str, str]] = None
def _normalize_routing_mode(value: Any) -> RoutingMode:
@@ -62,14 +71,69 @@ def _normalize_routing_mode(value: Any) -> RoutingMode:
return "inherit"
+def _normalize_gateway_interface(value: Any) -> GatewayInterfaceOverride:
+ iface = value.value if hasattr(value, "value") else value
+ iface = str(iface or "inherit").strip().lower()
+ if iface in ("inherit", "litellm_shim", "native_openai"):
+ return iface # type: ignore[return-value]
+ return "inherit"
+
+
+def _normalize_gateway_extra_headers(value: Any) -> Optional[Dict[str, str]]:
+ if not value:
+ return None
+ if not isinstance(value, dict):
+ return None
+ normalized: Dict[str, str] = {}
+ for raw_key, raw_val in value.items():
+ key = str(raw_key).strip()
+ if not key:
+ continue
+ normalized[key] = str(raw_val)
+ return normalized or None
+
+
+def _decrypt_gateway_auth_secret(encrypted: Any) -> Optional[str]:
+ 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 credential gateway auth secret: {}", exc)
+ return None
+
+
def routing_context_from_ai_provider(provider: Any) -> CredentialRoutingContext:
"""Build routing context from an ``AIProvider`` row."""
gateway_model = getattr(provider, "gateway_model", None)
if gateway_model:
gateway_model = str(gateway_model).strip() or None
+ gateway_base_url = getattr(provider, "gateway_base_url", None)
+ if gateway_base_url:
+ gateway_base_url = str(gateway_base_url).strip() or None
+ gateway_auth_header = getattr(provider, "gateway_auth_header", None)
+ if gateway_auth_header:
+ gateway_auth_header = str(gateway_auth_header).strip() or None
+ gateway_auth_secret_env = getattr(provider, "gateway_auth_secret_env", None)
+ if gateway_auth_secret_env:
+ gateway_auth_secret_env = str(gateway_auth_secret_env).strip() or None
return CredentialRoutingContext(
routing_mode=_normalize_routing_mode(getattr(provider, "routing_mode", "inherit")),
gateway_model=gateway_model,
+ gateway_interface=_normalize_gateway_interface(
+ getattr(provider, "gateway_interface", "inherit")
+ ),
+ gateway_base_url=gateway_base_url,
+ gateway_auth_header=gateway_auth_header,
+ gateway_auth_secret_env=gateway_auth_secret_env,
+ gateway_auth_secret=_decrypt_gateway_auth_secret(
+ getattr(provider, "gateway_auth_secret", None)
+ ),
+ gateway_extra_headers=_normalize_gateway_extra_headers(
+ getattr(provider, "gateway_extra_headers", None)
+ ),
)
@@ -122,17 +186,10 @@ def resolve_litellm_api_key(
)
return raw_key
- if raw_key != GATEWAY_MANAGED_KEY_SENTINEL:
- return raw_key
-
- if gateway_config and not gateway_config.passthrough_provider_keys:
+ if raw_key == GATEWAY_MANAGED_KEY_SENTINEL:
return None
- raise RuntimeError(
- "AI provider is configured for gateway-managed credentials, "
- "but the LLM gateway is not active for this credential. "
- "Enable the LLM Gateway or add a provider API key."
- )
+ return raw_key
@dataclass(frozen=True)
@@ -140,8 +197,11 @@ class LLMGatewayConfig:
"""Resolved gateway settings for a single LiteLLM call."""
gateway_type: GatewayType
+ gateway_interface: GatewayInterface
api_base: str
virtual_key: Optional[str] = None
+ auth_header: str = "x-bf-vk"
+ extra_headers: Optional[Dict[str, str]] = None
master_key: Optional[str] = None
passthrough_provider_keys: bool = True
@@ -166,6 +226,56 @@ def normalize_bifrost_url(base_url: str) -> str:
return url
+_NATIVE_OPENAI_PATH_SUFFIXES = (
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/chat/completions",
+ "/litellm",
+)
+
+
+def normalize_bifrost_native_url(base_url: str) -> str:
+ """Ensure a Bifrost native OpenAI-compatible URL is well-formed.
+
+ LiteLLM passes ``api_base`` to the OpenAI client, which appends
+ ``/chat/completions`` (not ``/v1/chat/completions``). The base must
+ therefore end with ``/v1``, e.g. ``http://localhost:8080/v1``.
+ """
+ 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)."
+ )
+ original = url
+ changed = False
+ while True:
+ stripped = False
+ for suffix in _NATIVE_OPENAI_PATH_SUFFIXES:
+ if url.endswith(suffix):
+ url = url[: -len(suffix)]
+ stripped = True
+ changed = True
+ break
+ if not stripped:
+ break
+ if not url.endswith("/v1"):
+ url = f"{url}/v1"
+ changed = True
+ if changed:
+ logger.warning(
+ "Normalized native Bifrost base_url '{}' -> '{}'. "
+ "Use the host root only (e.g. http://localhost:8080); "
+ "LiteLLM appends /chat/completions to a /v1 base automatically.",
+ original,
+ url,
+ )
+ 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("/")
@@ -180,8 +290,14 @@ def normalize_litellm_proxy_url(base_url: str) -> str:
return url
-def _normalize_base_url(base_url: str, gateway_type: GatewayType) -> str:
+def _normalize_base_url(
+ base_url: str,
+ gateway_type: GatewayType,
+ gateway_interface: GatewayInterface,
+) -> str:
if gateway_type == "bifrost":
+ if gateway_interface == "native_openai":
+ return normalize_bifrost_native_url(base_url)
return normalize_bifrost_url(base_url)
return normalize_litellm_proxy_url(base_url)
@@ -197,6 +313,7 @@ def _platform_config() -> Dict[str, Any]:
"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),
+ "gateway_interface": (settings.LLM_GATEWAY_INTERFACE or "litellm_shim").strip().lower(),
}
@@ -241,6 +358,73 @@ def _resolve_gateway_type(org: Dict[str, Any], platform: Dict[str, Any]) -> Gate
return "bifrost"
+def _resolve_gateway_interface(
+ credential: Optional[CredentialRoutingContext],
+ org: Dict[str, Any],
+ platform: Dict[str, Any],
+) -> GatewayInterface:
+ if credential and credential.gateway_interface in ("litellm_shim", "native_openai"):
+ return credential.gateway_interface
+
+ org_interface = org.get("gateway_interface")
+ if org_interface in ("litellm_shim", "native_openai"):
+ return org_interface
+
+ platform_interface = platform.get("gateway_interface", "litellm_shim")
+ if platform_interface in ("litellm_shim", "native_openai"):
+ return platform_interface
+
+ return "litellm_shim"
+
+
+def _resolve_gateway_base_url(
+ credential: Optional[CredentialRoutingContext],
+ org: Dict[str, Any],
+ platform: Dict[str, Any],
+) -> str:
+ if credential and credential.gateway_base_url:
+ return credential.gateway_base_url.strip()
+ return (org.get("base_url") or platform.get("base_url") or "").strip()
+
+
+def _resolve_gateway_auth_header(credential: Optional[CredentialRoutingContext]) -> str:
+ if credential and credential.gateway_auth_header:
+ header = credential.gateway_auth_header.strip()
+ if header:
+ return header
+ return "x-bf-vk"
+
+
+def _resolve_gateway_auth_secret(
+ credential: Optional[CredentialRoutingContext],
+ org: Dict[str, Any],
+ platform: Dict[str, Any],
+) -> Optional[str]:
+ if credential and credential.gateway_auth_secret_env:
+ env_name = credential.gateway_auth_secret_env.strip()
+ if env_name:
+ secret = (os.environ.get(env_name) or "").strip()
+ if secret:
+ return secret
+ logger.warning(
+ "Gateway auth env var '{}' is unset or empty; falling back to org/platform keys.",
+ env_name,
+ )
+
+ if credential and credential.gateway_auth_secret:
+ return credential.gateway_auth_secret.strip() or None
+
+ return _decrypt_org_virtual_key(org) or platform.get("virtual_key")
+
+
+def _format_gateway_auth_header_value(header_name: str, secret: str) -> str:
+ if header_name.lower() == "authorization":
+ if secret.lower().startswith("bearer "):
+ return secret
+ return f"Bearer {secret}"
+ return secret
+
+
def _org_wants_gateway(
org: Dict[str, Any],
platform: Dict[str, Any],
@@ -266,12 +450,14 @@ def _build_gateway_config(
org: Dict[str, Any],
platform: Dict[str, Any],
*,
+ credential: Optional[CredentialRoutingContext],
credential_mode: RoutingMode,
strict: bool,
) -> Optional[LLMGatewayConfig]:
- """Resolve gateway connection details from org/platform settings."""
+ """Resolve gateway connection details from org/platform/credential settings."""
gateway_type = _resolve_gateway_type(org, platform)
- base_url = (org.get("base_url") or platform["base_url"] or "").strip()
+ gateway_interface = _resolve_gateway_interface(credential, org, platform)
+ base_url = _resolve_gateway_base_url(credential, org, platform)
if not base_url:
message = (
@@ -285,7 +471,7 @@ def _build_gateway_config(
return None
try:
- api_base = _normalize_base_url(base_url, gateway_type)
+ api_base = _normalize_base_url(base_url, gateway_type, gateway_interface)
except ValueError as exc:
message = (
f"Invalid LLM gateway base_url for org {organization_id} "
@@ -296,13 +482,22 @@ def _build_gateway_config(
logger.warning("{} Falling back to direct provider routing.", message)
return None
- virtual_key = _decrypt_org_virtual_key(org) or platform["virtual_key"]
+ virtual_key = _resolve_gateway_auth_secret(credential, org, platform)
+ auth_header = _resolve_gateway_auth_header(credential)
+ extra_headers = (
+ dict(credential.gateway_extra_headers)
+ if credential and credential.gateway_extra_headers
+ else None
+ )
master_key = _decrypt_org_master_key(org) or platform["master_key"]
return LLMGatewayConfig(
gateway_type=gateway_type,
+ gateway_interface=gateway_interface if gateway_type == "bifrost" else "litellm_shim",
api_base=api_base,
virtual_key=virtual_key,
+ auth_header=auth_header,
+ extra_headers=extra_headers,
master_key=master_key,
passthrough_provider_keys=platform["passthrough_provider_keys"],
)
@@ -330,6 +525,7 @@ def resolve_effective_routing(
organization_id,
org,
platform,
+ credential=credential,
credential_mode=credential_mode,
strict=strict,
)
@@ -352,6 +548,20 @@ def resolve_llm_gateway(
return config
+def get_credential_effective_gateway_interface(
+ organization_id: UUID,
+ db: Session,
+ gateway_interface: Optional[str],
+) -> GatewayInterface:
+ """Resolved Bifrost API surface for a credential (for API responses)."""
+ credential = CredentialRoutingContext(
+ gateway_interface=_normalize_gateway_interface(gateway_interface or "inherit"),
+ )
+ org = _get_org_raw_settings(organization_id, db)
+ platform = _platform_config()
+ return _resolve_gateway_interface(credential, org, platform)
+
+
def get_credential_effective_routing_label(
organization_id: UUID,
db: Session,
@@ -400,6 +610,22 @@ def _strip_provider_keys(result: Dict[str, Any]) -> None:
result.pop("api_key", None)
+def _merge_gateway_extra_headers(
+ call_kwargs: Dict[str, Any],
+ config: LLMGatewayConfig,
+) -> Dict[str, str]:
+ merged = dict(call_kwargs.get("extra_headers") or {})
+ if config.extra_headers:
+ merged.update(config.extra_headers)
+ if config.virtual_key:
+ header_name = config.auth_header or "x-bf-vk"
+ merged[header_name] = _format_gateway_auth_header_value(
+ header_name,
+ config.virtual_key,
+ )
+ return merged
+
+
def _apply_bifrost_gateway(
call_kwargs: Dict[str, Any],
config: LLMGatewayConfig,
@@ -409,10 +635,9 @@ def _apply_bifrost_gateway(
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
+ merged_headers = _merge_gateway_extra_headers(result, config)
+ if merged_headers:
+ result["extra_headers"] = merged_headers
if not config.passthrough_provider_keys:
_strip_provider_keys(result)
@@ -440,6 +665,10 @@ def _apply_litellm_proxy_gateway(
result = dict(call_kwargs)
result["api_base"] = config.api_base
+ merged_headers = _merge_gateway_extra_headers(result, config)
+ if merged_headers:
+ result["extra_headers"] = merged_headers
+
if not config.passthrough_provider_keys:
_strip_provider_keys(result)
diff --git a/app/services/ai/llm_gateway_settings.py b/app/services/ai/llm_gateway_settings.py
index 42662ddc..95f5e459 100644
--- a/app/services/ai/llm_gateway_settings.py
+++ b/app/services/ai/llm_gateway_settings.py
@@ -16,9 +16,11 @@
from app.models.database import Organization
from app.services.ai.llm_gateway import (
EffectiveRouting,
+ GatewayInterface,
GatewayType,
_platform_config,
gateway_managed_credentials_enabled,
+ normalize_bifrost_native_url,
normalize_bifrost_url,
normalize_litellm_proxy_url,
resolve_llm_gateway,
@@ -26,6 +28,7 @@
GatewayMode = Literal["inherit", "enabled", "disabled"]
GatewayTypeOverride = Literal["inherit", "bifrost", "litellm_proxy"]
+GatewayInterfaceOverride = Literal["inherit", "litellm_shim", "native_openai"]
def _mode_from_enabled(enabled: Any) -> GatewayMode:
@@ -57,8 +60,33 @@ def _effective_routing(resolved: Any) -> EffectiveRouting:
return resolved.gateway_type
-def _normalize_url_for_type(base_url: str, gateway_type: GatewayType) -> str:
+def _gateway_interface_from_stored(raw: Dict[str, Any]) -> GatewayInterfaceOverride:
+ stored = raw.get("gateway_interface")
+ if stored in ("litellm_shim", "native_openai"):
+ return stored
+ return "inherit"
+
+
+def _resolve_effective_gateway_interface(
+ org_raw: Dict[str, Any], platform: Dict[str, Any]
+) -> GatewayInterface:
+ org_interface = org_raw.get("gateway_interface")
+ if org_interface in ("litellm_shim", "native_openai"):
+ return org_interface
+ platform_interface = platform.get("gateway_interface", "litellm_shim")
+ if platform_interface in ("litellm_shim", "native_openai"):
+ return platform_interface
+ return "litellm_shim"
+
+
+def _normalize_url_for_type(
+ base_url: str,
+ gateway_type: GatewayType,
+ gateway_interface: GatewayInterface = "litellm_shim",
+) -> str:
if gateway_type == "bifrost":
+ if gateway_interface == "native_openai":
+ return normalize_bifrost_native_url(base_url)
return normalize_bifrost_url(base_url)
return normalize_litellm_proxy_url(base_url)
@@ -85,22 +113,27 @@ def get_org_settings(organization_id: UUID, db: Session) -> Dict[str, Any]:
has_virtual_key = bool(raw.get("virtual_key"))
has_master_key = bool(raw.get("master_key"))
gateway_type = _gateway_type_from_stored(raw)
+ gateway_interface = _gateway_interface_from_stored(raw)
resolved = resolve_llm_gateway(organization_id, db)
platform = _platform_config()
effective_type = _resolve_effective_gateway_type(raw, platform)
+ effective_interface = _resolve_effective_gateway_interface(raw, platform)
return {
"mode": mode,
"gateway_type": gateway_type,
+ "gateway_interface": gateway_interface,
"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_gateway_interface": platform.get("gateway_interface", "litellm_shim"),
"platform_base_url": platform["base_url"],
"effective_routing": _effective_routing(resolved),
"effective_gateway_type": effective_type if resolved else None,
+ "effective_gateway_interface": effective_interface 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),
@@ -114,6 +147,7 @@ def set_org_settings(
*,
mode: GatewayMode,
gateway_type: GatewayTypeOverride = "inherit",
+ gateway_interface: GatewayInterfaceOverride = "inherit",
base_url: Optional[str] = None,
virtual_key: Optional[str] = None,
master_key: Optional[str] = None,
@@ -136,17 +170,31 @@ def set_org_settings(
else:
payload["gateway_type"] = gateway_type
+ if gateway_interface == "inherit":
+ payload["gateway_interface"] = None
+ else:
+ payload["gateway_interface"] = gateway_interface
+
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)
+ effective_interface_for_validation = _resolve_effective_gateway_interface(
+ {"gateway_interface": payload.get("gateway_interface")},
+ platform,
+ )
+
if base_url is not None:
trimmed = base_url.strip()
if trimmed:
try:
- _normalize_url_for_type(trimmed, effective_type_for_validation)
+ _normalize_url_for_type(
+ trimmed,
+ effective_type_for_validation,
+ effective_interface_for_validation,
+ )
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
payload["base_url"] = trimmed
diff --git a/app/services/ai/llm_resolver.py b/app/services/ai/llm_resolver.py
index 1c461576..d3143483 100644
--- a/app/services/ai/llm_resolver.py
+++ b/app/services/ai/llm_resolver.py
@@ -11,10 +11,16 @@
from uuid import UUID
from fastapi import HTTPException
+from sqlalchemy import desc
from sqlalchemy.orm import Session
from app.models.database import AIProvider
from app.models.enums import ModelProvider
+from app.services.ai.llm_gateway import (
+ resolve_effective_routing,
+ routing_context_from_ai_provider,
+)
+from app.services.credentials import resolve_ai_provider
_DEFAULT_MODELS: dict[ModelProvider, str] = {
@@ -23,54 +29,165 @@
ModelProvider.GOOGLE: "gemini-2.0-flash",
}
+_AUTO_DETECT_PRIORITY = (
+ ModelProvider.OPENAI,
+ ModelProvider.ANTHROPIC,
+ ModelProvider.GOOGLE,
+)
+
+
+def _provider_enum(provider: str) -> ModelProvider:
+ try:
+ return ModelProvider(provider.lower())
+ except ValueError:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unsupported LLM provider: {provider}",
+ )
+
+
+def _default_model_for(provider: ModelProvider) -> str:
+ return _DEFAULT_MODELS.get(provider, "gpt-5-mini")
+
+
+def _resolved_model_for_row(
+ organization_id: UUID,
+ db: Session,
+ ai_prov: AIProvider,
+ explicit_model: Optional[str],
+) -> str:
+ if explicit_model:
+ return explicit_model
+ ctx = routing_context_from_ai_provider(ai_prov)
+ _, effective = resolve_effective_routing(organization_id, db, ctx)
+ if effective != "direct" and ctx.gateway_model:
+ return ctx.gateway_model
+ try:
+ return _default_model_for(ModelProvider(ai_prov.provider.lower()))
+ except ValueError:
+ return "gpt-5-mini"
+
def get_llm_provider_and_model(
organization_id: UUID,
db: Session,
provider: Optional[str] = None,
model: Optional[str] = None,
+ credential_id: Optional[UUID] = None,
) -> Tuple[ModelProvider, str]:
"""Resolve ``(provider_enum, model_str)`` for a one-off LLM call.
- * If both ``provider`` and ``model`` are supplied by the caller we
- validate the provider against the ``ModelProvider`` enum and pass
- both straight through.
- * Otherwise we look at the org's active ``AIProvider`` rows in the
- preference order ``OpenAI -> Anthropic -> Google`` and pair the
- first match with a sensible default model.
+ * When ``credential_id`` is supplied we resolve that exact active
+ ``AIProvider`` row (required when multiple credentials share a
+ provider, e.g. several ``custom`` gateway models).
+ * When ``provider`` is supplied we resolve the matching active
+ ``AIProvider`` row. An omitted ``model`` uses the credential's
+ ``gateway_model`` when gateway routing is active, otherwise a
+ provider-specific default.
+ * When both are omitted we prefer the org-wide default credential,
+ then OpenAI -> Anthropic -> Google, then any other active row.
* Raises ``HTTPException(400)`` with an actionable message when no
- AI provider has been configured at all.
+ AI provider has been configured.
Tests rely on patching ``app.services.ai.llm_resolver.get_llm_provider_and_model``,
so the public surface here is intentionally minimal.
"""
- if provider and model:
- try:
- provider_enum = ModelProvider(provider.lower())
- except ValueError:
- raise HTTPException(
- status_code=400,
- detail=f"Unsupported LLM provider: {provider}",
- )
- return provider_enum, model
+ explicit_model = (model or "").strip() or None
+ explicit_provider = (provider or "").strip() or None
- for prov in (ModelProvider.OPENAI, ModelProvider.ANTHROPIC, ModelProvider.GOOGLE):
+ if credential_id is not None:
ai_prov = (
db.query(AIProvider)
.filter(
+ AIProvider.id == credential_id,
AIProvider.organization_id == organization_id,
- AIProvider.is_active == True, # noqa: E712 (SQLAlchemy boolean)
- AIProvider.provider == prov.value,
+ AIProvider.is_active == True, # noqa: E712
)
.first()
)
+ if not ai_prov:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"No active AI provider found for credential {credential_id}. "
+ "Check AI Providers settings."
+ ),
+ )
+ provider_enum = _provider_enum(ai_prov.provider)
+ if (
+ explicit_provider
+ and explicit_provider.lower() != ai_prov.provider.lower()
+ ):
+ raise HTTPException(
+ status_code=400,
+ detail="provider does not match the selected credential.",
+ )
+ model_str = _resolved_model_for_row(
+ organization_id, db, ai_prov, explicit_model
+ )
+ return provider_enum, model_str
+
+ if explicit_provider:
+ provider_enum = _provider_enum(explicit_provider)
+ ai_prov = resolve_ai_provider(provider_enum.value, db, organization_id)
+ if not ai_prov:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"No active AI provider configured for {explicit_provider}. "
+ "Add one in AI Providers settings."
+ ),
+ )
+ model_str = _resolved_model_for_row(
+ organization_id, db, ai_prov, explicit_model
+ )
+ return provider_enum, model_str
+
+ default_row = (
+ db.query(AIProvider)
+ .filter(
+ AIProvider.organization_id == organization_id,
+ AIProvider.is_active == True, # noqa: E712 (SQLAlchemy boolean)
+ AIProvider.is_default == True, # noqa: E712
+ )
+ .order_by(desc(AIProvider.updated_at))
+ .first()
+ )
+ if default_row:
+ provider_enum = _provider_enum(default_row.provider)
+ model_str = _resolved_model_for_row(
+ organization_id, db, default_row, explicit_model
+ )
+ return provider_enum, model_str
+
+ for prov in _AUTO_DETECT_PRIORITY:
+ ai_prov = resolve_ai_provider(prov.value, db, organization_id)
if ai_prov:
- return prov, model or _DEFAULT_MODELS.get(prov, "gpt-5-mini")
+ model_str = _resolved_model_for_row(
+ organization_id, db, ai_prov, explicit_model
+ )
+ return prov, model_str
+
+ fallback = (
+ db.query(AIProvider)
+ .filter(
+ AIProvider.organization_id == organization_id,
+ AIProvider.is_active == True, # noqa: E712
+ )
+ .order_by(desc(AIProvider.updated_at))
+ .first()
+ )
+ if fallback:
+ provider_enum = _provider_enum(fallback.provider)
+ model_str = _resolved_model_for_row(
+ organization_id, db, fallback, explicit_model
+ )
+ return provider_enum, model_str
raise HTTPException(
status_code=400,
detail=(
- "No active AI provider configured. Add an OpenAI, Anthropic, "
- "or Google provider in AI Providers settings."
+ "No active AI provider configured. Add an AI provider "
+ "in AI Providers settings."
),
)
diff --git a/config.yml.example b/config.yml.example
index ccc22d0c..ddd746af 100644
--- a/config.yml.example
+++ b/config.yml.example
@@ -175,6 +175,7 @@ judge_alignment:
# 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
+# gateway_interface: litellm_shim # litellm_shim | native_openai (Bifrost only)
# Enterprise License (JWT signed with RS256). Unlocks gated features like
# oidc_sso, mfa_enforce, audit_export, voice_playground, gepa_optimization,
diff --git a/docs-fumadocs/content/docs/getting-started/llm-gateway.mdx b/docs-fumadocs/content/docs/getting-started/llm-gateway.mdx
index ed3dea42..47f11bd3 100644
--- a/docs-fumadocs/content/docs/getting-started/llm-gateway.mdx
+++ b/docs-fumadocs/content/docs/getting-started/llm-gateway.mdx
@@ -14,6 +14,7 @@ Supported gateway types:
|------|-------------|
| **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 |
+| **Bifrost (native)** | Bifrost root OpenAI-compatible API (`/v1/chat/completions`) without the `/litellm` shim |
| **LiteLLM Proxy** | Self-hosted [LiteLLM Proxy](https://docs.litellm.ai/docs/proxy/quick_start) with optional master key |
## What is routed through the gateway
@@ -39,16 +40,18 @@ llm_gateway:
virtual_key: null # Bifrost only (x-bf-vk header)
master_key: null # LiteLLM Proxy only (LITELLM_MASTER_KEY)
passthrough_provider_keys: false
+ gateway_interface: litellm_shim # litellm_shim | native_openai (Bifrost only)
```
| 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`) |
+| `base_url` | Gateway endpoint (Bifrost shim: include `/litellm`; native: host root e.g. `http://localhost:8080` — EfficientAI appends `/v1` for 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 |
+| `gateway_interface` | Bifrost API surface: `litellm_shim` (default) or `native_openai` for custom models that fail through `/litellm` |
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.
@@ -90,7 +93,39 @@ Each **AI Provider** and **Voice Platform** credential can override org-level ga
When creating or editing an AI Provider with gateway routing, you can set an optional **Gateway Model** — a Bifrost custom model ID (e.g. `production-gpt4` or `openai/gpt-4o`). When set, batch/eval workloads that use this credential send that model string to the gateway instead of the workload-selected model.
-Workload UIs (evaluators, voice bundles, call import) continue to pick provider + model + credential as before. The selected credential controls routing and optional gateway model override.
+You can also override **Bifrost API surface** per credential:
+
+| Interface | `api_base` | Use case |
+|-----------|------------|----------|
+| **LiteLLM shim** (default) | `http://bifrost:8080/litellm` | Standard models through Bifrost's LiteLLM integration |
+| **Native OpenAI-compatible** | `http://bifrost:8080` (no `/litellm`) | Custom models (e.g. Gemma on customer cloud) that only work on Bifrost's native API |
+
+Optional **Gateway Base URL** on the credential overrides the org/platform URL for that integration only.
+
+### Per-credential gateway auth (no org virtual key required)
+
+Each AI Provider can override Bifrost authentication independently of org LLM Gateway settings:
+
+| Field | Maps to | Description |
+|-------|---------|-------------|
+| **Gateway Model** | `model` | Bifrost model ID, e.g. `inhouse-llm-server-v2//models/gemma-4-31B-it-AWQ-4bit` |
+| **Gateway Base URL** | `endpoint` | Per-credential Bifrost host, e.g. `http://localhost:8080` |
+| **Gateway Auth Header** | `authHeaderName` | Header name (default `x-bf-vk`; also supports `Authorization`, `x-api-key`, etc.) |
+| **Gateway Auth Secret Env Var** | `authSecretName` | Runtime env var holding the virtual key, e.g. `BIFROST_PRD_VK_GEMMA_4_31B` |
+| **Gateway Auth Secret** | inline secret | Alternative to env var — encrypted at rest in the DB |
+| **Gateway Extra Headers** | arbitrary headers | JSON object of additional HTTP headers, e.g. `{"X-Custom-Tenant": "prod"}` |
+
+Auth secret resolution order: credential env var → credential inline secret → org virtual key → platform virtual key. Configured auth header wins over the same key in **Gateway Extra Headers**.
+
+**Example — custom Gemma credential via Bifrost (no org VK configured):**
+
+1. AI Provider: `routing_mode=gateway`, `gateway_interface=native_openai`
+2. Gateway Model: `inhouse-llm-server-v2//models/gemma-4-31B-it-AWQ-4bit`
+3. Gateway Base URL: `http://localhost:8080`
+4. Gateway Auth Header: `x-bf-vk`
+5. Gateway Auth Secret Env Var: `BIFROST_PRD_VK_GEMMA_4_31B` (mounted in the EfficientAI deployment)
+
+Workload UIs (evaluators, voice bundles, call import) continue to pick provider + model + credential as before. The selected credential controls routing, API surface, URL override, auth header/secret, and optional gateway model override.
### Voice platform limitations
@@ -107,7 +142,7 @@ Only providers supported by **both** LiteLLM and your gateway deployment work th
## Example call shapes
-**Bifrost:**
+**Bifrost (LiteLLM shim):**
```python
litellm.completion(
@@ -118,6 +153,17 @@ litellm.completion(
)
```
+**Bifrost (native OpenAI-compatible):**
+
+```python
+litellm.completion(
+ model="custom-gemma-model",
+ messages=[{"role": "user", "content": "Hello!"}],
+ api_base="http://localhost:8080",
+ extra_headers={"x-bf-vk": "your-virtual-key"},
+)
+```
+
**LiteLLM Proxy:**
```python
diff --git a/frontend/src/components/AIProviderModelPicker.tsx b/frontend/src/components/AIProviderModelPicker.tsx
index 37d6439d..5fcad258 100644
--- a/frontend/src/components/AIProviderModelPicker.tsx
+++ b/frontend/src/components/AIProviderModelPicker.tsx
@@ -1,155 +1,200 @@
-import { useEffect } from 'react'
-import { useQuery } from '@tanstack/react-query'
-import { Bot } from 'lucide-react'
-import { apiClient } from '../lib/api'
-import LLMAdvancedOptionsPanel from './providers/LLMAdvancedOptionsPanel'
-import type { LLMGenerationConfig } from '../config/llmGenerationParams'
-
-interface AIProviderRow {
- id: string
- provider: string
- name: string | null
- is_active: boolean
-}
-
-const PROVIDER_LABELS: Record = {
- openai: 'OpenAI',
- anthropic: 'Anthropic',
- google: 'Google',
- deepseek: 'DeepSeek',
- groq: 'Groq',
-}
-
-/**
- * Inline two-up provider + model dropdown shared by every AI-generate
- * surface (Prompt Partials, Visualizations TLDR, etc.).
- *
- * Empty string for ``provider`` means "auto-detect" -- the backend
- * resolver will pick the org's first active OpenAI/Anthropic/Google
- * credential. When ``provider`` is set we surface that provider's
- * available LLM models from ``apiClient.getModelOptions``.
- *
- * The component is fully controlled: the parent owns ``provider`` /
- * ``model`` state and reacts to ``onProviderChange`` / ``onModelChange``
- * so it can persist or seed the picker between mounts (e.g. seed to
- * the previously-used provider on Regenerate).
- */
-export default function AIProviderModelPicker({
- provider,
- model,
- onProviderChange,
- onModelChange,
- llm_config,
- onLLMConfigChange,
- disabled = false,
- size = 'md',
- showAdvancedOptions = true,
-}: {
- provider: string
- model: string
- onProviderChange: (next: string) => void
- onModelChange: (next: string) => void
- llm_config?: LLMGenerationConfig | null
- onLLMConfigChange?: (next: LLMGenerationConfig | null) => void
- disabled?: boolean
- size?: 'sm' | 'md'
- showAdvancedOptions?: boolean
-}) {
- const { data: aiProviders = [] } = useQuery({
- queryKey: ['ai-providers'],
- queryFn: () => apiClient.listAIProviders(),
- })
-
- const activeProviders = aiProviders.filter((p) => p.is_active)
-
- const { data: modelOptions } = useQuery({
- queryKey: ['model-options', provider],
- queryFn: () => apiClient.getModelOptions(provider),
- enabled: !!provider,
- })
-
- const llmModels: string[] = modelOptions?.llm ?? []
-
- // When the provider switches, snap the model selection to that
- // provider's first available LLM so the parent never holds a model
- // that doesn't belong to the active provider.
- useEffect(() => {
- if (provider && llmModels.length > 0 && !llmModels.includes(model)) {
- onModelChange(llmModels[0])
- }
- }, [provider, llmModels, model, onModelChange])
-
- const inputClass =
- size === 'sm'
- ? 'w-full px-2.5 py-1.5 text-xs border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white disabled:bg-gray-50 disabled:text-gray-400'
- : 'w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white disabled:bg-gray-50 disabled:text-gray-400'
-
- const labelClass =
- size === 'sm'
- ? 'block text-[10px] font-medium text-gray-600 mb-1'
- : 'block text-xs font-medium text-gray-600 mb-1'
-
- return (
-
-
-
-
-
- {activeProviders.length === 0 && (
-
- No AI providers configured. Add one in AI Providers settings.
-
- This provider has no audio-capable Chat Completions
- models. Use OpenAI's gpt-4o-audio-preview /
- gpt-4o-mini-audio-preview, or a Google Gemini 1.5+ model.
+ {gatewayDirectModel ? (
+ <>
+
+
+ {gatewayDirectModel}
+
+
+ Model is fixed on the integration — Bifrost gateway routing applies.
+ This provider has no audio-capable Chat Completions
+ models. Use OpenAI's gpt-4o-audio-preview /
+ gpt-4o-mini-audio-preview, or a Google Gemini 1.5+ model.
+
+ Use native OpenAI-compatible for custom Bifrost models (e.g. Gemma) that do not work through the /litellm shim.
+
+
+ )}
+
{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.'}
+ : (llmGatewayInterface === 'native_openai' || llmGatewaySettings?.effective_gateway_interface === 'native_openai')
+ ? 'Bifrost host root only (e.g. http://localhost:8080). /v1 is added automatically. Do not include /v1/chat/completions.'
+ : 'Bifrost URL with /litellm path. Leave blank to inherit the platform URL.'}
@@ -1152,6 +1260,7 @@ export default function Integrations() {
{showGatewayModelField && (
+ <>
+
+
+
+
+ Override how this credential reaches Bifrost. Use native for custom models that fail through /litellm.
+
{credentialRoutingMode === 'direct'
? 'Direct routing always requires a provider 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'}
+ : 'Gateway routing does not require a provider API key — Bifrost handles the model call. Add one only if you want to pass a provider secret through the gateway.'}