Skip to content
Open
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
10 changes: 10 additions & 0 deletions backend/app/api/docs/llm/get_llm_call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Retrieve the status and results of an LLM call job by job ID.

This endpoint allows you to poll for the status and results of an asynchronous LLM call job that was previously initiated via the POST `/llm/call` endpoint.


### Notes

- This endpoint returns both the job status AND the actual LLM response when complete
- LLM responses are also delivered asynchronously via the callback URL (if provided)
- Jobs can be queried at any time after creation
103 changes: 96 additions & 7 deletions backend/app/api/routes/llm.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import logging
from uuid import UUID

from fastapi import APIRouter, Depends

from app.api.deps import AuthContextDep, SessionDep
from app.api.permissions import Permission, require_permission
from app.models import LLMCallRequest, LLMCallResponse, Message
from app.core.exception_handlers import HTTPException
from app.crud.jobs import JobCrud
from app.crud.llm import get_llm_calls_by_job_id
from app.models import (
LLMCallRequest,
LLMCallResponse,
LLMJobImmediatePublic,
LLMJobPublic,
)
from app.models.llm.response import LLMResponse, Usage
from app.services.llm.jobs import start_job
from app.utils import APIResponse, validate_callback_url, load_description

Expand Down Expand Up @@ -34,7 +44,7 @@ def llm_callback_notification(body: APIResponse[LLMCallResponse]):
@router.post(
"/llm/call",
description=load_description("llm/llm_call.md"),
response_model=APIResponse[Message],
response_model=APIResponse[LLMJobImmediatePublic],
callbacks=llm_callback_router.routes,
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
Expand All @@ -43,22 +53,101 @@ def llm_call(
):
"""
Endpoint to initiate an LLM call as a background job.
Returns job information for polling.
"""
project_id = _current_user.project_.id
organization_id = _current_user.organization_.id

if request.callback_url:
validate_callback_url(str(request.callback_url))

start_job(
job_id = start_job(
db=session,
request=request,
project_id=project_id,
organization_id=organization_id,
)

return APIResponse.success_response(
data=Message(
message=f"Your response is being generated and will be delivered via callback."
),
# Fetch job details to return immediate response
job_crud = JobCrud(session=session)
job = job_crud.get(job_id=job_id)

if not job:
raise HTTPException(status_code=404, detail="Job not found")

if request.callback_url:
message = "Your response is being generated and will be delivered via callback."
else:
message = "Your response is being generated"

job_response = LLMJobImmediatePublic(
job_id=job.id,
status=job.status.value,
message=message,
job_inserted_at=job.created_at,
job_updated_at=job.updated_at,
)

return APIResponse.success_response(data=job_response)


@router.get(
"/llm/call/{job_id}",
description=load_description("llm/get_llm_call.md"),
response_model=APIResponse[LLMJobPublic],
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def get_llm_call_status(
_current_user: AuthContextDep,
session: SessionDep,
job_id: UUID,
) -> APIResponse[LLMJobPublic]:
"""
Poll for LLM call job status and results.
Returns job information with nested LLM response when complete.
"""
job_crud = JobCrud(session=session)
job = job_crud.get(job_id=job_id)

if not job:
raise HTTPException(status_code=404, detail="Job not found")
Comment on lines +109 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Authorize job_id before returning status.

This lookup is scoped only by raw UUID, and _current_user is otherwise unused here. Any caller with project access who learns a job ID can poll another tenant's job, and this endpoint will also return non-LLM jobs unless you enforce ownership and expected job type before responding.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/routes/llm.py` around lines 109 - 113, The endpoint currently
fetches Job via JobCrud.get(job_id=job_id) and returns its status without
verifying ownership or job type; update the logic to authorize the job before
responding by ensuring the retrieved job belongs to the current user/tenant and
is an LLM job: either extend JobCrud.get to accept a tenant/project/user filter
(e.g., pass project_id or owner_id from _current_user) or, after fetching, check
job.project_id == _current_user.project_id (or job.owner_id == _current_user.id)
and check job.type == "llm" (or JobType.LLM); if either check fails, raise
HTTPException(status_code=404, detail="Job not found") to avoid leaking job
existence or non-LLM jobs.


llm_call_response = None
if job.status.value == "SUCCESS":
llm_calls = get_llm_calls_by_job_id(
session=session, job_id=job_id, project_id=_current_user.project_.id
)

if llm_calls:
# Get the first LLM call from the list which will be the only call for the job id
# since we initially won't be using this endpoint for llm chains
llm_call = llm_calls[0]

llm_response = LLMResponse(
provider_response_id=llm_call.provider_response_id or "",
conversation_id=llm_call.conversation_id,
provider=llm_call.provider,
model=llm_call.model,
output=llm_call.content,
)

if not llm_call.usage:
raise HTTPException(
status_code=500,
detail="Completed LLM job is missing usage data",
)

llm_call_response = LLMCallResponse(
response=llm_response,
usage=Usage(**llm_call.usage),
provider_raw_response=None,
)

job_response = LLMJobPublic(
job_id=job.id,
status=job.status.value,
llm_response=llm_call_response,
error_message=job.error_message,
)

return APIResponse.success_response(data=job_response)
4 changes: 2 additions & 2 deletions backend/app/crud/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,13 @@ def get_llm_call_by_id(


def get_llm_calls_by_job_id(
session: Session,
job_id: UUID,
session: Session, job_id: UUID, project_id: int
) -> list[LlmCall]:
statement = (
select(LlmCall)
.where(
LlmCall.job_id == job_id,
LlmCall.project_id == project_id,
LlmCall.deleted_at.is_(None),
)
.order_by(LlmCall.created_at.desc())
Expand Down
3 changes: 3 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@
LLMChainRequest,
LLMChainResponse,
LlmChain,
LLMJobBasePublic,
LLMJobImmediatePublic,
LLMJobPublic,
)

from .message import Message
Expand Down
3 changes: 3 additions & 0 deletions backend/app/models/llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,7 @@
AudioOutput,
LLMChainResponse,
IntermediateChainResponse,
LLMJobBasePublic,
LLMJobImmediatePublic,
LLMJobPublic,
)
26 changes: 26 additions & 0 deletions backend/app/models/llm/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

This module contains structured response models for LLM API calls.
"""
from datetime import datetime
from uuid import UUID

from sqlmodel import SQLModel, Field
from typing import Literal, Annotated
from app.models.llm.request import AudioContent, TextContent
Expand Down Expand Up @@ -100,3 +103,26 @@ class IntermediateChainResponse(SQLModel):
default=None,
description="Unmodified raw response from the LLM provider from the current block",
)


# Job response models
class LLMJobBasePublic(SQLModel):
"""Base response model for LLM job information."""

job_id: UUID
status: str # JobStatus from job.py


class LLMJobImmediatePublic(LLMJobBasePublic):
"""Immediate response after creating an LLM job."""

message: str
job_inserted_at: datetime
job_updated_at: datetime


class LLMJobPublic(LLMJobBasePublic):
"""Full job response with nested LLM response when complete."""

llm_response: LLMCallResponse | None = None
error_message: str | None = None
Comment on lines +124 to +128
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep the polling schema aligned with the route payload.

The new GET handler already builds LLMJobPublic with job_inserted_at and job_updated_at, but those fields are not part of this schema. Right now the route and the documented response shape drift from each other.

Suggested fix
 class LLMJobPublic(LLMJobBasePublic):
     """Full job response with nested LLM response when complete."""
 
+    job_inserted_at: datetime
+    job_updated_at: datetime
     llm_response: LLMCallResponse | None = None
     error_message: str | None = None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/models/llm/response.py` around lines 124 - 128, The LLMJobPublic
Pydantic schema is missing the timestamp fields that the GET handler includes in
its response (causing schema/route drift); update the schema by adding the
job_inserted_at and job_updated_at attributes (appropriate datetime types) to
LLMJobPublic (or to LLMJobBasePublic if shared across responses) so the model
matches the route payload and documented shape (ensure any necessary
imports/annotations are present).

Loading