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
73 changes: 73 additions & 0 deletions src/api/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,79 @@ async def download_file_options(session_id: str, file_id: str):
)


@router.get("/sessions/{session_id}/objects/{file_id}")
async def get_session_object(
session_id: str,
file_id: str,
kind: str | None = Query(None, description="Resource kind: 'skill', 'agent', or 'user'"),
resource_id: str | None = Query(None, alias="id", description="Resource id (userId / agentId / skillId)"),
version: int | None = Query(None, description="Resource version (only meaningful for kind=skill)"),
file_service: FileServiceDep = None,
session_service: SessionServiceDep = None,
Comment thread
gafda marked this conversation as resolved.
):
"""Return file object metadata for a session - LibreChat compatible.

Called by LibreChat's ``getSessionInfo`` (process.js) to check whether a
previously-uploaded file is still live in the code environment. The
response must include ``lastModified`` — LibreChat uses it to decide
whether to re-upload.

Query params ``kind``/``id``/``version`` are accepted for parity with
LibreChat's ``buildCodeEnvDownloadQuery`` but are not enforced today.
"""
try:
file_info = await file_service.get_file_info(session_id, file_id)
if not file_info:
raise HTTPException(status_code=404, detail="File not found")

# Determine lastModified: prefer session last_activity for active
# sessions (mirrors the /files/{session_id} detail=summary logic).
last_modified = None
try:
session = await session_service.get_session(session_id)
if session:
if session.status == SessionStatus.ACTIVE:
last_modified = datetime.now(UTC)
elif session.last_activity:
act = session.last_activity
if isinstance(act, str):
act = datetime.fromisoformat(act)
if act.tzinfo is None:
act = act.replace(tzinfo=UTC)
last_modified = act
except Exception as exc:
logger.warning(
"Failed to retrieve session for lastModified derivation, falling back to file created_at",
session_id=session_id,
error=str(exc),
)

if last_modified is None:
last_modified = file_info.created_at
if isinstance(last_modified, str):
last_modified = datetime.fromisoformat(last_modified)
if last_modified.tzinfo is None:
last_modified = last_modified.replace(tzinfo=UTC)

last_modified_str = last_modified.isoformat(timespec="milliseconds").replace("+00:00", "Z")

return {
"name": f"{session_id}/{file_id}",
"lastModified": last_modified_str,
}

except HTTPException:
raise
except Exception as e:
logger.error(
"Failed to get session object info",
session_id=session_id,
file_id=file_id,
error=str(e),
)
raise HTTPException(status_code=500, detail="Internal server error")


@router.delete("/files/{session_id}/{file_id}")
async def delete_file(session_id: str, file_id: str, file_service: FileServiceDep = None):
"""Delete a file from the session - LibreChat compatible."""
Expand Down
143 changes: 142 additions & 1 deletion tests/unit/test_librechat_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

import pytest

from src.api.files import list_files, upload_file, upload_files_batch
from src.api.files import get_session_object, list_files, upload_file, upload_files_batch
from src.models.exec import FileRef, RequestFile

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -340,3 +340,144 @@ async def test_list_files_detail_full_emits_storage_session_id(self):
assert len(result) == 1
assert result[0]["storage_session_id"] == "sess-1"
assert result[0]["session_id"] == "sess-1"


class TestGetSessionObject:
"""Tests for GET /sessions/{session_id}/objects/{file_id}.

LibreChat's ``getSessionInfo`` (process.js) calls this endpoint to check
whether a previously-uploaded file is still active. It expects a JSON
response with ``lastModified``. A 404 triggers a re-upload cycle.
"""

@pytest.mark.asyncio
async def test_returns_last_modified_for_existing_file(self):
from datetime import UTC, datetime

from src.models.files import FileInfo
from src.models.session import Session, SessionStatus

file_info = FileInfo(
file_id="file-abc",
filename="data.csv",
size=42,
content_type="text/csv",
created_at=datetime(2026, 1, 1, tzinfo=UTC),
path="/data.csv",
)
file_service = MagicMock()
file_service.get_file_info = AsyncMock(return_value=file_info)

session = MagicMock()
session.status = SessionStatus.ACTIVE
session.last_activity = datetime(2026, 1, 2, tzinfo=UTC)

session_service = MagicMock()
session_service.get_session = AsyncMock(return_value=session)

result = await get_session_object(
session_id="sess-1",
file_id="file-abc",
kind="user",
resource_id="user-123",
version=None,
file_service=file_service,
session_service=session_service,
)

assert "lastModified" in result
assert result["name"] == "sess-1/file-abc"
# Active session should yield a timestamp close to now (not file's created_at)
assert result["lastModified"].endswith("Z")
Comment thread
gafda marked this conversation as resolved.
parsed = datetime.fromisoformat(result["lastModified"].replace("Z", "+00:00"))
assert abs((datetime.now(UTC) - parsed).total_seconds()) < 5

@pytest.mark.asyncio
async def test_returns_404_when_file_not_found(self):
from fastapi import HTTPException

file_service = MagicMock()
file_service.get_file_info = AsyncMock(return_value=None)
session_service = MagicMock()

with pytest.raises(HTTPException) as exc:
await get_session_object(
session_id="sess-1",
file_id="nonexistent",
kind="user",
resource_id="user-123",
version=None,
file_service=file_service,
session_service=session_service,
)
assert exc.value.status_code == 404

@pytest.mark.asyncio
async def test_accepts_kind_id_version_params_without_error(self):
"""The kind/id/version query params must be accepted (no 422)."""
from datetime import UTC, datetime

from src.models.files import FileInfo

file_info = FileInfo(
file_id="fid",
filename="script.py",
size=10,
content_type="text/x-python",
created_at=datetime(2026, 5, 1, tzinfo=UTC),
path="/script.py",
)
file_service = MagicMock()
file_service.get_file_info = AsyncMock(return_value=file_info)
session_service = MagicMock()
session_service.get_session = AsyncMock(return_value=None)

result = await get_session_object(
Comment thread
gafda marked this conversation as resolved.
session_id="s1",
file_id="fid",
kind="skill",
resource_id="skill-42",
version=3,
file_service=file_service,
session_service=session_service,
)

assert "lastModified" in result

@pytest.mark.asyncio
async def test_returns_last_activity_for_inactive_session(self):
"""Inactive session with last_activity should use that timestamp."""
from datetime import UTC, datetime

from src.models.files import FileInfo
from src.models.session import Session, SessionStatus

file_info = FileInfo(
file_id="file-abc",
filename="data.csv",
size=42,
content_type="text/csv",
created_at=datetime(2026, 1, 1, tzinfo=UTC),
path="/data.csv",
)
file_service = MagicMock()
file_service.get_file_info = AsyncMock(return_value=file_info)

session = MagicMock()
session.status = SessionStatus.TERMINATED
session.last_activity = datetime(2026, 3, 15, 10, 30, 0, tzinfo=UTC)

session_service = MagicMock()
session_service.get_session = AsyncMock(return_value=session)

result = await get_session_object(
session_id="sess-1",
file_id="file-abc",
kind="user",
resource_id="user-123",
version=None,
file_service=file_service,
session_service=session_service,
)

assert result["lastModified"] == "2026-03-15T10:30:00.000Z"
Loading