Skip to content
Merged
2 changes: 1 addition & 1 deletion backend/app/crud/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def create_or_raise(
version = ConfigVersion(
config_id=config.id,
version=1,
config_blob=config_create.config_blob,
config_blob=config_create.config_blob.model_dump(),
commit_message=config_create.commit_message,
)

Expand Down
2 changes: 1 addition & 1 deletion backend/app/crud/config/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def create_or_raise(self, version_create: ConfigVersionCreate) -> ConfigVersion:
version = ConfigVersion(
config_id=self.config_id,
version=next_version,
config_blob=version_create.config_blob,
config_blob=version_create.config_blob.model_dump(),
commit_message=version_create.commit_message,
)

Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
from .job import Job, JobType, JobStatus, JobUpdate

from .llm import (
ConfigBlob,
CompletionConfig,
LLMCallRequest,
LLMCallResponse,
)
Expand Down
5 changes: 3 additions & 2 deletions backend/app/models/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
from datetime import datetime
from typing import TYPE_CHECKING, Any

from sqlmodel import Field, SQLModel, UniqueConstraint, Index, text
from sqlmodel import Field, SQLModel, Index, text
from pydantic import field_validator

from app.core.util import now
from app.models.llm.request import ConfigBlob
from .version import ConfigVersionPublic


Expand Down Expand Up @@ -56,7 +57,7 @@ class ConfigCreate(ConfigBase):
"""Create new configuration"""

# Initial version data
config_blob: dict[str, Any] = Field(description="Provider-specific parameters")
config_blob: ConfigBlob = Field(description="Provider-specific parameters")
commit_message: str | None = Field(
default=None,
max_length=512,
Expand Down
8 changes: 7 additions & 1 deletion backend/app/models/config/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from sqlmodel import Field, SQLModel, UniqueConstraint, Index, text

from app.core.util import now
from app.models.llm.request import ConfigBlob


class ConfigVersionBase(SQLModel):
Expand Down Expand Up @@ -60,7 +61,12 @@ class ConfigVersion(ConfigVersionBase, table=True):


class ConfigVersionCreate(ConfigVersionBase):
pass
# Store config_blob as JSON in the DB. Validation uses ConfigBlob only at creation
# time, since schema may evolve. When fetching, it is returned as a raw dict and
# re-validated against the latest schema before use.
config_blob: ConfigBlob = Field(
description="Provider-specific configuration parameters (temperature, max_tokens, etc.)",
)


class ConfigVersionPublic(ConfigVersionBase):
Expand Down
7 changes: 6 additions & 1 deletion backend/app/models/llm/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
from app.models.llm.request import LLMCallRequest, CompletionConfig, QueryParams
from app.models.llm.request import (
LLMCallRequest,
CompletionConfig,
QueryParams,
ConfigBlob,
)
from app.models.llm.response import LLMCallResponse, LLMResponse, LLMOutput, Usage
83 changes: 79 additions & 4 deletions backend/app/models/llm/request.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any, Literal

from uuid import UUID
from sqlmodel import Field, SQLModel
from pydantic import model_validator, HttpUrl

Expand Down Expand Up @@ -57,20 +58,94 @@ class CompletionConfig(SQLModel):
)


class LLMCallConfig(SQLModel):
"""Complete configuration for LLM call including all processing stages."""
class ConfigBlob(SQLModel):
"""Raw JSON blob of config."""

completion: CompletionConfig = Field(..., description="Completion configuration")
# Future additions:
# classifier: ClassifierConfig | None = None
# pre_filter: PreFilterConfig | None = None


class LLMCallConfig(SQLModel):
"""
Complete configuration for LLM call including all processing stages.
Either references a stored config (id + version) or provides an ad-hoc config blob.
Depending on which is provided, only one of the two options should be used.
"""

id: UUID | None = Field(
default=None,
description=(
"Identifier for an existing LLM call configuration. [require version if provided]"
),
)
version: int | None = Field(
default=None,
ge=1,
description=(
"Version of the stored config to use. [require if id is provided]"
),
)

blob: ConfigBlob | None = Field(
default=None,
description=(
"Raw JSON blob of the full configuration. Used for ad-hoc configurations without storing."
"Either this or (id + version) must be provided."
),
)

@model_validator(mode="after")
def validate_config_logic(self):
has_stored = self.id is not None or self.version is not None
has_blob = self.blob is not None

if has_stored and has_blob:
raise ValueError(
"Provide either 'id' with 'version' for stored config OR 'blob' for ad-hoc config, not both."
)

if has_stored:
if not self.id or not self.version:
raise ValueError(
"'id' and 'version' must both be provided together for stored config."
)
return self

if not has_blob:
raise ValueError(
"Must provide either a stored config (id + version) or an ad-hoc config (blob)."
)

return self

@property
def is_stored_config(self) -> bool:
"""Check if the config refers to a stored config or not."""
return self.id is not None and self.version is not None


class LLMCallRequest(SQLModel):
"""User-facing API request for LLM completion."""
"""
API request for an LLM completion.

The `config` field accepts either:
- **Stored config (id + version)** — recommended for all production use.
- **Inline config blob** — for testing or validating new configs.

Prefer stored configs in production; use blobs only for development/testing/validations.
"""

query: QueryParams = Field(..., description="Query-specific parameters")
config: LLMCallConfig = Field(..., description="Configuration for the LLM call")
config: LLMCallConfig = Field(
...,
description=(
"Complete LLM call configuration, provided either by reference (id + version) "
"or as config blob. Use the blob only for testing/validation; "
"in production, always use the id + version."
),
)
callback_url: HttpUrl | None = Field(
default=None, description="Webhook URL for async response delivery"
)
Expand Down
94 changes: 75 additions & 19 deletions backend/app/services/llm/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from sqlmodel import Session

from app.core.db import engine
from app.crud.config import ConfigVersionCrud
from app.crud.jobs import JobCrud
from app.models import JobStatus, JobType, JobUpdate, LLMCallRequest, LLMCallResponse
from app.models import JobStatus, JobType, JobUpdate, LLMCallRequest
from app.models.llm.request import ConfigBlob, LLMCallConfig
from app.utils import APIResponse, send_callback
from app.celery.utils import start_high_priority_job
from app.services.llm.providers.registry import get_llm_provider
Expand Down Expand Up @@ -76,6 +78,41 @@ def handle_job_error(
return callback_response.model_dump()


def resolve_config_blob(
config_crud: ConfigVersionCrud, config: LLMCallConfig
) -> tuple[ConfigBlob | None, str | None]:
"""Fetch and parse stored config version into ConfigBlob.

Returns:
(config_blob, error_message)
- config_blob: ConfigBlob if successful, else None
- error_message: human-safe error string if an error occurs, else None
"""
try:
config_version = config_crud.exists_or_raise(version_number=config.version)
except HTTPException as e:
return None, f"Failed to retrieve stored configuration: {e.detail}"
except Exception:
logger.error(
f"[resolve_config_blob] Unexpected error retrieving config version | "
f"config_id={config.id}, version={config.version}",
exc_info=True,
)
return None, "Unexpected error occurred while retrieving stored configuration"

try:
return ConfigBlob(**config_version.config_blob), None
except (TypeError, ValueError) as e:
return None, f"Stored configuration blob is invalid: {str(e)}"
except Exception:
logger.error(
f"[resolve_config_blob] Unexpected error parsing config blob | "
f"config_id={config.id}, version={config.version}",
exc_info=True,
)
return None, "Unexpected error occurred while parsing stored configuration"


def execute_job(
request_data: dict,
project_id: int,
Expand All @@ -93,53 +130,72 @@ def execute_job(
request = LLMCallRequest(**request_data)
job_id: UUID = UUID(job_id)

# one of (id, version) or blob is guaranteed to be present due to prior validation
config = request.config
provider = config.completion.provider
callback = None
callback_response = None
config_blob: ConfigBlob | None = None

logger.info(
f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, "
f"provider={provider}"
)

try:
# Update job status to PROCESSING
with Session(engine) as session:
# Update job status to PROCESSING
job_crud = JobCrud(session=session)
job_crud.update(
job_id=job_id, job_update=JobUpdate(status=JobStatus.PROCESSING)
)

# if stored config, fetch blob from DB
if config.is_stored_config:
config_crud = ConfigVersionCrud(
session=session, project_id=project_id, config_id=config.id
)

# blob is dynamic, need to resolve to ConfigBlob format
config_blob, error = resolve_config_blob(config_crud, config)

if error:
callback_response = APIResponse.failure_response(
error=error,
metadata=request.request_metadata,
)
return handle_job_error(
job_id, request.callback_url, callback_response
)

else:
config_blob = config.blob

try:
provider_instance = get_llm_provider(
session=session,
provider_type=provider,
provider_type=config_blob.completion.provider,
project_id=project_id,
organization_id=organization_id,
)
except ValueError as ve:
callback = APIResponse.failure_response(
callback_response = APIResponse.failure_response(
error=str(ve),
metadata=request.request_metadata,
)

if callback:
return handle_job_error(job_id, request.callback_url, callback)
return handle_job_error(job_id, request.callback_url, callback_response)

response, error = provider_instance.execute(
completion_config=config.completion,
completion_config=config_blob.completion,
query=request.query,
include_provider_raw_response=request.include_provider_raw_response,
)

if response:
callback = APIResponse.success_response(
callback_response = APIResponse.success_response(
data=response, metadata=request.request_metadata
)
if request.callback_url:
send_callback(
callback_url=request.callback_url,
data=callback.model_dump(),
data=callback_response.model_dump(),
)

with Session(engine) as session:
Expand All @@ -152,21 +208,21 @@ def execute_job(
f"[execute_job] Successfully completed LLM job | job_id={job_id}, "
f"provider_response_id={response.response.provider_response_id}, tokens={response.usage.total_tokens}"
)
return callback.model_dump()
return callback_response.model_dump()

callback = APIResponse.failure_response(
callback_response = APIResponse.failure_response(
error=error or "Unknown error occurred",
metadata=request.request_metadata,
)
return handle_job_error(job_id, request.callback_url, callback)
return handle_job_error(job_id, request.callback_url, callback_response)

except Exception as e:
callback = APIResponse.failure_response(
callback_response = APIResponse.failure_response(
error=f"Unexpected error occurred",
metadata=request.request_metadata,
)
logger.error(
f"[execute_job] {callback.error} {str(e)} | job_id={job_id}, task_id={task_id}",
f"[execute_job] Unknown error occurred: {str(e)} | job_id={job_id}, task_id={task_id}",
exc_info=True,
)
return handle_job_error(job_id, request.callback_url, callback)
return handle_job_error(job_id, request.callback_url, callback_response)
25 changes: 20 additions & 5 deletions backend/app/tests/api/routes/configs/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ def test_create_config_success(
"name": "test-llm-config",
"description": "A test LLM configuration",
"config_blob": {
"model": "gpt-4",
"temperature": 0.8,
"max_tokens": 2000,
"completion": {
"provider": "openai",
"params": {
"model": "gpt-4",
"temperature": 0.8,
"max_tokens": 2000,
},
}
},
"commit_message": "Initial configuration",
}
Expand Down Expand Up @@ -81,7 +86,12 @@ def test_create_config_duplicate_name_fails(
config_data = {
"name": "duplicate-config",
"description": "Should fail",
"config_blob": {"model": "gpt-4"},
"config_blob": {
"completion": {
"provider": "openai",
"params": {"model": "gpt-4"},
}
},
"commit_message": "Initial",
}

Expand Down Expand Up @@ -406,7 +416,12 @@ def test_create_config_requires_authentication(
config_data = {
"name": "test-config",
"description": "Test",
"config_blob": {"model": "gpt-4"},
"config_blob": {
"completion": {
"provider": "openai",
"params": {"model": "gpt-4"},
}
},
"commit_message": "Initial",
}

Expand Down
Loading