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
7 changes: 2 additions & 5 deletions backend/app/api/routes/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@
from app.api.deps import AuthContextDep, SessionDep
from app.api.permissions import Permission, require_permission
from app.core.exception_handlers import HTTPException
from app.core.providers import (
CredentialPayload,
mask_credential_fields,
validate_provider,
)
from app.core.providers import mask_credential_fields, validate_provider
from app.models.provider_credentials import CredentialPayload
from app.crud.credentials import (
get_creds_by_org,
get_provider_credential,
Expand Down
40 changes: 3 additions & 37 deletions backend/app/core/batch/google_gcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,14 @@
from google.cloud import storage as gcs

from app.core.cloud.storage import CloudStorageError, build_gcp_sa_credentials
from app.core.providers import (
GoogleGcpCredentials,
Provider,
parse_provider_credentials,
)
from app.core.providers import parse_provider_credentials
from app.models.provider_credentials import GoogleGcpCredentials, Provider

from .base import BATCH_KEY, BatchProvider
from .gemini import BatchJobState

logger = logging.getLogger(__name__)

# Terminal Vertex job states (superset of AI-Studio: Vertex adds PAUSED).
_TERMINAL_STATES = {
BatchJobState.SUCCEEDED.value,
BatchJobState.FAILED.value,
BatchJobState.CANCELLED.value,
BatchJobState.EXPIRED.value,
"JOB_STATE_PAUSED",
}
_FAILED_STATES = {
BatchJobState.FAILED.value,
BatchJobState.CANCELLED.value,
Expand Down Expand Up @@ -88,7 +77,7 @@ def from_credentials(
parse_provider_credentials(Provider.GOOGLE_GCP, credentials),
)

creds = build_gcp_sa_credentials(cast(dict[str, Any], creds_model.sa_key))
creds = build_gcp_sa_credentials(creds_model.sa_key)
client = genai.Client(
vertexai=True,
project=creds_model.project_id,
Expand Down Expand Up @@ -116,11 +105,6 @@ def create_batch(
src_uri = self.upload_file(jsonl_content, purpose="batch")
dest_uri = f"gs://{self._bucket}/{self._output_prefix}/{uuid4().hex}/"

logger.info(
f"[create_batch] Creating Vertex batch | items={len(jsonl_data)} | "
f"model={model} | src={src_uri} | dest={dest_uri}"
)

try:
batch_job = self._client.batches.create(
model=model,
Expand All @@ -137,18 +121,13 @@ def create_batch(
"provider_status": initial_state,
"total_items": len(jsonl_data),
}
logger.info(
f"[create_batch] Created Vertex batch | batch_id={batch_job.name} | "
f"status={initial_state} | items={len(jsonl_data)}"
)
return result
except Exception as e:
logger.error(f"[create_batch] Failed to create Vertex batch | {e}")
raise

def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""Poll Vertex for batch job status."""
logger.info(f"[get_batch_status] Polling Vertex batch | batch_id={batch_id}")
try:
batch_job = self._client.batches.get(name=batch_id)
state = batch_job.state.name if batch_job.state else "UNKNOWN"
Expand All @@ -165,10 +144,6 @@ def get_batch_status(self, batch_id: str) -> dict[str, Any]:
if state in _FAILED_STATES:
message = batch_job.error.message if batch_job.error else state
result["error_message"] = message
logger.info(
f"[get_batch_status] Vertex batch status | batch_id={batch_id} | "
f"status={state}"
)
return result
except Exception as e:
logger.error(
Expand All @@ -182,9 +157,6 @@ def download_batch_results(self, output_file_id: str) -> list[dict[str, Any]]:

Vertex echoes the input ``key`` per line; line order is only the fallback.
"""
logger.info(
f"[download_batch_results] Reading Vertex results | src={output_file_id}"
)
output_uri = output_file_id
if not output_uri.startswith("gs://"):
batch_job = self._client.batches.get(name=output_file_id)
Expand Down Expand Up @@ -219,10 +191,6 @@ def download_batch_results(self, output_file_id: str) -> list[dict[str, Any]]:
}
)
index += 1
logger.info(
f"[download_batch_results] Read Vertex results | src={output_uri} | "
f"results={len(results)}"
)
return results
except Exception as e:
logger.error(
Expand All @@ -234,7 +202,6 @@ def download_batch_results(self, output_file_id: str) -> list[dict[str, Any]]:
def upload_file(self, content: str, purpose: str = "batch") -> str:
"""Upload a JSONL string to GCS and return its ``gs://`` URI."""
key = f"{self._input_prefix}/{int(time.time())}-{uuid4().hex}.jsonl"
logger.info(f"[upload_file] Uploading batch input to GCS | key={key}")
try:
blob = self._storage.bucket(self._bucket).blob(key)
blob.upload_from_string(content, content_type="application/jsonl")
Expand All @@ -245,7 +212,6 @@ def upload_file(self, content: str, purpose: str = "batch") -> str:

def download_file(self, file_id: str) -> str:
"""Download a ``gs://`` object's content as text."""
logger.info(f"[download_file] Downloading from GCS | uri={file_id}")
try:
bucket_name, key = _parse_gs_uri(file_id)
blob = self._storage.bucket(bucket_name).blob(key)
Expand Down
155 changes: 18 additions & 137 deletions backend/app/core/providers.py
Original file line number Diff line number Diff line change
@@ -1,132 +1,22 @@
import logging
from typing import Annotated
from enum import Enum
from dataclasses import dataclass, field
from pydantic import ValidationError

from app.models.provider_credentials import (
AnthropicCredentials,
CredentialPayload,
ElevenLabsCredentials,
GoogleAIStudioCredentials,
GoogleCredentials,
GoogleGcpCredentials,
LangfuseCredentials,
OpenAICredentials,
Provider,
ProviderConfig,
ProviderCredentialsBase,
ProxyCredentials,
SarvamAICredentials,
WebhookSecretCredentials,
)

from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError

logger = logging.getLogger(__name__)


class Provider(str, Enum):
"""Enumeration of supported credential providers."""

OPENAI = "openai"
LANGFUSE = "langfuse"
GOOGLE_AISTUDIO = "google-aistudio"
GOOGLE_GCP = "google-gcp"
SARVAMAI = "sarvamai"
ELEVENLABS = "elevenlabs"
ANTHROPIC = "anthropic"
GOOGLE = "google"
WEBHOOK_SECRET = "webhook_secret"
PROXY = "proxy"


CredentialPayload = dict[str, JsonValue]


class ProviderCredentialsBase(BaseModel):
"""Base for provider credential payloads.

Extra keys are allowed and preserved. Callers already store keys beyond the
declared auth fields, and the payload is written back verbatim, so
forbidding them would reject requests that work today. Tightening this to
``extra="forbid"`` is a deliberate breaking change and needs its own
migration for existing rows.
"""

model_config = ConfigDict(extra="allow")


class OpenAICredentials(ProviderCredentialsBase):
api_key: str = Field(description="OpenAI API key")


class LangfuseCredentials(ProviderCredentialsBase):
secret_key: str = Field(description="Langfuse secret key")
public_key: str = Field(description="Langfuse public key")
host: str = Field(description="Langfuse host URL")


class GoogleAIStudioCredentials(ProviderCredentialsBase):
api_key: str = Field(description="Google AI Studio API key")


class SarvamAICredentials(ProviderCredentialsBase):
api_key: str = Field(description="SarvamAI API key")


class ElevenLabsCredentials(ProviderCredentialsBase):
api_key: str = Field(description="ElevenLabs API key")


class AnthropicCredentials(ProviderCredentialsBase):
api_key: str = Field(description="Anthropic API key")


class GoogleCredentials(ProviderCredentialsBase):
api_key: str = Field(description="Google API key")


class GoogleGcpCredentials(ProviderCredentialsBase):
api_key: str = Field(description="Google GCP API key")
project_id: str = Field(description="GCP project ID")
location: str = Field(description="GCP region/location")
sa_key: JsonValue = Field(description="Service account key JSON")
gcs_bucket: str = Field(description="GCS bucket name")


class WebhookSecretCredentials(ProviderCredentialsBase):
webhook_secret: str = Field(
description="Shared secret used to HMAC-sign outgoing webhooks"
)


class ProxyCredentials(ProviderCredentialsBase):
api_key: str = Field(description="Proxy API key")


ProviderCredentials = Annotated[
OpenAICredentials
| LangfuseCredentials
| GoogleAIStudioCredentials
| SarvamAICredentials
| ElevenLabsCredentials
| AnthropicCredentials
| GoogleCredentials
| GoogleGcpCredentials
| WebhookSecretCredentials
| ProxyCredentials,
Field(
description=(
"Credential payload. The accepted shape depends on the provider it "
"is keyed by — see the per-provider schemas."
)
),
]


@dataclass
class ProviderConfig:
"""Configuration for a provider: its credential schema and which of those
fields are secret."""

model: type[ProviderCredentialsBase]
sensitive_fields: list[str] = field(default_factory=list)

@property
def required_fields(self) -> list[str]:
"""Credential keys the provider cannot be used without, derived from
the schema so the two can never drift."""
return [
name
for name, model_field in self.model.model_fields.items()
if model_field.is_required()
]


# Provider configurations
PROVIDER_CONFIGS: dict[Provider, ProviderConfig] = {
Provider.OPENAI: ProviderConfig(
model=OpenAICredentials, sensitive_fields=["api_key"]
Expand Down Expand Up @@ -180,9 +70,6 @@ def validate_provider(provider: str) -> Provider:
return Provider(provider.lower())
except (AttributeError, ValueError): # non-string keys reach here too
supported = ", ".join(p.value for p in Provider)
logger.warning(
f"[validate_provider] Unsupported provider | provider: {provider}, supported_providers: {supported}"
)
raise ValueError(
f"Unsupported provider: {provider}. Supported providers are: {supported}"
)
Expand Down Expand Up @@ -210,9 +97,6 @@ def parse_provider_credentials(
if missing_fields := [
name for name in config.required_fields if name not in credentials
]:
logger.warning(
f"[parse_provider_credentials] Missing required fields | provider: {provider}, missing_fields: {', '.join(missing_fields)}"
)
raise ValueError(
f"Missing required fields for {provider}: {', '.join(missing_fields)}"
)
Expand All @@ -224,9 +108,6 @@ def parse_provider_credentials(
f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}"
for err in e.errors()
)
logger.warning(
f"[parse_provider_credentials] Invalid credential fields | provider: {provider}, errors: {invalid_fields}"
)
raise ValueError(f"Invalid credentials for {provider}: {invalid_fields}")


Expand Down
18 changes: 8 additions & 10 deletions backend/app/models/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@
from pydantic import field_validator, model_validator
from sqlmodel import Field, Relationship, SQLModel

from app.core.providers import (
from app.core import providers as core_providers
from app.models.provider_credentials import (
CredentialPayload,
Provider,
ProviderCredentials,
mask_credential_fields,
parse_provider_credentials,
validate_provider,
)
from app.core.util import now
from app.models.organization import Organization
Expand Down Expand Up @@ -62,9 +60,9 @@ def _parse_credential(cls, value: object) -> object:
return value

return {
validate_provider(provider).value: parse_provider_credentials(
provider, payload
)
core_providers.validate_provider(
provider
).value: core_providers.parse_provider_credentials(provider, payload)
for provider, payload in value.items()
}

Expand Down Expand Up @@ -106,7 +104,7 @@ def _parse_credential(cls, data: object) -> object:
if not isinstance(provider, str) or credential is None:
return data

provider_key = validate_provider(provider).value
provider_key = core_providers.validate_provider(provider).value
nested = credential.get(provider_key) if isinstance(credential, dict) else None
if isinstance(nested, dict):
credential = nested
Expand All @@ -115,7 +113,7 @@ def _parse_credential(cls, data: object) -> object:
# credential, so it is rejected here rather than deferred to the
# crud-level merge check.
if isinstance(credential, dict) and not credential:
parse_provider_credentials(provider_key, credential)
core_providers.parse_provider_credentials(provider_key, credential)

return {
**data,
Expand Down Expand Up @@ -188,7 +186,7 @@ def to_public(self, mask: bool = True) -> "CredsPublic":

decrypted = decrypt_credentials(self.credential) if self.credential else None
if mask and decrypted:
decrypted = mask_credential_fields(self.provider, decrypted)
decrypted = core_providers.mask_credential_fields(self.provider, decrypted)

return CredsPublic(
id=self.id,
Expand Down
13 changes: 8 additions & 5 deletions backend/app/models/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
from sqlmodel import SQLModel, Field
from pydantic import EmailStr, model_validator, field_validator

from app.core.providers import (
from app.core import providers as core_providers
from app.models.provider_credentials import (
Provider,
ProviderCredentials,
ProviderCredentialsBase,
parse_provider_credentials,
validate_provider,
)


Expand Down Expand Up @@ -109,9 +108,13 @@ def _parse_credential_list(cls, value: object) -> object:
)

(provider, payload) = next(iter(item.items()))
canonical_provider = validate_provider(provider).value
canonical_provider = core_providers.validate_provider(provider).value
parsed.append(
{canonical_provider: parse_provider_credentials(provider, payload)}
{
canonical_provider: core_providers.parse_provider_credentials(
provider, payload
)
}
)

return parsed
Expand Down
Loading
Loading