Skip to content

Job Management: Preserve llm_call details#823

Open
Prajna1999 wants to merge 6 commits into
mainfrom
feat/add-llm-call-record
Open

Job Management: Preserve llm_call details#823
Prajna1999 wants to merge 6 commits into
mainfrom
feat/add-llm-call-record

Conversation

@Prajna1999
Copy link
Copy Markdown
Collaborator

@Prajna1999 Prajna1999 commented May 11, 2026

Summary

Target issue is #819
Explain the motivation for making this change. What existing problem does the pull request solve?

Checklist

Before submitting a pull request, please ensure that you mark these task.

  • Ran fastapi run --reload app/main.py or docker compose up in the repository root and test.
  • If you've fixed a bug or added code that is tested and has test cases.

Notes

Please add here if any other information is required for the reviewer.

Summary by CodeRabbit

  • New Features

    • Audio inputs now support both base64 encoding and public URLs
    • Audio outputs are accessible via signed, time-limited URLs
    • Enhanced audio file upload and storage capabilities
  • Improvements

    • Refined audio content metadata handling across speech-to-text and text-to-speech operations
    • Improved LLM call and chain record lifecycle with pending/resolved state management

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

📝 Walkthrough

Walkthrough

This PR implements two-phase LLM call lifecycle (pending → resolved) with audio URL support. The schema is loosened to allow nullable fields, audio inputs can now be URLs or base64, and job execution defers provider/model determination. Audio is downloaded from URLs, uploaded to S3, and served via presigned URLs.

Changes

Audio URL Support and Call Lifecycle

Layer / File(s) Summary
Database schema and migration
backend/app/alembic/versions/058_make_llm_call_fields_nullable.py, backend/app/models/llm/request.py
Migration 058 makes input_type, provider, and model columns nullable; LlmCall ORM reflects these changes with default=None.
Audio input schema and serialization
backend/app/models/llm/request.py, backend/app/crud/llm.py
AudioContent now accepts both "base64" and "url" formats with optional uri field; serialize_input includes URL and MIME type in JSON for URL-sourced audio.
Audio download, upload, and resolution helpers
backend/app/core/storage_utils.py, backend/app/utils.py
New utilities download audio from public URLs with error handling, upload bytes to S3 with MIME-to-extension mapping, and integrate into resolve_input flow for URL handling.
LLM call database lifecycle helpers
backend/app/crud/llm.py
New functions support pending/resolved workflow: create_llm_call_pending pre-creates records at job start, update_llm_call_resolved_fields fills deferred fields, update_llm_call_input writes S3 URIs, and update_llm_call_response safely handles audio size calculation.
LLM chain job lookup
backend/app/crud/llm_chain.py
Added get_llm_chain_by_job_id to retrieve and reuse existing chain records instead of always creating new ones.
Job initialization and record pre-creation
backend/app/services/llm/jobs.py
start_job() and start_chain_job() now pre-create pending LlmCall and LlmChain DB records before task scheduling; exceptions logged as non-fatal warnings.
LLM call execution with audio handling
backend/app/services/llm/jobs.py
execute_llm_call() updates pending records instead of creating new ones for standalone calls; adds STT audio conversion/upload from URL to S3 and TTS audio upload with URI tracking; manages separate DB-persisted content (URI-only for remote audio).
Job completion and audio presigning
backend/app/services/llm/jobs.py
execute_job() replaces stored S3 audio URIs with presigned URLs on success; execute_chain_job() reuses existing chain records when available.
Chain executor presigned URL generation
backend/app/services/llm/chain/executor.py
New _resolve_presigned_url() helper replaces audio content.uri with signed URLs (1-hour expiry) in both final response and intermediate callbacks; logs warnings and clears URI on errors.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ProjectTech4DevAI/kaapi-backend#574: Both PRs modify code paths for resolving and handling audio LLM inputs/outputs—adding audio-resolution utilities, updating CRUD audio handling and size calculation, and adding audio processing and upload helpers.

Suggested reviewers

  • nishika26
  • vprashrex

Poem

🐰 Audio streams now flow from URLs so grand,
Records pending bloom before they're manned,
S3 uploads soar with MIME-mapped care,
Presigned secrets signed through the air! 🎵

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Job Management: Preserve llm_call details' directly corresponds to the main objective: pre-creating LLM call records before Celery task scheduling and preserving call details throughout the job lifecycle.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/add-llm-call-record

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Prajna1999 Prajna1999 changed the title Feat/add llm call record Job Management: Preserve llm_call details May 11, 2026
@Prajna1999 Prajna1999 requested review from kartpop and vprashrex May 11, 2026 13:16
@Prajna1999 Prajna1999 self-assigned this May 12, 2026
@Prajna1999 Prajna1999 added the enhancement New feature or request label May 12, 2026
@Prajna1999 Prajna1999 moved this to In Review in Kaapi-dev May 12, 2026
@Prajna1999 Prajna1999 linked an issue May 12, 2026 that may be closed by this pull request
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
backend/app/core/storage_utils.py (1)

211-222: ⚡ Quick win

Add m4a MIME aliases to extension mapping.

audio/m4a (and common variant audio/x-m4a) currently falls back to wav, which can produce wrong file extensions.

Suggested mapping update
 _MIME_TO_EXT: dict[str, str] = {
@@
     "audio/mp4": "mp4",
+    "audio/m4a": "m4a",
+    "audio/x-m4a": "m4a",
     "audio/aac": "aac",
     "audio/flac": "flac",
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/storage_utils.py` around lines 211 - 222, The _MIME_TO_EXT
mapping in storage_utils.py is missing aliases for m4a mime types so audio/m4a
and audio/x-m4a fall back incorrectly; update the _MIME_TO_EXT dict to include
entries mapping "audio/m4a" and "audio/x-m4a" to "m4a" (alongside the existing
audio/* entries) so functions that rely on _MIME_TO_EXT return the correct .m4a
extension.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/alembic/versions/058_make_llm_call_fields_nullable.py`:
- Around line 25-28: The downgrade() changes try to set llm_call.model,
llm_call.provider and llm_call.input_type to nullable=False which will fail if
any rows contain NULL; modify downgrade() to first backfill NULL values for
these columns (or abort with a clear error) before calling op.alter_column, e.g.
run UPDATE statements against the "llm_call" table to set sensible defaults for
model, provider and input_type or raise a RuntimeError if NULLs exist, then call
op.alter_column("llm_call", "model", nullable=False),
op.alter_column("llm_call", "provider", nullable=False) and
op.alter_column("llm_call", "input_type", nullable=False).

In `@backend/app/crud/llm.py`:
- Around line 284-291: get_llm_call_by_job_id is non-deterministic because it
uses .first() without filtering or ordering, which can return the wrong
standalone LlmCall when multiple rows exist; update the query in
get_llm_call_by_job_id to restrict to the intended active/pending row (e.g., add
a predicate such as LlmCall.status == "pending" or LlmCall.is_active) and add a
deterministic ORDER BY (for example LlmCall.created_at.desc() or
LlmCall.id.desc()) before taking the first result so updates always target the
correct record.

In `@backend/app/services/llm/chain/executor.py`:
- Line 73: The _resolve_presigned_url function's parameter is untyped; update
the signature to include a concrete type (e.g., change def
_resolve_presigned_url(self, output) -> None: to def
_resolve_presigned_url(self, output: dict[str, Any]) -> None:) and import Any
from typing at the top of the module; if a more specific type exists in your
codebase (e.g., a Response or Output model), use that type instead of dict[str,
Any] and keep the return as -> None.

In `@backend/app/utils.py`:
- Around line 595-607: The download_audio_bytes function is vulnerable to SSRF
and memory exhaustion; harden it by validating the input URL and streaming with
size caps: ensure the scheme is http/https, resolve the hostname to an IP and
reject private/loopback/metadata or other disallowed ranges (re-check after
redirects), enforce and check Content-Length against a configured
MAX_AUDIO_BYTES before allocating, use requests.get(stream=True) with short
timeouts and iterate response.iter_content(chunk_size) accumulating up to the
max and aborting if exceeded, and return clear error messages on validation
failures, redirect-caused host changes, timeouts, or oversized payloads; keep
all checks and limits inside download_audio_bytes and reference it when
implementing these changes.

---

Nitpick comments:
In `@backend/app/core/storage_utils.py`:
- Around line 211-222: The _MIME_TO_EXT mapping in storage_utils.py is missing
aliases for m4a mime types so audio/m4a and audio/x-m4a fall back incorrectly;
update the _MIME_TO_EXT dict to include entries mapping "audio/m4a" and
"audio/x-m4a" to "m4a" (alongside the existing audio/* entries) so functions
that rely on _MIME_TO_EXT return the correct .m4a extension.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: aca2da0e-ba34-4944-b3f4-9eac2b41990d

📥 Commits

Reviewing files that changed from the base of the PR and between e8d87ff and 486af51.

📒 Files selected for processing (8)
  • backend/app/alembic/versions/058_make_llm_call_fields_nullable.py
  • backend/app/core/storage_utils.py
  • backend/app/crud/llm.py
  • backend/app/crud/llm_chain.py
  • backend/app/models/llm/request.py
  • backend/app/services/llm/chain/executor.py
  • backend/app/services/llm/jobs.py
  • backend/app/utils.py

Comment on lines +25 to +28
def downgrade() -> None:
op.alter_column("llm_call", "model", nullable=False)
op.alter_column("llm_call", "provider", nullable=False)
op.alter_column("llm_call", "input_type", nullable=False)
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 | ⚡ Quick win

Downgrade can fail once NULL rows exist.

Setting these columns back to nullable=False without backfilling will fail on rollback if any row has NULL in input_type, provider, or model.

Suggested migration hardening
 def downgrade() -> None:
+    op.execute(
+        """
+        UPDATE llm_call
+        SET input_type = COALESCE(input_type, 'text'),
+            provider = COALESCE(provider, 'unknown'),
+            model = COALESCE(model, 'unknown')
+        """
+    )
     op.alter_column("llm_call", "model", nullable=False)
     op.alter_column("llm_call", "provider", nullable=False)
     op.alter_column("llm_call", "input_type", nullable=False)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/alembic/versions/058_make_llm_call_fields_nullable.py` around
lines 25 - 28, The downgrade() changes try to set llm_call.model,
llm_call.provider and llm_call.input_type to nullable=False which will fail if
any rows contain NULL; modify downgrade() to first backfill NULL values for
these columns (or abort with a clear error) before calling op.alter_column, e.g.
run UPDATE statements against the "llm_call" table to set sensible defaults for
model, provider and input_type or raise a RuntimeError if NULLs exist, then call
op.alter_column("llm_call", "model", nullable=False),
op.alter_column("llm_call", "provider", nullable=False) and
op.alter_column("llm_call", "input_type", nullable=False).

Comment thread backend/app/crud/llm.py
Comment on lines +284 to +291
def get_llm_call_by_job_id(session: Session, job_id: UUID) -> LlmCall | None:
"""Return the single active LlmCall for a standalone job (no chain_id)."""
statement = select(LlmCall).where(
LlmCall.job_id == job_id,
LlmCall.chain_id.is_(None),
LlmCall.deleted_at.is_(None),
)
return session.exec(statement).first()
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 | ⚡ Quick win

Make get_llm_call_by_job_id deterministic for pending-row updates.

Using .first() without ordering/pending filters can pick the wrong row when multiple standalone calls exist for the same job, causing updates to land on an incorrect record.

Suggested query tightening
 def get_llm_call_by_job_id(session: Session, job_id: UUID) -> LlmCall | None:
     """Return the single active LlmCall for a standalone job (no chain_id)."""
     statement = select(LlmCall).where(
         LlmCall.job_id == job_id,
         LlmCall.chain_id.is_(None),
         LlmCall.deleted_at.is_(None),
+        LlmCall.provider.is_(None),
+        LlmCall.model.is_(None),
+        LlmCall.content.is_(None),
     )
-    return session.exec(statement).first()
+    statement = statement.order_by(LlmCall.created_at.desc())
+    return session.exec(statement).first()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/crud/llm.py` around lines 284 - 291, get_llm_call_by_job_id is
non-deterministic because it uses .first() without filtering or ordering, which
can return the wrong standalone LlmCall when multiple rows exist; update the
query in get_llm_call_by_job_id to restrict to the intended active/pending row
(e.g., add a predicate such as LlmCall.status == "pending" or LlmCall.is_active)
and add a deterministic ORDER BY (for example LlmCall.created_at.desc() or
LlmCall.id.desc()) before taking the first result so updates always target the
correct record.

self._context.project_id, self._context.organization_id
)

def _resolve_presigned_url(self, output) -> None:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add a type hint for _resolve_presigned_url parameter.

output is currently untyped; this breaks the project’s typing rule and weakens static checks.

As per coding guidelines: “Always add type hints to all function parameters and return values in Python code”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/llm/chain/executor.py` at line 73, The
_resolve_presigned_url function's parameter is untyped; update the signature to
include a concrete type (e.g., change def _resolve_presigned_url(self, output)
-> None: to def _resolve_presigned_url(self, output: dict[str, Any]) -> None:)
and import Any from typing at the top of the module; if a more specific type
exists in your codebase (e.g., a Response or Output model), use that type
instead of dict[str, Any] and keep the return as -> None.

Comment thread backend/app/utils.py
Comment on lines +595 to +607
def download_audio_bytes(url: str) -> tuple[bytes | None, str | None]:
"""Download audio from a public URL. Returns (bytes, error)."""
try:
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.content, None
except requests.exceptions.Timeout:
return None, f"Timed out downloading audio from URL: {url}"
except requests.exceptions.HTTPError as e:
return None, f"HTTP {e.response.status_code} downloading audio from URL: {url}"
except Exception as e:
return None, f"Failed to download audio from URL: {str(e)}"

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 | ⚡ Quick win

Harden URL audio download against SSRF and oversized payloads.

This directly fetches user-controlled URLs without host/IP validation, and reads unbounded content into memory. That is exploitable for SSRF and resource exhaustion.

Suggested hardening pattern
 def download_audio_bytes(url: str) -> tuple[bytes | None, str | None]:
     """Download audio from a public URL. Returns (bytes, error)."""
     try:
-        response = requests.get(url, timeout=30)
+        validate_callback_url(url)  # or dedicated outbound-url validator for media fetch
+        with requests.Session() as session:
+            session.trust_env = False
+            response = session.get(
+                url,
+                timeout=(5, 30),
+                allow_redirects=False,
+                stream=True,
+            )
         response.raise_for_status()
-        return response.content, None
+        max_bytes = 25 * 1024 * 1024
+        chunks: list[bytes] = []
+        total = 0
+        for chunk in response.iter_content(chunk_size=64 * 1024):
+            if not chunk:
+                continue
+            total += len(chunk)
+            if total > max_bytes:
+                return None, "Audio file exceeds maximum allowed size"
+            chunks.append(chunk)
+        return b"".join(chunks), None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/utils.py` around lines 595 - 607, The download_audio_bytes
function is vulnerable to SSRF and memory exhaustion; harden it by validating
the input URL and streaming with size caps: ensure the scheme is http/https,
resolve the hostname to an IP and reject private/loopback/metadata or other
disallowed ranges (re-check after redirects), enforce and check Content-Length
against a configured MAX_AUDIO_BYTES before allocating, use
requests.get(stream=True) with short timeouts and iterate
response.iter_content(chunk_size) accumulating up to the max and aborting if
exceeded, and return clear error messages on validation failures,
redirect-caused host changes, timeouts, or oversized payloads; keep all checks
and limits inside download_audio_bytes and reference it when implementing these
changes.

@Prajna1999 Prajna1999 requested a review from nishika26 May 12, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request ready-for-review

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

Job Management: Preserve llm_call details

2 participants