From edeb059954cb9371682de2b8d44973ac72643d37 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Mon, 30 Mar 2026 16:09:10 +0530 Subject: [PATCH 1/7] llm call: pollimg llm endpoint --- backend/app/api/docs/llm/get_llm_call.md | 44 ++++++++++++ backend/app/api/routes/llm.py | 92 ++++++++++++++++++++++-- backend/app/models/__init__.py | 3 + backend/app/models/llm/__init__.py | 3 + backend/app/models/llm/request.py | 23 +++++- 5 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 backend/app/api/docs/llm/get_llm_call.md diff --git a/backend/app/api/docs/llm/get_llm_call.md b/backend/app/api/docs/llm/get_llm_call.md new file mode 100644 index 000000000..f305d328d --- /dev/null +++ b/backend/app/api/docs/llm/get_llm_call.md @@ -0,0 +1,44 @@ +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. + +### Path Parameters + +**`job_id`** (required, UUID) - The unique identifier of the job returned when the LLM call was created. + +### Response + +The endpoint returns an `LLMJobPublic` object containing: + +- **`job_id`** (UUID) - The unique identifier of the job +- **`status`** (string) - Current status of the job. Possible values: + - `PENDING` - Job has been created and is waiting to be processed + - `PROCESSING` - Job is currently being processed + - `SUCCESS` - Job completed successfully + - `FAILED` - Job failed during processing +- **`llm_response`** (object | null) - The complete LLM response when status is `SUCCESS`, containing: + - `response` - Normalized LLM response with provider_response_id, conversation_id, provider, model, and output + - `usage` - Token usage information (input_tokens, output_tokens, total_tokens) +- **`error_message`** (string | null) - Error details if the job failed, otherwise null +- **`job_inserted_at`** (datetime) - Timestamp when the job was created +- **`job_updated_at`** (datetime) - Timestamp when the job was last updated + +### Usage + +1. Create an LLM call using POST `/llm/call` to receive a `job_id` +2. Use this endpoint to poll for the job status +3. When the status is `SUCCESS`, the `llm_response` field will contain the complete LLM response +4. When the status is `FAILED`, check the `error_message` field for details + +### Polling Strategy + +- Poll this endpoint periodically until `status` is either `SUCCESS` or `FAILED` +- Use exponential backoff (e.g., 1s, 2s, 4s, 8s) to reduce server load +- Stop polling when status is terminal (`SUCCESS` or `FAILED`) + +### 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 +- The endpoint returns a 404 error if the job_id does not exist diff --git a/backend/app/api/routes/llm.py b/backend/app/api/routes/llm.py index ec48803ce..7d6eff8c2 100644 --- a/backend/app/api/routes/llm.py +++ b/backend/app/api/routes/llm.py @@ -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 @@ -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))], ) @@ -43,6 +53,7 @@ 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 @@ -50,15 +61,82 @@ def llm_call( 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") + + job_response = LLMJobImmediatePublic( + job_id=job.id, + status=job.status.value, + 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, +): + """ + 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") + + llm_call_response = None + if job.status.value == "SUCCESS": + llm_calls = get_llm_calls_by_job_id(session=session, job_id=job_id) + + if llm_calls: + # Get the first (latest) LLM call + 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, + ) + + usage = Usage(**llm_call.usage) if llm_call.usage else None + + llm_call_response = LLMCallResponse( + response=llm_response, + usage=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, + job_inserted_at=job.created_at, + job_updated_at=job.updated_at, ) + + return APIResponse.success_response(data=job_response) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b5cb3f0c6..64856cdf9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -115,6 +115,9 @@ LLMChainRequest, LLMChainResponse, LlmChain, + LLMJobBasePublic, + LLMJobImmediatePublic, + LLMJobPublic, ) from .message import Message diff --git a/backend/app/models/llm/__init__.py b/backend/app/models/llm/__init__.py index 1cb659f85..ce401904b 100644 --- a/backend/app/models/llm/__init__.py +++ b/backend/app/models/llm/__init__.py @@ -20,6 +20,9 @@ PDFContent, ImageInput, PDFInput, + LLMJobBasePublic, + LLMJobImmediatePublic, + LLMJobPublic, ) from app.models.llm.response import ( LLMCallResponse, diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py index 1317c9ef3..f4454afb6 100644 --- a/backend/app/models/llm/request.py +++ b/backend/app/models/llm/request.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Annotated, Any, Literal, Union +from typing import TYPE_CHECKING, Annotated, Any, Literal, Union from uuid import UUID, uuid4 import sqlalchemy as sa @@ -8,6 +8,9 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import Field, Index, SQLModel, text from app.core.util import now + +if TYPE_CHECKING: + from app.models.llm.response import LLMCallResponse from app.models.llm.constants import ( DEFAULT_STT_MODEL, DEFAULT_TTS_MODEL, @@ -808,3 +811,21 @@ class LlmChain(SQLModel, table=True): "comment": "Timestamp when the chain record was last updated" }, ) + + +# Response models +class LLMJobBasePublic(SQLModel): + job_id: UUID + status: str # JobStatus from job.py + + +class LLMJobImmediatePublic(LLMJobBasePublic): + job_inserted_at: datetime + job_updated_at: datetime + + +class LLMJobPublic(LLMJobBasePublic): + llm_response: "LLMCallResponse | None" = None # Forward reference + error_message: str | None = None + job_inserted_at: datetime + job_updated_at: datetime From 2861753f0f6c3f75c21f1d0f02f25ab5233c4127 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Tue, 31 Mar 2026 11:00:05 +0530 Subject: [PATCH 2/7] adding message back to response body --- backend/app/api/routes/llm.py | 5 +++++ backend/app/models/llm/__init__.py | 6 +++--- backend/app/models/llm/request.py | 23 +---------------------- backend/app/models/llm/response.py | 26 ++++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/backend/app/api/routes/llm.py b/backend/app/api/routes/llm.py index 7d6eff8c2..82cc7b4d8 100644 --- a/backend/app/api/routes/llm.py +++ b/backend/app/api/routes/llm.py @@ -75,13 +75,18 @@ def llm_call( if not job: raise HTTPException(status_code=404, detail="Job not found") + message = "Your response is being generated and will be delivered via callback." + 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, ) + # message = "Your response is being generated and will be delivered via callback." if request.callback_url else "Your response is being generated. Use the job_id to poll for results." + return APIResponse.success_response(data=job_response) diff --git a/backend/app/models/llm/__init__.py b/backend/app/models/llm/__init__.py index ce401904b..5d7d9326f 100644 --- a/backend/app/models/llm/__init__.py +++ b/backend/app/models/llm/__init__.py @@ -20,9 +20,6 @@ PDFContent, ImageInput, PDFInput, - LLMJobBasePublic, - LLMJobImmediatePublic, - LLMJobPublic, ) from app.models.llm.response import ( LLMCallResponse, @@ -33,4 +30,7 @@ AudioOutput, LLMChainResponse, IntermediateChainResponse, + LLMJobBasePublic, + LLMJobImmediatePublic, + LLMJobPublic, ) diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py index f4454afb6..1317c9ef3 100644 --- a/backend/app/models/llm/request.py +++ b/backend/app/models/llm/request.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import TYPE_CHECKING, Annotated, Any, Literal, Union +from typing import Annotated, Any, Literal, Union from uuid import UUID, uuid4 import sqlalchemy as sa @@ -8,9 +8,6 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import Field, Index, SQLModel, text from app.core.util import now - -if TYPE_CHECKING: - from app.models.llm.response import LLMCallResponse from app.models.llm.constants import ( DEFAULT_STT_MODEL, DEFAULT_TTS_MODEL, @@ -811,21 +808,3 @@ class LlmChain(SQLModel, table=True): "comment": "Timestamp when the chain record was last updated" }, ) - - -# Response models -class LLMJobBasePublic(SQLModel): - job_id: UUID - status: str # JobStatus from job.py - - -class LLMJobImmediatePublic(LLMJobBasePublic): - job_inserted_at: datetime - job_updated_at: datetime - - -class LLMJobPublic(LLMJobBasePublic): - llm_response: "LLMCallResponse | None" = None # Forward reference - error_message: str | None = None - job_inserted_at: datetime - job_updated_at: datetime diff --git a/backend/app/models/llm/response.py b/backend/app/models/llm/response.py index fff62ea76..e31fc196a 100644 --- a/backend/app/models/llm/response.py +++ b/backend/app/models/llm/response.py @@ -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 @@ -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 + message: str + + +class LLMJobImmediatePublic(LLMJobBasePublic): + """Immediate response after creating an LLM job.""" + + 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 From 20f96cb465de4b6ec4a82c840391b25fd4ca10ac Mon Sep 17 00:00:00 2001 From: nishika26 Date: Thu, 2 Apr 2026 02:00:39 +0530 Subject: [PATCH 3/7] adding message to response body --- backend/app/api/docs/llm/get_llm_call.md | 34 ------------------------ backend/app/api/routes/llm.py | 10 ++++--- backend/app/models/llm/response.py | 2 +- 3 files changed, 7 insertions(+), 39 deletions(-) diff --git a/backend/app/api/docs/llm/get_llm_call.md b/backend/app/api/docs/llm/get_llm_call.md index f305d328d..ab6217d20 100644 --- a/backend/app/api/docs/llm/get_llm_call.md +++ b/backend/app/api/docs/llm/get_llm_call.md @@ -2,43 +2,9 @@ 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. -### Path Parameters - -**`job_id`** (required, UUID) - The unique identifier of the job returned when the LLM call was created. - -### Response - -The endpoint returns an `LLMJobPublic` object containing: - -- **`job_id`** (UUID) - The unique identifier of the job -- **`status`** (string) - Current status of the job. Possible values: - - `PENDING` - Job has been created and is waiting to be processed - - `PROCESSING` - Job is currently being processed - - `SUCCESS` - Job completed successfully - - `FAILED` - Job failed during processing -- **`llm_response`** (object | null) - The complete LLM response when status is `SUCCESS`, containing: - - `response` - Normalized LLM response with provider_response_id, conversation_id, provider, model, and output - - `usage` - Token usage information (input_tokens, output_tokens, total_tokens) -- **`error_message`** (string | null) - Error details if the job failed, otherwise null -- **`job_inserted_at`** (datetime) - Timestamp when the job was created -- **`job_updated_at`** (datetime) - Timestamp when the job was last updated - -### Usage - -1. Create an LLM call using POST `/llm/call` to receive a `job_id` -2. Use this endpoint to poll for the job status -3. When the status is `SUCCESS`, the `llm_response` field will contain the complete LLM response -4. When the status is `FAILED`, check the `error_message` field for details - -### Polling Strategy - -- Poll this endpoint periodically until `status` is either `SUCCESS` or `FAILED` -- Use exponential backoff (e.g., 1s, 2s, 4s, 8s) to reduce server load -- Stop polling when status is terminal (`SUCCESS` or `FAILED`) ### 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 -- The endpoint returns a 404 error if the job_id does not exist diff --git a/backend/app/api/routes/llm.py b/backend/app/api/routes/llm.py index 82cc7b4d8..98e450354 100644 --- a/backend/app/api/routes/llm.py +++ b/backend/app/api/routes/llm.py @@ -75,7 +75,10 @@ def llm_call( if not job: raise HTTPException(status_code=404, detail="Job not found") - message = "Your response is being generated and will be delivered via callback." + 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, @@ -85,8 +88,6 @@ def llm_call( job_updated_at=job.updated_at, ) - # message = "Your response is being generated and will be delivered via callback." if request.callback_url else "Your response is being generated. Use the job_id to poll for results." - return APIResponse.success_response(data=job_response) @@ -116,7 +117,8 @@ def get_llm_call_status( llm_calls = get_llm_calls_by_job_id(session=session, job_id=job_id) if llm_calls: - # Get the first (latest) LLM call + # 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( diff --git a/backend/app/models/llm/response.py b/backend/app/models/llm/response.py index e31fc196a..2a82347c7 100644 --- a/backend/app/models/llm/response.py +++ b/backend/app/models/llm/response.py @@ -111,12 +111,12 @@ class LLMJobBasePublic(SQLModel): job_id: UUID status: str # JobStatus from job.py - message: str class LLMJobImmediatePublic(LLMJobBasePublic): """Immediate response after creating an LLM job.""" + message: str job_inserted_at: datetime job_updated_at: datetime From 05e9f57be676e9a627f734d5f43ee3b97ffa89bb Mon Sep 17 00:00:00 2001 From: nishika26 Date: Thu, 2 Apr 2026 13:23:28 +0530 Subject: [PATCH 4/7] coderabbit reviews --- backend/app/api/routes/llm.py | 16 ++++++++++------ backend/app/crud/llm.py | 4 ++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/app/api/routes/llm.py b/backend/app/api/routes/llm.py index 98e450354..8133496a9 100644 --- a/backend/app/api/routes/llm.py +++ b/backend/app/api/routes/llm.py @@ -101,7 +101,7 @@ 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. @@ -114,7 +114,9 @@ def get_llm_call_status( llm_call_response = None if job.status.value == "SUCCESS": - llm_calls = get_llm_calls_by_job_id(session=session, job_id=job_id) + 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 @@ -129,11 +131,15 @@ def get_llm_call_status( output=llm_call.content, ) - usage = Usage(**llm_call.usage) if llm_call.usage else None + 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, + usage=Usage(**llm_call.usage), provider_raw_response=None, ) @@ -142,8 +148,6 @@ def get_llm_call_status( status=job.status.value, llm_response=llm_call_response, error_message=job.error_message, - job_inserted_at=job.created_at, - job_updated_at=job.updated_at, ) return APIResponse.success_response(data=job_response) diff --git a/backend/app/crud/llm.py b/backend/app/crud/llm.py index e0ca2b171..7b8f86e3f 100644 --- a/backend/app/crud/llm.py +++ b/backend/app/crud/llm.py @@ -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()) From b341f837e13d9d69951fd631ca39595dcec6b87e Mon Sep 17 00:00:00 2001 From: nishika26 Date: Thu, 2 Apr 2026 22:09:13 +0530 Subject: [PATCH 5/7] test cases --- backend/app/api/routes/llm.py | 6 +- backend/app/tests/api/routes/test_llm.py | 140 ++++++++++++++++++++++- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/backend/app/api/routes/llm.py b/backend/app/api/routes/llm.py index 8133496a9..a4cd705c6 100644 --- a/backend/app/api/routes/llm.py +++ b/backend/app/api/routes/llm.py @@ -1,11 +1,10 @@ import logging from uuid import UUID -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission -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 ( @@ -13,6 +12,7 @@ LLMCallResponse, LLMJobImmediatePublic, LLMJobPublic, + JobStatus, ) from app.models.llm.response import LLMResponse, Usage from app.services.llm.jobs import start_job @@ -113,7 +113,7 @@ def get_llm_call_status( raise HTTPException(status_code=404, detail="Job not found") llm_call_response = None - if job.status.value == "SUCCESS": + if job.status.value == JobStatus.SUCCESS: llm_calls = get_llm_calls_by_job_id( session=session, job_id=job_id, project_id=_current_user.project_.id ) diff --git a/backend/app/tests/api/routes/test_llm.py b/backend/app/tests/api/routes/test_llm.py index 5031effd9..7328f2607 100644 --- a/backend/app/tests/api/routes/test_llm.py +++ b/backend/app/tests/api/routes/test_llm.py @@ -1,8 +1,14 @@ +import pytest +from uuid import uuid4 from unittest.mock import patch +from sqlmodel import Session from fastapi.testclient import TestClient -from app.models import LLMCallRequest +from app.crud import JobCrud +from app.crud.llm import create_llm_call, update_llm_call_response +from app.models import JobType, LLMCallRequest, Job, JobStatus, JobUpdate +from app.models.llm.response import LLMCallResponse from app.models.llm.request import ( QueryParams, LLMCallConfig, @@ -12,6 +18,52 @@ ) +@pytest.fixture +def llm_job(db: Session) -> Job: + crud = JobCrud(db) + return crud.create(job_type=JobType.LLM_API) + + +@pytest.fixture +def llm_response_in_db(db: Session, llm_job, user_api_key) -> LLMCallResponse: + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + params={ + "model": "gpt-4o", + "instructions": "You are helpful.", + "temperature": 0.7, + }, + type="text", + ) + ) + llm_call = create_llm_call( + db, + request=LLMCallRequest( + query=QueryParams(input="What is the capital of France?"), + config=LLMCallConfig(blob=config_blob), + ), + job_id=llm_job.id, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, + resolved_config=config_blob, + original_provider="openai", + ) + update_llm_call_response( + db, + llm_call_id=llm_call.id, + provider_response_id="resp_abc123", + content={"type": "text", "content": {"format": "text", "value": "Paris"}}, + usage={ + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "reasoning_tokens": None, + }, + ) + return llm_call + + def test_llm_call_success( client: TestClient, user_api_key_header: dict[str, str] ) -> None: @@ -247,3 +299,89 @@ def test_llm_call_guardrails_bypassed_still_succeeds( assert "response is being generated" in body["data"]["message"] mock_start_job.assert_called_once() + + +def test_get_llm_call_pending( + client: TestClient, + user_api_key_header: dict[str, str], + llm_job, +) -> None: + """Job in PENDING state returns status with no llm_response.""" + response = client.get( + f"/api/v1/llm/call/{llm_job.id}", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["data"]["job_id"] == str(llm_job.id) + assert body["data"]["status"] == "PENDING" + assert body["data"]["llm_response"] is None + + +def test_get_llm_call_success( + client: TestClient, + db: Session, + user_api_key_header: dict[str, str], + llm_job, + llm_response_in_db, +) -> None: + """Job in SUCCESS state returns full llm_response with usage.""" + + JobCrud(db).update(llm_job.id, JobUpdate(status=JobStatus.SUCCESS)) + + response = client.get( + f"/api/v1/llm/call/{llm_job.id}", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + data = body["data"] + assert data["status"] == "SUCCESS" + assert data["llm_response"] is not None + assert data["llm_response"]["response"]["provider_response_id"] == "resp_abc123" + assert data["llm_response"]["response"]["provider"] == "openai" + assert data["llm_response"]["usage"]["input_tokens"] == 10 + assert data["llm_response"]["usage"]["output_tokens"] == 5 + assert data["llm_response"]["usage"]["total_tokens"] == 15 + + +def test_get_llm_call_failed( + client: TestClient, + db: Session, + user_api_key_header: dict[str, str], + llm_job, +) -> None: + JobCrud(db).update( + llm_job.id, + JobUpdate(status=JobStatus.FAILED, error_message="Provider timeout"), + ) + + response = client.get( + f"/api/v1/llm/call/{llm_job.id}", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["data"]["status"] == "FAILED" + assert body["data"]["error_message"] == "Provider timeout" + assert body["data"]["llm_response"] is None + + +def test_get_llm_call_not_found( + client: TestClient, + user_api_key_header: dict[str, str], +) -> None: + """Non-existent job_id returns 404.""" + + response = client.get( + f"/api/v1/llm/call/{uuid4()}", + headers=user_api_key_header, + ) + + assert response.status_code == 404 From 572e422401c5a5d741b3e9d412e3e08e83e27c06 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Fri, 3 Apr 2026 17:15:35 +0530 Subject: [PATCH 6/7] fixing test cases and adding factories --- backend/app/crud/llm.py | 7 ++- backend/app/models/llm/request.py | 1 + backend/app/models/llm/response.py | 3 +- backend/app/tests/api/routes/test_llm.py | 56 +++++-------------- backend/app/tests/crud/test_llm.py | 31 ++++------- backend/app/tests/utils/llm.py | 71 ++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 65 deletions(-) create mode 100644 backend/app/tests/utils/llm.py diff --git a/backend/app/crud/llm.py b/backend/app/crud/llm.py index 7b8f86e3f..c7f5b1aee 100644 --- a/backend/app/crud/llm.py +++ b/backend/app/crud/llm.py @@ -1,11 +1,12 @@ import logging +import base64 +import json +from uuid import UUID from typing import Any, Literal -from uuid import UUID from sqlmodel import Session, select + from app.core.util import now -import base64 -import json from app.models.llm import LlmCall, LLMCallRequest, ConfigBlob from app.models.llm.request import ( TextInput, diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py index 8cc5f5c3e..65726de28 100644 --- a/backend/app/models/llm/request.py +++ b/backend/app/models/llm/request.py @@ -7,6 +7,7 @@ from pydantic import HttpUrl, model_validator from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import Field, Index, SQLModel, text + from app.core.util import now from app.models.llm.constants import ( DEFAULT_STT_MODEL, diff --git a/backend/app/models/llm/response.py b/backend/app/models/llm/response.py index 2a82347c7..dfbcbf4f6 100644 --- a/backend/app/models/llm/response.py +++ b/backend/app/models/llm/response.py @@ -5,9 +5,10 @@ """ from datetime import datetime from uuid import UUID +from typing import Literal, Annotated from sqlmodel import SQLModel, Field -from typing import Literal, Annotated + from app.models.llm.request import AudioContent, TextContent diff --git a/backend/app/tests/api/routes/test_llm.py b/backend/app/tests/api/routes/test_llm.py index 7328f2607..885076bb0 100644 --- a/backend/app/tests/api/routes/test_llm.py +++ b/backend/app/tests/api/routes/test_llm.py @@ -6,62 +6,35 @@ from fastapi.testclient import TestClient from app.crud import JobCrud -from app.crud.llm import create_llm_call, update_llm_call_response -from app.models import JobType, LLMCallRequest, Job, JobStatus, JobUpdate +from app.models import Job, JobStatus, JobUpdate from app.models.llm.response import LLMCallResponse from app.models.llm.request import ( - QueryParams, LLMCallConfig, ConfigBlob, - KaapiCompletionConfig, NativeCompletionConfig, + KaapiCompletionConfig, + QueryParams, ) +from app.models.llm import LLMCallRequest +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.llm import create_llm_job, create_llm_call_with_response @pytest.fixture def llm_job(db: Session) -> Job: - crud = JobCrud(db) - return crud.create(job_type=JobType.LLM_API) + return create_llm_job(db) @pytest.fixture -def llm_response_in_db(db: Session, llm_job, user_api_key) -> LLMCallResponse: - config_blob = ConfigBlob( - completion=KaapiCompletionConfig( - provider="openai", - params={ - "model": "gpt-4o", - "instructions": "You are helpful.", - "temperature": 0.7, - }, - type="text", - ) - ) - llm_call = create_llm_call( +def llm_response_in_db( + db: Session, llm_job: Job, user_api_key: TestAuthContext +) -> LLMCallResponse: + return create_llm_call_with_response( db, - request=LLMCallRequest( - query=QueryParams(input="What is the capital of France?"), - config=LLMCallConfig(blob=config_blob), - ), job_id=llm_job.id, project_id=user_api_key.project_id, organization_id=user_api_key.organization_id, - resolved_config=config_blob, - original_provider="openai", - ) - update_llm_call_response( - db, - llm_call_id=llm_call.id, - provider_response_id="resp_abc123", - content={"type": "text", "content": {"format": "text", "value": "Paris"}}, - usage={ - "input_tokens": 10, - "output_tokens": 5, - "total_tokens": 15, - "reasoning_tokens": None, - }, ) - return llm_call def test_llm_call_success( @@ -324,15 +297,14 @@ def test_get_llm_call_success( client: TestClient, db: Session, user_api_key_header: dict[str, str], - llm_job, - llm_response_in_db, + llm_response_in_db: LLMCallResponse, ) -> None: """Job in SUCCESS state returns full llm_response with usage.""" - JobCrud(db).update(llm_job.id, JobUpdate(status=JobStatus.SUCCESS)) + JobCrud(db).update(llm_response_in_db.job_id, JobUpdate(status=JobStatus.SUCCESS)) response = client.get( - f"/api/v1/llm/call/{llm_job.id}", + f"/api/v1/llm/call/{llm_response_in_db.job_id}", headers=user_api_key_header, ) diff --git a/backend/app/tests/crud/test_llm.py b/backend/app/tests/crud/test_llm.py index ce6bb2e60..b51ab25e4 100644 --- a/backend/app/tests/crud/test_llm.py +++ b/backend/app/tests/crud/test_llm.py @@ -1,49 +1,42 @@ +import base64 from uuid import uuid4 import pytest -from sqlmodel import Session, select +from sqlmodel import Session -from app.crud import JobCrud from app.crud.llm import ( create_llm_call, get_llm_call_by_id, get_llm_calls_by_job_id, update_llm_call_response, ) -from app.models import JobType, Project, Organization +from app.models import Project, Organization from app.models.llm import ( ConfigBlob, LLMCallRequest, - LlmCall, QueryParams, ) from app.models.llm.request import ( KaapiCompletionConfig, LLMCallConfig, ) +from app.tests.utils.utils import get_project, get_organization +from app.tests.utils.llm import create_llm_job @pytest.fixture def test_project(db: Session) -> Project: - """Get the first available test project.""" - project = db.exec(select(Project).limit(1)).first() - assert project is not None, "No test project found in seed data" - return project + return get_project(db) @pytest.fixture def test_organization(db: Session, test_project: Project) -> Organization: - """Get the organization for the test project.""" - org = db.get(Organization, test_project.organization_id) - assert org is not None, "No organization found for test project" - return org + return get_organization(db) @pytest.fixture def test_job(db: Session): - """Create a test job for LLM call tests.""" - crud = JobCrud(db) - return crud.create(job_type=JobType.LLM_API, trace_id="test-llm-trace") + return create_llm_job(db) @pytest.fixture @@ -308,14 +301,15 @@ def test_get_llm_calls_by_job_id( original_provider="openai", ) - llm_calls = get_llm_calls_by_job_id(db, test_job.id) + llm_calls = get_llm_calls_by_job_id(db, test_job.id, test_project.id) assert len(llm_calls) == 3 -def test_get_llm_calls_by_job_id_empty(db: Session) -> None: +def test_get_llm_calls_by_job_id_empty(db: Session, test_project: Project) -> None: """Test fetching LLM calls for a job with no calls.""" fake_job_id = uuid4() - llm_calls = get_llm_calls_by_job_id(db, fake_job_id) + + llm_calls = get_llm_calls_by_job_id(db, fake_job_id, test_project.id) assert llm_calls == [] @@ -421,7 +415,6 @@ def test_update_llm_call_response_with_audio_content( tts_config_blob: ConfigBlob, ) -> None: """Test updating LLM call with audio content calculates size.""" - import base64 request = LLMCallRequest( query=QueryParams(input="Test input"), diff --git a/backend/app/tests/utils/llm.py b/backend/app/tests/utils/llm.py new file mode 100644 index 000000000..8c3368026 --- /dev/null +++ b/backend/app/tests/utils/llm.py @@ -0,0 +1,71 @@ +from sqlmodel import Session + +from app.crud import JobCrud +from app.crud.llm import create_llm_call, update_llm_call_response +from app.models import JobType, Job +from app.models.llm.response import LLMCallResponse +from app.models.llm.request import ( + ConfigBlob, + KaapiCompletionConfig, + LLMCallConfig, + QueryParams, +) +from app.models.llm import LLMCallRequest + + +def create_llm_job(db: Session) -> Job: + """Create a persisted LLM_API job for use in tests.""" + return JobCrud(db).create(job_type=JobType.LLM_API, trace_id="test-llm-trace") + + +def create_llm_call_with_response( + db: Session, + job_id, + project_id: int, + organization_id: int, +) -> LLMCallResponse: + """ + Create a persisted LlmCall with a completed response for use in tests. + + Uses a standard OpenAI text-completion config and fixed response values + so tests can assert against predictable data. + """ + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + params={ + "model": "gpt-4o", + "instructions": "You are helpful.", + "temperature": 0.7, + }, + type="text", + ) + ) + + llm_call = create_llm_call( + db, + request=LLMCallRequest( + query=QueryParams(input="What is the capital of France?"), + config=LLMCallConfig(blob=config_blob), + ), + job_id=job_id, + project_id=project_id, + organization_id=organization_id, + resolved_config=config_blob, + original_provider="openai", + ) + + update_llm_call_response( + db, + llm_call_id=llm_call.id, + provider_response_id="resp_abc123", + content={"type": "text", "content": {"format": "text", "value": "Paris"}}, + usage={ + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "reasoning_tokens": None, + }, + ) + + return llm_call From 7c173135dd6e23af3c6ad41be4b7a3164eb8d517 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Fri, 3 Apr 2026 17:28:53 +0530 Subject: [PATCH 7/7] removing unused function --- backend/app/tests/crud/test_llm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/tests/crud/test_llm.py b/backend/app/tests/crud/test_llm.py index b51ab25e4..4a188392f 100644 --- a/backend/app/tests/crud/test_llm.py +++ b/backend/app/tests/crud/test_llm.py @@ -30,7 +30,7 @@ def test_project(db: Session) -> Project: @pytest.fixture -def test_organization(db: Session, test_project: Project) -> Organization: +def test_organization(db: Session) -> Organization: return get_organization(db)