Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
43 changes: 43 additions & 0 deletions backend/app/api/docs/credentials/create.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
Persist new credentials for the current organization and project.

Credentials are encrypted and stored securely for provider integrations (OpenAI, Langfuse, etc.). Only one credential per provider is allowed per organization-project combination.

### Supported Providers:
- **LLM:** openai, sarvamai, google(gemini)
- **Observability:** langfuse
- **Audio:** elevenlabs

### Examples:

#### Single Provider
```json
{
"credential": {
"openai": {
"api_key": "sk-proj-..."
}
}
}
```

#### Multiple Providers
```json
{
"credential": {
"openai": {
"api_key": "sk-proj-..."
},
"google": {
"api_key": "AIzaSy..."
},
"sarvamai": {
"api_key": "sarvam-..."
},
"elevenlabs": {
"api_key": "sk_..."
},
"langfuse": {
"public_key": "pk-lf-....",
"secret_key": "sk-lf-...",
"host": "https://cloud.langfuse.com"
}
}
}
```
20 changes: 18 additions & 2 deletions backend/app/api/docs/onboarding/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
- We’ve also included a list of the providers currently supported by kaapi.

### Supported Providers
- **LLM:** openai
- **LLM:** openai, google, sarvamai
- **Observability:** langfuse
- **Audio:** elevenlabs

### Example: For sending multiple credentials -
```
Expand All @@ -41,9 +42,24 @@
"api_key": "sk-proj-..."
}
},
{
"google": {
"api_key": "AIzaSy..."
}
},
{
"sarvamai": {
"api_key": "sarvam-..."
}
},
{
"elevenlabs": {
"api_key": "sk_..."
}
},
{
"langfuse": {
"public_key": "pk-lf-...",
"public_key": "pk-lf-....",
"secret_key": "sk-lf-...",
"host": "https://cloud.langfuse.com"
}
Expand Down
12 changes: 7 additions & 5 deletions backend/app/core/providers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from typing import Dict, List, Optional
from typing import Dict, List
from enum import Enum
from dataclasses import dataclass

Expand All @@ -10,13 +10,15 @@ class Provider(str, Enum):
"""Enumeration of supported credential providers."""

OPENAI = "openai"
AWS = "aws"
LANGFUSE = "langfuse"
GOOGLE = "google"
SARVAMAI = "sarvamai"
ELEVENLABS = "elevenlabs"


# AWS = "aws"


@dataclass
class ProviderConfig:
"""Configuration for a provider including its required credential fields."""
Expand All @@ -27,15 +29,15 @@ class ProviderConfig:
# Provider configurations
PROVIDER_CONFIGS: Dict[Provider, ProviderConfig] = {
Provider.OPENAI: ProviderConfig(required_fields=["api_key"]),
Provider.AWS: ProviderConfig(
required_fields=["access_key_id", "secret_access_key", "region"]
),
Provider.LANGFUSE: ProviderConfig(
required_fields=["secret_key", "public_key", "host"]
),
Provider.GOOGLE: ProviderConfig(required_fields=["api_key"]),
Provider.SARVAMAI: ProviderConfig(required_fields=["api_key"]),
Provider.ELEVENLABS: ProviderConfig(required_fields=["api_key"]),
# Provider.AWS: ProviderConfig(
# required_fields=["access_key_id", "secret_access_key", "region"]
# ),
}


Expand Down
37 changes: 31 additions & 6 deletions backend/app/crud/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from app.core.util import now
from app.models import Credential, CredsCreate, CredsUpdate


logger = logging.getLogger(__name__)


Expand All @@ -28,8 +29,14 @@ def set_creds_for_org(

for provider, credentials in creds_add.credential.items():
# Validate provider and credentials
validate_provider(provider)
validate_provider_credentials(provider, credentials)
try:
validate_provider(provider)
validate_provider_credentials(provider, credentials)
except ValueError as e:
logger.error(
f"[set_creds_for_org] Validation error | project_id: {project_id}, provider: {provider}, error: {str(e)}"
)
raise HTTPException(status_code=400, detail=str(e))

# Encrypt entire credentials object
encrypted_credentials = encrypt_credentials(credentials)
Expand Down Expand Up @@ -144,7 +151,13 @@ def get_provider_credential(
Raises:
HTTPException: If credentials are not found
"""
validate_provider(provider)
try:
validate_provider(provider)
except ValueError as e:
logger.error(
f"[get_provider_credential] Validation error | organization_id: {org_id}, project_id: {project_id}, provider: {provider}, error: {str(e)}"
)
raise HTTPException(status_code=400, detail=str(e))

statement = select(Credential).where(
Credential.organization_id == org_id,
Expand Down Expand Up @@ -176,8 +189,14 @@ def update_creds_for_org(
if not creds_in.provider or not creds_in.credential:
raise ValueError("Provider and credential must be provided")

validate_provider(creds_in.provider)
validate_provider_credentials(creds_in.provider, creds_in.credential)
try:
validate_provider(creds_in.provider)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Inside validate_provider its already raising the exception hence wrapping ahain in another try..catch is redundnat.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

the validate provider try catch catches the value error and the try catch we aree wrappin the function with converts it to httpexception

validate_provider_credentials(creds_in.provider, creds_in.credential)
except ValueError as e:
logger.error(
f"[update_creds_for_org] Validation error | organization_id: {org_id}, project_id: {project_id}, provider: {creds_in.provider}, error: {str(e)}"
)
raise HTTPException(status_code=400, detail=str(e))

# Encrypt the entire credentials object
encrypted_credentials = encrypt_credentials(creds_in.credential)
Expand Down Expand Up @@ -216,7 +235,13 @@ def remove_provider_credential(
Raises:
HTTPException: If credentials not found or deletion fails
"""
validate_provider(provider)
try:
validate_provider(provider)
except ValueError as e:
logger.error(
f"[remove_provider_credential] Validation error | organization_id: {org_id}, project_id: {project_id}, provider: {provider}, error: {str(e)}"
)
raise HTTPException(status_code=400, detail=str(e))

# Verify credentials exist before attempting delete
creds = get_provider_credential(
Expand Down
57 changes: 25 additions & 32 deletions backend/app/models/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,40 +89,33 @@ def _validate_credential_list(cls, v: list[dict[str, dict[str, str]]] | None):
return v

if not isinstance(v, list):
raise TypeError(
"credential must be a list of single-key dicts (e.g., {'openai': {...}})."
raise ValueError(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

isn't it a TypeError?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

changed this to typeerror

"Credential must be a list of single-key dicts (e.g., {'openai': {...}})."
)

errors: list[str] = []

for idx, item in enumerate(v):
try:
if not isinstance(item, dict):
raise TypeError(
"must be a dict with a single provider key like {'openai': {...}}."
)
if len(item) != 1:
raise ValueError(
"must have exactly one provider key like {'openai': {...}}."
)

(provider_key,) = item.keys()
values = item[provider_key]

validate_provider(provider_key)

if not isinstance(values, dict):
raise TypeError(
f"value for provider '{provider_key}' must be an object/dict."
)

validate_provider_credentials(provider_key, values)

except (TypeError, ValueError) as e:
errors.append(f"[{idx}] {e}")

if errors:
raise ValueError("credential validation failed:\n" + "\n".join(errors))
for item in v:
if not isinstance(item, dict):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this post filter required inside OnboardingRequest?Imo a better approach would be to decouple this validation logic from onboarding as it might be hard to maintain.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

while it may be hard to maintain, this helps us catch any issue early on with the request body when it comes to credentials being given, otherwise we will only get to know about it after crud function is hit and we put the verification in that

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

sure we can take it up later

raise ValueError(
"Credential must be a dict with a single provider key like {'openai': {...}}."
)
if len(item) != 1:
raise ValueError(
"Credential must have exactly one provider key like {'openai': {...}}."
)

(provider_key,) = item.keys()
values = item[provider_key]

# validate_provider will raise ValueError with clear message if invalid
validate_provider(provider_key)

if not isinstance(values, dict):
raise ValueError(
f"Value for provider '{provider_key}' must be an object/dict."
)

# validate_provider_credentials will raise ValueError with clear message
validate_provider_credentials(provider_key, values)

return v

Expand Down
24 changes: 8 additions & 16 deletions backend/app/tests/api/routes/test_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,7 @@ def test_onboard_project_invalid_provider(
assert response.status_code == 422
error_response = response.json()
assert error_response["errors"]
assert any(
"credential validation failed" in e["message"] for e in error_response["errors"]
)
assert any("Unsupported provider" in e["message"] for e in error_response["errors"])


def test_onboard_project_non_dict_values_in_credential(
Expand Down Expand Up @@ -230,9 +228,6 @@ def test_onboard_project_non_dict_values_in_credential(
assert response.status_code == 422
error_response = response.json()
assert error_response["errors"]
assert any(
"credential validation failed" in e["message"] for e in error_response["errors"]
)
assert any(
"must be an object/dict" in e["message"] for e in error_response["errors"]
)
Expand Down Expand Up @@ -266,9 +261,9 @@ def test_onboard_project_missing_required_fields_for_openai(
error_response = response.json()
assert error_response["errors"]
assert any(
"credential validation failed" in e["message"] for e in error_response["errors"]
"Missing required fields for openai" in e["message"]
for e in error_response["errors"]
)
assert any("openai" in e["message"] for e in error_response["errors"])


def test_onboard_project_missing_required_fields_for_langfuse(
Expand Down Expand Up @@ -301,15 +296,15 @@ def test_onboard_project_missing_required_fields_for_langfuse(
error_response = response.json()
assert error_response["errors"]
assert any(
"credential validation failed" in e["message"] for e in error_response["errors"]
"Missing required fields for langfuse" in e["message"]
for e in error_response["errors"]
)
assert any("langfuse" in e["message"] for e in error_response["errors"])


def test_onboard_project_aggregates_multiple_credential_errors(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
"""Test onboarding aggregates multiple credential validation errors with index markers."""
"""Test onboarding reports credential validation errors (fails on first error)."""
org_name = "TestOrgOnboard"
project_name = "TestProjectOnboard"
email = random_email()
Expand All @@ -336,8 +331,5 @@ def test_onboard_project_aggregates_multiple_credential_errors(
assert response.status_code == 422
error_response = response.json()
assert error_response["errors"]
assert any(
"credential validation failed" in e["message"] for e in error_response["errors"]
)
assert any("[0]" in e["message"] for e in error_response["errors"])
assert any("[1]" in e["message"] for e in error_response["errors"])
# Validation fails on the first error (unsupported provider)
assert any("Unsupported provider" in e["message"] for e in error_response["errors"])
8 changes: 0 additions & 8 deletions backend/app/tests/core/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,3 @@ def test_validate_provider_credentials_missing_fields():
validate_provider_credentials("openai", {})
assert "Missing required fields" in str(exc_info.value)
assert "api_key" in str(exc_info.value)

# Test AWS missing region
with pytest.raises(ValueError) as exc_info:
validate_provider_credentials(
"aws", {"access_key_id": "test-id", "secret_access_key": "test-secret"}
)
assert "Missing required fields" in str(exc_info.value)
assert "region" in str(exc_info.value)
14 changes: 12 additions & 2 deletions backend/app/tests/crud/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ def test_remove_creds_for_org(db: Session) -> None:

def test_invalid_provider(db: Session) -> None:
"""Test handling of invalid provider names."""
from app.core.exception_handlers import HTTPException

project = create_test_project(db)

credentials_data = {"invalid_provider": {"api_key": "test-key"}}
Expand All @@ -214,14 +216,17 @@ def test_invalid_provider(db: Session) -> None:
credential=credentials_data,
)

with pytest.raises(ValueError, match="Unsupported provider"):
with pytest.raises(HTTPException) as exc_info:
set_creds_for_org(
session=db,
creds_add=credentials_create,
organization_id=project.organization_id,
project_id=project.id,
)

assert exc_info.value.status_code == 400
assert "Unsupported provider" in exc_info.value.detail


def test_duplicate_provider_credentials(db: Session) -> None:
"""Test handling of duplicate provider credentials."""
Expand Down Expand Up @@ -253,6 +258,8 @@ def test_duplicate_provider_credentials(db: Session) -> None:

def test_langfuse_credential_validation(db: Session) -> None:
"""Test validation of Langfuse credentials structure."""
from app.core.exception_handlers import HTTPException

project = create_test_project(db)

# Test with missing required fields
Expand All @@ -268,14 +275,17 @@ def test_langfuse_credential_validation(db: Session) -> None:
credential=invalid_credentials,
)

with pytest.raises(ValueError):
with pytest.raises(HTTPException) as exc_info:
set_creds_for_org(
session=db,
creds_add=credentials_create,
organization_id=project.organization_id,
project_id=project.id,
)

assert exc_info.value.status_code == 400
assert "Missing required fields for langfuse" in exc_info.value.detail

valid_credentials = {
"langfuse": {
"public_key": "test-public-key",
Expand Down
Loading
Loading