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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 1 addition & 35 deletions app/api/v1/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
213 changes: 179 additions & 34 deletions app/api/v1/routes/aiproviders.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,48 +17,129 @@

from app.dependencies import get_db, get_organization_id
from app.models.database import AIProvider
from app.models.enums import CredentialRoutingMode
from app.models.schemas import (
AIProviderCreate, AIProviderUpdate, AIProviderResponse
)
from app.config import settings
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,
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 _scrub_for_response(db: Session, instance: AIProvider) -> AIProviderResponse:
"""Detach the row from the session before clearing ``api_key``.
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()
try:
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
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc


The same SQLAlchemy session is reused across requests in tests; if we
leave the instance attached and assign ``api_key = None``, SQLAlchemy
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.
"""
def _scrub_for_response(
db: Session,
instance: AIProvider,
organization_id: UUID,
) -> AIProviderResponse:
"""Detach the row from the session before clearing ``api_key``."""
gateway_managed = is_gateway_managed_stored_key(instance.api_key)
effective_routing = get_credential_effective_routing_label(
organization_id,
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})
return response.model_copy(
update={
"gateway_managed": gateway_managed,
Comment on lines +62 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 N+1 org-settings queries in list endpoints

_scrub_for_response calls get_credential_effective_routing_labelresolve_effective_routing_get_org_raw_settings, which issues a DB query for each provider in the list. For an org with many AI providers the list endpoint issues N redundant identical queries for the same org row. The same pattern applies to list_integrations. Fetching org settings once before the loop would eliminate the redundant queries.

"effective_routing": effective_routing,
"effective_gateway_interface": effective_gateway_interface,
"has_gateway_auth_secret": has_gateway_auth_secret,
}
)


def _encrypt_provider_api_key(api_key: Optional[str]) -> str:
def _validate_routing_and_api_key(
*,
routing_mode: CredentialRoutingMode,
api_key: Optional[str],
gateway_model: Optional[str],
has_existing_key: bool = False,
) -> None:
mode = routing_mode.value if hasattr(routing_mode, "value") else str(routing_mode)
trimmed_key = (api_key or "").strip()

if mode == CredentialRoutingMode.DIRECT.value and 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 direct.",
)

if mode == CredentialRoutingMode.GATEWAY.value:
return

if mode == CredentialRoutingMode.INHERIT.value:
if not trimmed_key and not has_existing_key:
if settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"api_key is required when platform passthrough_provider_keys "
"is enabled."
),
)
return


def _encrypt_provider_api_key(
api_key: Optional[str],
*,
routing_mode: CredentialRoutingMode,
) -> str:
trimmed = (api_key or "").strip()
if trimmed:
return encrypt_api_key(trimmed)
if gateway_managed_credentials_enabled():

mode = routing_mode.value if hasattr(routing_mode, "value") else str(routing_mode)
if mode in (
CredentialRoutingMode.GATEWAY.value,
CredentialRoutingMode.INHERIT.value,
):
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.",
)


Expand All @@ -68,15 +149,15 @@ async def create_aiprovider(
organization_id: UUID = Depends(get_organization_id),
db: Session = Depends(get_db)
):
"""Create a new AI Provider credential row.

Multiple credentials per (org, provider) are now allowed. The first
row created for a given provider is auto-promoted to default;
subsequent rows are stored as additional keys and can be promoted via
``set-default``.
"""
"""Create a new AI Provider credential row."""
provider_value = aiprovider.provider.value if hasattr(aiprovider.provider, 'value') else aiprovider.provider

_validate_routing_and_api_key(
routing_mode=aiprovider.routing_mode,
api_key=aiprovider.api_key,
gateway_model=aiprovider.gateway_model,
)

existing_default = db.query(AIProvider).filter(
AIProvider.organization_id == organization_id,
func.lower(AIProvider.provider) == provider_value.lower(),
Expand All @@ -86,13 +167,32 @@ async def create_aiprovider(
requested_default = bool(aiprovider.is_default)
will_be_default = requested_default or existing_default is None

encrypted_api_key = _encrypt_provider_api_key(aiprovider.api_key)
encrypted_api_key = _encrypt_provider_api_key(
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,
api_key=encrypted_api_key,
name=aiprovider.name,
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,
),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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()
Expand All @@ -110,7 +210,7 @@ async def create_aiprovider(
db.commit()
db.refresh(db_aiprovider)

return _scrub_for_response(db, db_aiprovider)
return _scrub_for_response(db, db_aiprovider, organization_id)


@router.get("", response_model=List[AIProviderResponse], operation_id="listAIProviders")
Expand All @@ -126,7 +226,10 @@ async def list_aiproviders(
.all()
)

return [_scrub_for_response(db, provider) for provider in aiproviders]
return [
_scrub_for_response(db, provider, organization_id)
for provider in aiproviders
]


@router.get("/{aiprovider_id}", response_model=AIProviderResponse)
Expand All @@ -146,7 +249,7 @@ async def get_aiprovider(
status_code=404, detail=f"AI Provider {aiprovider_id} not found"
)

return _scrub_for_response(db, aiprovider)
return _scrub_for_response(db, aiprovider, organization_id)


@router.put("/{aiprovider_id}", response_model=AIProviderResponse, operation_id="updateAIProvider")
Expand All @@ -168,17 +271,59 @@ async def update_aiprovider(
)

update_data = aiprovider_update.model_dump(exclude_unset=True)
next_routing_mode = update_data.get(
"routing_mode",
CredentialRoutingMode(db_aiprovider.routing_mode),
)
next_gateway_model = update_data.get("gateway_model", db_aiprovider.gateway_model)
next_api_key = update_data.get("api_key")

if "routing_mode" in update_data or "api_key" in update_data or "gateway_model" in update_data:
_validate_routing_and_api_key(
routing_mode=next_routing_mode,
api_key=next_api_key,
gateway_model=next_gateway_model,
has_existing_key=(
bool(db_aiprovider.api_key)
and not is_gateway_managed_stored_key(db_aiprovider.api_key)
),
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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
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)

return _scrub_for_response(db, db_aiprovider)
return _scrub_for_response(db, db_aiprovider, organization_id)


@router.post(
Expand Down Expand Up @@ -219,7 +364,7 @@ async def set_default_aiprovider(
db.commit()
db.refresh(db_aiprovider)

return _scrub_for_response(db, db_aiprovider)
return _scrub_for_response(db, db_aiprovider, organization_id)


@router.delete("/{aiprovider_id}", status_code=status.HTTP_204_NO_CONTENT, operation_id="deleteAIProvider")
Expand Down
Loading
Loading