letta-nightly 0.9.0.dev20250725104508__py3-none-any.whl → 0.9.1.dev20250727063635__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- letta/__init__.py +1 -1
- letta/agents/base_agent.py +1 -1
- letta/agents/letta_agent.py +6 -0
- letta/helpers/datetime_helpers.py +1 -1
- letta/helpers/json_helpers.py +1 -1
- letta/orm/agent.py +2 -3
- letta/orm/agents_tags.py +1 -0
- letta/orm/block.py +2 -2
- letta/orm/group.py +2 -2
- letta/orm/identity.py +3 -4
- letta/orm/mcp_oauth.py +62 -0
- letta/orm/step.py +2 -4
- letta/schemas/agent_file.py +31 -5
- letta/schemas/block.py +3 -0
- letta/schemas/enums.py +4 -0
- letta/schemas/group.py +3 -0
- letta/schemas/mcp.py +70 -0
- letta/schemas/memory.py +35 -0
- letta/schemas/message.py +98 -91
- letta/schemas/providers/openai.py +1 -1
- letta/server/rest_api/app.py +19 -21
- letta/server/rest_api/middleware/__init__.py +4 -0
- letta/server/rest_api/middleware/check_password.py +24 -0
- letta/server/rest_api/middleware/profiler_context.py +25 -0
- letta/server/rest_api/routers/v1/blocks.py +2 -0
- letta/server/rest_api/routers/v1/groups.py +1 -1
- letta/server/rest_api/routers/v1/sources.py +26 -0
- letta/server/rest_api/routers/v1/tools.py +224 -23
- letta/services/agent_manager.py +15 -9
- letta/services/agent_serialization_manager.py +84 -3
- letta/services/block_manager.py +4 -0
- letta/services/file_manager.py +23 -13
- letta/services/file_processor/file_processor.py +12 -10
- letta/services/mcp/base_client.py +20 -28
- letta/services/mcp/oauth_utils.py +433 -0
- letta/services/mcp/sse_client.py +12 -1
- letta/services/mcp/streamable_http_client.py +17 -5
- letta/services/mcp/types.py +9 -0
- letta/services/mcp_manager.py +304 -42
- letta/services/provider_manager.py +2 -2
- letta/services/tool_executor/tool_executor.py +6 -2
- letta/services/tool_manager.py +8 -4
- letta/services/tool_sandbox/base.py +3 -3
- letta/services/tool_sandbox/e2b_sandbox.py +1 -1
- letta/services/tool_sandbox/local_sandbox.py +16 -9
- letta/settings.py +11 -1
- letta/system.py +1 -1
- letta/templates/template_helper.py +25 -1
- letta/utils.py +19 -35
- {letta_nightly-0.9.0.dev20250725104508.dist-info → letta_nightly-0.9.1.dev20250727063635.dist-info}/METADATA +3 -2
- {letta_nightly-0.9.0.dev20250725104508.dist-info → letta_nightly-0.9.1.dev20250727063635.dist-info}/RECORD +54 -49
- {letta_nightly-0.9.0.dev20250725104508.dist-info → letta_nightly-0.9.1.dev20250727063635.dist-info}/LICENSE +0 -0
- {letta_nightly-0.9.0.dev20250725104508.dist-info → letta_nightly-0.9.1.dev20250727063635.dist-info}/WHEEL +0 -0
- {letta_nightly-0.9.0.dev20250725104508.dist-info → letta_nightly-0.9.1.dev20250727063635.dist-info}/entry_points.txt +0 -0
letta/schemas/message.py
CHANGED
@@ -41,7 +41,7 @@ from letta.schemas.letta_message_content import (
|
|
41
41
|
get_letta_message_content_union_str_json_schema,
|
42
42
|
)
|
43
43
|
from letta.system import unpack_message
|
44
|
-
from letta.utils import parse_json
|
44
|
+
from letta.utils import parse_json, validate_function_response
|
45
45
|
|
46
46
|
|
47
47
|
def add_inner_thoughts_to_tool_call(
|
@@ -251,10 +251,10 @@ class Message(BaseMessage):
|
|
251
251
|
include_err: Optional[bool] = None,
|
252
252
|
) -> List[LettaMessage]:
|
253
253
|
"""Convert message object (in DB format) to the style used by the original Letta API"""
|
254
|
-
messages = []
|
255
254
|
|
255
|
+
# TODO (cliandy): break this into more manageable pieces
|
256
256
|
if self.role == MessageRole.assistant:
|
257
|
-
|
257
|
+
messages = []
|
258
258
|
# Handle reasoning
|
259
259
|
if self.content:
|
260
260
|
# Check for ReACT-style COT inside of TextContent
|
@@ -348,7 +348,7 @@ class Message(BaseMessage):
|
|
348
348
|
# We need to unpack the actual message contents from the function call
|
349
349
|
try:
|
350
350
|
func_args = parse_json(tool_call.function.arguments)
|
351
|
-
message_string = func_args[assistant_message_tool_kwarg]
|
351
|
+
message_string = validate_function_response(func_args[assistant_message_tool_kwarg], 0, truncate=False)
|
352
352
|
except KeyError:
|
353
353
|
raise ValueError(f"Function call {tool_call.function.name} missing {assistant_message_tool_kwarg} argument")
|
354
354
|
messages.append(
|
@@ -380,99 +380,106 @@ class Message(BaseMessage):
|
|
380
380
|
is_err=self.is_err,
|
381
381
|
)
|
382
382
|
)
|
383
|
-
elif self.role == MessageRole.tool:
|
384
|
-
# This is type ToolReturnMessage
|
385
|
-
# Try to interpret the function return, recall that this is how we packaged:
|
386
|
-
# def package_function_response(was_success, response_string, timestamp=None):
|
387
|
-
# formatted_time = get_local_time() if timestamp is None else timestamp
|
388
|
-
# packaged_message = {
|
389
|
-
# "status": "OK" if was_success else "Failed",
|
390
|
-
# "message": response_string,
|
391
|
-
# "time": formatted_time,
|
392
|
-
# }
|
393
|
-
if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
|
394
|
-
text_content = self.content[0].text
|
395
|
-
else:
|
396
|
-
raise ValueError(f"Invalid tool return (no text object on message): {self.content}")
|
397
383
|
|
398
|
-
|
399
|
-
|
400
|
-
text_content = str(function_return.get("message", text_content))
|
401
|
-
status = function_return["status"]
|
402
|
-
if status == "OK":
|
403
|
-
status_enum = "success"
|
404
|
-
elif status == "Failed":
|
405
|
-
status_enum = "error"
|
406
|
-
else:
|
407
|
-
raise ValueError(f"Invalid status: {status}")
|
408
|
-
except json.JSONDecodeError:
|
409
|
-
raise ValueError(f"Failed to decode function return: {text_content}")
|
410
|
-
assert self.tool_call_id is not None
|
411
|
-
messages.append(
|
412
|
-
# TODO make sure this is what the API returns
|
413
|
-
# function_return may not match exactly...
|
414
|
-
ToolReturnMessage(
|
415
|
-
id=self.id,
|
416
|
-
date=self.created_at,
|
417
|
-
tool_return=text_content,
|
418
|
-
status=self.tool_returns[0].status if self.tool_returns else status_enum,
|
419
|
-
tool_call_id=self.tool_call_id,
|
420
|
-
stdout=self.tool_returns[0].stdout if self.tool_returns else None,
|
421
|
-
stderr=self.tool_returns[0].stderr if self.tool_returns else None,
|
422
|
-
name=self.name,
|
423
|
-
otid=Message.generate_otid_from_id(self.id, len(messages)),
|
424
|
-
sender_id=self.sender_id,
|
425
|
-
step_id=self.step_id,
|
426
|
-
is_err=self.is_err,
|
427
|
-
)
|
428
|
-
)
|
384
|
+
elif self.role == MessageRole.tool:
|
385
|
+
messages = [self._convert_tool_message()]
|
429
386
|
elif self.role == MessageRole.user:
|
430
|
-
|
431
|
-
if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
|
432
|
-
text_content = self.content[0].text
|
433
|
-
elif self.content:
|
434
|
-
text_content = self.content
|
435
|
-
else:
|
436
|
-
raise ValueError(f"Invalid user message (no text object on message): {self.content}")
|
437
|
-
|
438
|
-
message = unpack_message(text_content)
|
439
|
-
messages.append(
|
440
|
-
UserMessage(
|
441
|
-
id=self.id,
|
442
|
-
date=self.created_at,
|
443
|
-
content=message,
|
444
|
-
name=self.name,
|
445
|
-
otid=self.otid,
|
446
|
-
sender_id=self.sender_id,
|
447
|
-
step_id=self.step_id,
|
448
|
-
is_err=self.is_err,
|
449
|
-
)
|
450
|
-
)
|
387
|
+
messages = [self._convert_user_message()]
|
451
388
|
elif self.role == MessageRole.system:
|
452
|
-
|
453
|
-
if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
|
454
|
-
text_content = self.content[0].text
|
455
|
-
else:
|
456
|
-
raise ValueError(f"Invalid system message (no text object on system): {self.content}")
|
457
|
-
|
458
|
-
messages.append(
|
459
|
-
SystemMessage(
|
460
|
-
id=self.id,
|
461
|
-
date=self.created_at,
|
462
|
-
content=text_content,
|
463
|
-
name=self.name,
|
464
|
-
otid=self.otid,
|
465
|
-
sender_id=self.sender_id,
|
466
|
-
step_id=self.step_id,
|
467
|
-
)
|
468
|
-
)
|
389
|
+
messages = [self._convert_system_message()]
|
469
390
|
else:
|
470
|
-
raise ValueError(self.role)
|
391
|
+
raise ValueError(f"Unknown role: {self.role}")
|
392
|
+
|
393
|
+
return messages[::-1] if reverse else messages
|
471
394
|
|
472
|
-
|
473
|
-
|
395
|
+
def _convert_tool_message(self) -> ToolReturnMessage:
|
396
|
+
"""Convert tool role message to ToolReturnMessage
|
474
397
|
|
475
|
-
return
|
398
|
+
the tool return is packaged as follows:
|
399
|
+
packaged_message = {
|
400
|
+
"status": "OK" if was_success else "Failed",
|
401
|
+
"message": response_string,
|
402
|
+
"time": formatted_time,
|
403
|
+
}
|
404
|
+
"""
|
405
|
+
if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
|
406
|
+
text_content = self.content[0].text
|
407
|
+
else:
|
408
|
+
raise ValueError(f"Invalid tool return (no text object on message): {self.content}")
|
409
|
+
|
410
|
+
try:
|
411
|
+
function_return = parse_json(text_content)
|
412
|
+
message_text = str(function_return.get("message", text_content))
|
413
|
+
status = self._parse_tool_status(function_return["status"])
|
414
|
+
except json.JSONDecodeError:
|
415
|
+
raise ValueError(f"Failed to decode function return: {text_content}")
|
416
|
+
|
417
|
+
assert self.tool_call_id is not None
|
418
|
+
|
419
|
+
return ToolReturnMessage(
|
420
|
+
id=self.id,
|
421
|
+
date=self.created_at,
|
422
|
+
tool_return=message_text,
|
423
|
+
status=self.tool_returns[0].status if self.tool_returns else status,
|
424
|
+
tool_call_id=self.tool_call_id,
|
425
|
+
stdout=self.tool_returns[0].stdout if self.tool_returns else None,
|
426
|
+
stderr=self.tool_returns[0].stderr if self.tool_returns else None,
|
427
|
+
name=self.name,
|
428
|
+
otid=Message.generate_otid_from_id(self.id, 0),
|
429
|
+
sender_id=self.sender_id,
|
430
|
+
step_id=self.step_id,
|
431
|
+
is_err=self.is_err,
|
432
|
+
)
|
433
|
+
|
434
|
+
@staticmethod
|
435
|
+
def _parse_tool_status(status: str) -> Literal["success", "error"]:
|
436
|
+
"""Convert tool status string to enum value"""
|
437
|
+
if status == "OK":
|
438
|
+
return "success"
|
439
|
+
elif status == "Failed":
|
440
|
+
return "error"
|
441
|
+
else:
|
442
|
+
raise ValueError(f"Invalid status: {status}")
|
443
|
+
|
444
|
+
def _convert_user_message(self) -> UserMessage:
|
445
|
+
"""Convert user role message to UserMessage"""
|
446
|
+
# Extract text content
|
447
|
+
if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
|
448
|
+
text_content = self.content[0].text
|
449
|
+
elif self.content:
|
450
|
+
text_content = self.content
|
451
|
+
else:
|
452
|
+
raise ValueError(f"Invalid user message (no text object on message): {self.content}")
|
453
|
+
|
454
|
+
message = unpack_message(text_content)
|
455
|
+
|
456
|
+
return UserMessage(
|
457
|
+
id=self.id,
|
458
|
+
date=self.created_at,
|
459
|
+
content=message,
|
460
|
+
name=self.name,
|
461
|
+
otid=self.otid,
|
462
|
+
sender_id=self.sender_id,
|
463
|
+
step_id=self.step_id,
|
464
|
+
is_err=self.is_err,
|
465
|
+
)
|
466
|
+
|
467
|
+
def _convert_system_message(self) -> SystemMessage:
|
468
|
+
"""Convert system role message to SystemMessage"""
|
469
|
+
if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
|
470
|
+
text_content = self.content[0].text
|
471
|
+
else:
|
472
|
+
raise ValueError(f"Invalid system message (no text object on system): {self.content}")
|
473
|
+
|
474
|
+
return SystemMessage(
|
475
|
+
id=self.id,
|
476
|
+
date=self.created_at,
|
477
|
+
content=text_content,
|
478
|
+
name=self.name,
|
479
|
+
otid=self.otid,
|
480
|
+
sender_id=self.sender_id,
|
481
|
+
step_id=self.step_id,
|
482
|
+
)
|
476
483
|
|
477
484
|
@staticmethod
|
478
485
|
def dict_to_message(
|
@@ -202,7 +202,7 @@ class OpenAIProvider(Provider):
|
|
202
202
|
if model_type not in ["text->embedding"]:
|
203
203
|
continue
|
204
204
|
else:
|
205
|
-
logger.
|
205
|
+
logger.debug(
|
206
206
|
f"Skipping embedding models for %s by default, as we don't assume embeddings are supported."
|
207
207
|
"Please open an issue on GitHub if support is required.",
|
208
208
|
self.base_url,
|
letta/server/rest_api/app.py
CHANGED
@@ -12,7 +12,6 @@ from typing import Optional
|
|
12
12
|
import uvicorn
|
13
13
|
from fastapi import FastAPI, Request
|
14
14
|
from fastapi.responses import JSONResponse
|
15
|
-
from starlette.middleware.base import BaseHTTPMiddleware
|
16
15
|
from starlette.middleware.cors import CORSMiddleware
|
17
16
|
|
18
17
|
from letta.__init__ import __version__ as letta_version
|
@@ -35,6 +34,7 @@ from letta.server.db import db_registry
|
|
35
34
|
# NOTE(charles): these are extra routes that are not part of v1 but we still need to mount to pass tests
|
36
35
|
from letta.server.rest_api.auth.index import setup_auth_router # TODO: probably remove right?
|
37
36
|
from letta.server.rest_api.interface import StreamingServerInterface
|
37
|
+
from letta.server.rest_api.middleware import CheckPasswordMiddleware, ProfilerContextMiddleware
|
38
38
|
from letta.server.rest_api.routers.openai.chat_completions.chat_completions import router as openai_chat_completions_router
|
39
39
|
from letta.server.rest_api.routers.v1 import ROUTERS as v1_routes
|
40
40
|
from letta.server.rest_api.routers.v1.organizations import router as organizations_router
|
@@ -42,7 +42,7 @@ from letta.server.rest_api.routers.v1.users import router as users_router # TOD
|
|
42
42
|
from letta.server.rest_api.static_files import mount_static_files
|
43
43
|
from letta.server.rest_api.utils import SENTRY_ENABLED
|
44
44
|
from letta.server.server import SyncServer
|
45
|
-
from letta.settings import settings
|
45
|
+
from letta.settings import settings, telemetry_settings
|
46
46
|
|
47
47
|
if SENTRY_ENABLED:
|
48
48
|
import sentry_sdk
|
@@ -92,24 +92,6 @@ def generate_password():
|
|
92
92
|
random_password = os.getenv("LETTA_SERVER_PASSWORD") or generate_password()
|
93
93
|
|
94
94
|
|
95
|
-
class CheckPasswordMiddleware(BaseHTTPMiddleware):
|
96
|
-
async def dispatch(self, request, call_next):
|
97
|
-
# Exclude health check endpoint from password protection
|
98
|
-
if request.url.path in {"/v1/health", "/v1/health/", "/latest/health/"}:
|
99
|
-
return await call_next(request)
|
100
|
-
|
101
|
-
if (
|
102
|
-
request.headers.get("X-BARE-PASSWORD") == f"password {random_password}"
|
103
|
-
or request.headers.get("Authorization") == f"Bearer {random_password}"
|
104
|
-
):
|
105
|
-
return await call_next(request)
|
106
|
-
|
107
|
-
return JSONResponse(
|
108
|
-
content={"detail": "Unauthorized"},
|
109
|
-
status_code=401,
|
110
|
-
)
|
111
|
-
|
112
|
-
|
113
95
|
@asynccontextmanager
|
114
96
|
async def lifespan(app_: FastAPI):
|
115
97
|
"""
|
@@ -117,6 +99,19 @@ async def lifespan(app_: FastAPI):
|
|
117
99
|
"""
|
118
100
|
worker_id = os.getpid()
|
119
101
|
|
102
|
+
if telemetry_settings.profiler:
|
103
|
+
try:
|
104
|
+
import googlecloudprofiler
|
105
|
+
|
106
|
+
googlecloudprofiler.start(
|
107
|
+
service="memgpt-server",
|
108
|
+
service_version=str(letta_version),
|
109
|
+
verbose=3,
|
110
|
+
)
|
111
|
+
logger.info("Profiler started.")
|
112
|
+
except Exception as exc:
|
113
|
+
logger.info("Profiler not enabled: %", exc)
|
114
|
+
|
120
115
|
logger.info(f"[Worker {worker_id}] Starting lifespan initialization")
|
121
116
|
logger.info(f"[Worker {worker_id}] Initializing database connections")
|
122
117
|
db_registry.initialize_sync()
|
@@ -283,11 +278,14 @@ def create_application() -> "FastAPI":
|
|
283
278
|
|
284
279
|
if (os.getenv("LETTA_SERVER_SECURE") == "true") or "--secure" in sys.argv:
|
285
280
|
print(f"▶ Using secure mode with password: {random_password}")
|
286
|
-
app.add_middleware(CheckPasswordMiddleware)
|
281
|
+
app.add_middleware(CheckPasswordMiddleware, password=random_password)
|
287
282
|
|
288
283
|
# Add reverse proxy middleware to handle X-Forwarded-* headers
|
289
284
|
# app.add_middleware(ReverseProxyMiddleware, base_path=settings.server_base_path)
|
290
285
|
|
286
|
+
if telemetry_settings.profiler:
|
287
|
+
app.add_middleware(ProfilerContextMiddleware)
|
288
|
+
|
291
289
|
app.add_middleware(
|
292
290
|
CORSMiddleware,
|
293
291
|
allow_origins=settings.cors_origins,
|
@@ -0,0 +1,24 @@
|
|
1
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
2
|
+
from starlette.responses import JSONResponse
|
3
|
+
|
4
|
+
|
5
|
+
class CheckPasswordMiddleware(BaseHTTPMiddleware):
|
6
|
+
def __init__(self, app, password: str):
|
7
|
+
super().__init__(app)
|
8
|
+
self.password = password
|
9
|
+
|
10
|
+
async def dispatch(self, request, call_next):
|
11
|
+
# Exclude health check endpoint from password protection
|
12
|
+
if request.url.path in {"/v1/health", "/v1/health/", "/latest/health/"}:
|
13
|
+
return await call_next(request)
|
14
|
+
|
15
|
+
if (
|
16
|
+
request.headers.get("X-BARE-PASSWORD") == f"password {self.password}"
|
17
|
+
or request.headers.get("Authorization") == f"Bearer {self.password}"
|
18
|
+
):
|
19
|
+
return await call_next(request)
|
20
|
+
|
21
|
+
return JSONResponse(
|
22
|
+
content={"detail": "Unauthorized"},
|
23
|
+
status_code=401,
|
24
|
+
)
|
@@ -0,0 +1,25 @@
|
|
1
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
2
|
+
|
3
|
+
|
4
|
+
class ProfilerContextMiddleware(BaseHTTPMiddleware):
|
5
|
+
"""Middleware to set context if using profiler. Currently just uses google-cloud-profiler."""
|
6
|
+
|
7
|
+
async def dispatch(self, request, call_next):
|
8
|
+
ctx = None
|
9
|
+
if request.url.path in {"/v1/health", "/v1/health/"}:
|
10
|
+
return await call_next(request)
|
11
|
+
try:
|
12
|
+
labels = {
|
13
|
+
"method": request.method,
|
14
|
+
"path": request.url.path,
|
15
|
+
"endpoint": request.url.path,
|
16
|
+
}
|
17
|
+
import googlecloudprofiler
|
18
|
+
|
19
|
+
ctx = googlecloudprofiler.context.set_labels(**labels)
|
20
|
+
except:
|
21
|
+
return await call_next(request)
|
22
|
+
if ctx:
|
23
|
+
with ctx:
|
24
|
+
return await call_next(request)
|
25
|
+
return await call_next(request)
|
@@ -22,6 +22,7 @@ async def list_blocks(
|
|
22
22
|
name: Optional[str] = Query(None, description="Name of the block"),
|
23
23
|
identity_id: Optional[str] = Query(None, description="Search agents by identifier id"),
|
24
24
|
identifier_keys: Optional[List[str]] = Query(None, description="Search agents by identifier keys"),
|
25
|
+
project_id: Optional[str] = Query(None, description="Search blocks by project id"),
|
25
26
|
limit: Optional[int] = Query(50, description="Number of blocks to return"),
|
26
27
|
server: SyncServer = Depends(get_letta_server),
|
27
28
|
actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
|
@@ -34,6 +35,7 @@ async def list_blocks(
|
|
34
35
|
template_name=name,
|
35
36
|
identity_id=identity_id,
|
36
37
|
identifier_keys=identifier_keys,
|
38
|
+
project_id=project_id,
|
37
39
|
limit=limit,
|
38
40
|
)
|
39
41
|
|
@@ -31,12 +31,12 @@ def list_groups(
|
|
31
31
|
"""
|
32
32
|
actor = server.user_manager.get_user_or_default(user_id=actor_id)
|
33
33
|
return server.group_manager.list_groups(
|
34
|
+
actor=actor,
|
34
35
|
project_id=project_id,
|
35
36
|
manager_type=manager_type,
|
36
37
|
before=before,
|
37
38
|
after=after,
|
38
39
|
limit=limit,
|
39
|
-
actor=actor,
|
40
40
|
)
|
41
41
|
|
42
42
|
|
@@ -2,6 +2,7 @@ import asyncio
|
|
2
2
|
import mimetypes
|
3
3
|
import os
|
4
4
|
import tempfile
|
5
|
+
from datetime import datetime, timedelta, timezone
|
5
6
|
from pathlib import Path
|
6
7
|
from typing import List, Optional
|
7
8
|
|
@@ -393,6 +394,31 @@ async def get_file_metadata(
|
|
393
394
|
if file_metadata.source_id != source_id:
|
394
395
|
raise HTTPException(status_code=404, detail=f"File with id={file_id} not found in source {source_id}.")
|
395
396
|
|
397
|
+
# Check for timeout if status is not terminal
|
398
|
+
if not file_metadata.processing_status.is_terminal_state():
|
399
|
+
if file_metadata.created_at:
|
400
|
+
# Handle timezone differences between PostgreSQL (timezone-aware) and SQLite (timezone-naive)
|
401
|
+
if settings.letta_pg_uri_no_default:
|
402
|
+
# PostgreSQL: both datetimes are timezone-aware
|
403
|
+
timeout_threshold = datetime.now(timezone.utc) - timedelta(minutes=settings.file_processing_timeout_minutes)
|
404
|
+
file_created_at = file_metadata.created_at
|
405
|
+
else:
|
406
|
+
# SQLite: both datetimes should be timezone-naive
|
407
|
+
timeout_threshold = datetime.utcnow() - timedelta(minutes=settings.file_processing_timeout_minutes)
|
408
|
+
file_created_at = file_metadata.created_at
|
409
|
+
|
410
|
+
if file_created_at < timeout_threshold:
|
411
|
+
# Move file to error status with timeout message
|
412
|
+
timeout_message = settings.file_processing_timeout_error_message.format(settings.file_processing_timeout_minutes)
|
413
|
+
try:
|
414
|
+
file_metadata = await server.file_manager.update_file_status(
|
415
|
+
file_id=file_metadata.id, actor=actor, processing_status=FileProcessingStatus.ERROR, error_message=timeout_message
|
416
|
+
)
|
417
|
+
except ValueError as e:
|
418
|
+
# state transition was blocked - log it but don't fail the request
|
419
|
+
logger.warning(f"Could not update file to timeout error state: {str(e)}")
|
420
|
+
# continue with existing file_metadata
|
421
|
+
|
396
422
|
if should_use_pinecone() and file_metadata.processing_status == FileProcessingStatus.EMBEDDING:
|
397
423
|
ids = await list_pinecone_index_for_files(file_id=file_id, actor=actor)
|
398
424
|
logger.info(
|