letta-nightly 0.6.27.dev20250220104103__py3-none-any.whl → 0.6.29.dev20250221033538__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.

Potentially problematic release.


This version of letta-nightly might be problematic. Click here for more details.

Files changed (66) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +19 -2
  3. letta/client/client.py +2 -0
  4. letta/constants.py +2 -0
  5. letta/functions/schema_generator.py +6 -6
  6. letta/helpers/converters.py +153 -0
  7. letta/helpers/tool_rule_solver.py +11 -1
  8. letta/llm_api/anthropic.py +10 -5
  9. letta/llm_api/aws_bedrock.py +1 -1
  10. letta/llm_api/deepseek.py +303 -0
  11. letta/llm_api/helpers.py +20 -10
  12. letta/llm_api/llm_api_tools.py +85 -2
  13. letta/llm_api/openai.py +16 -1
  14. letta/local_llm/chat_completion_proxy.py +15 -2
  15. letta/local_llm/lmstudio/api.py +75 -1
  16. letta/orm/__init__.py +2 -0
  17. letta/orm/agent.py +11 -4
  18. letta/orm/custom_columns.py +31 -110
  19. letta/orm/identities_agents.py +13 -0
  20. letta/orm/identity.py +60 -0
  21. letta/orm/organization.py +2 -0
  22. letta/orm/sqlalchemy_base.py +4 -0
  23. letta/schemas/agent.py +11 -1
  24. letta/schemas/identity.py +67 -0
  25. letta/schemas/llm_config.py +2 -0
  26. letta/schemas/message.py +1 -1
  27. letta/schemas/openai/chat_completion_response.py +2 -0
  28. letta/schemas/providers.py +72 -1
  29. letta/schemas/tool_rule.py +9 -1
  30. letta/serialize_schemas/__init__.py +1 -0
  31. letta/serialize_schemas/agent.py +36 -0
  32. letta/serialize_schemas/base.py +12 -0
  33. letta/serialize_schemas/custom_fields.py +69 -0
  34. letta/serialize_schemas/message.py +15 -0
  35. letta/server/db.py +111 -0
  36. letta/server/rest_api/app.py +8 -0
  37. letta/server/rest_api/chat_completions_interface.py +45 -21
  38. letta/server/rest_api/interface.py +114 -9
  39. letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +98 -24
  40. letta/server/rest_api/routers/v1/__init__.py +2 -0
  41. letta/server/rest_api/routers/v1/agents.py +14 -3
  42. letta/server/rest_api/routers/v1/identities.py +121 -0
  43. letta/server/rest_api/utils.py +183 -4
  44. letta/server/server.py +23 -117
  45. letta/services/agent_manager.py +53 -6
  46. letta/services/block_manager.py +1 -1
  47. letta/services/identity_manager.py +156 -0
  48. letta/services/job_manager.py +1 -1
  49. letta/services/message_manager.py +1 -1
  50. letta/services/organization_manager.py +1 -1
  51. letta/services/passage_manager.py +1 -1
  52. letta/services/provider_manager.py +1 -1
  53. letta/services/sandbox_config_manager.py +1 -1
  54. letta/services/source_manager.py +1 -1
  55. letta/services/step_manager.py +1 -1
  56. letta/services/tool_manager.py +1 -1
  57. letta/services/user_manager.py +1 -1
  58. letta/settings.py +3 -0
  59. letta/streaming_interface.py +6 -2
  60. letta/tracing.py +205 -0
  61. letta/utils.py +4 -0
  62. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.29.dev20250221033538.dist-info}/METADATA +9 -2
  63. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.29.dev20250221033538.dist-info}/RECORD +66 -52
  64. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.29.dev20250221033538.dist-info}/LICENSE +0 -0
  65. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.29.dev20250221033538.dist-info}/WHEEL +0 -0
  66. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.29.dev20250221033538.dist-info}/entry_points.txt +0 -0
@@ -1,21 +1,30 @@
1
1
  import asyncio
2
- from typing import TYPE_CHECKING, Iterable, List, Optional, Union, cast
2
+ from typing import TYPE_CHECKING, List, Optional, Union
3
3
 
4
+ import httpx
5
+ import openai
4
6
  from fastapi import APIRouter, Body, Depends, Header, HTTPException
5
7
  from fastapi.responses import StreamingResponse
6
- from openai.types.chat import ChatCompletionMessageParam
7
8
  from openai.types.chat.completion_create_params import CompletionCreateParams
9
+ from starlette.concurrency import run_in_threadpool
8
10
 
9
11
  from letta.agent import Agent
10
12
  from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
11
13
  from letta.log import get_logger
12
- from letta.schemas.message import MessageCreate
13
- from letta.schemas.openai.chat_completion_response import Message
14
+ from letta.schemas.message import Message, MessageCreate
14
15
  from letta.schemas.user import User
15
16
  from letta.server.rest_api.chat_completions_interface import ChatCompletionsStreamingInterface
16
17
 
17
18
  # TODO this belongs in a controller!
18
- from letta.server.rest_api.utils import get_letta_server, sse_async_generator
19
+ from letta.server.rest_api.utils import (
20
+ convert_letta_messages_to_openai,
21
+ create_assistant_message_from_openai_response,
22
+ create_user_message,
23
+ get_letta_server,
24
+ get_messages_from_completion_request,
25
+ sse_async_generator,
26
+ )
27
+ from letta.settings import model_settings
19
28
 
20
29
  if TYPE_CHECKING:
21
30
  from letta.server.server import SyncServer
@@ -25,6 +34,88 @@ router = APIRouter(prefix="/v1", tags=["chat_completions"])
25
34
  logger = get_logger(__name__)
26
35
 
27
36
 
37
+ @router.post(
38
+ "/fast/chat/completions",
39
+ response_model=None,
40
+ operation_id="create_fast_chat_completions",
41
+ responses={
42
+ 200: {
43
+ "description": "Successful response",
44
+ "content": {
45
+ "text/event-stream": {"description": "Server-Sent Events stream"},
46
+ },
47
+ }
48
+ },
49
+ )
50
+ async def create_fast_chat_completions(
51
+ completion_request: CompletionCreateParams = Body(...),
52
+ server: "SyncServer" = Depends(get_letta_server),
53
+ user_id: Optional[str] = Header(None, alias="user_id"),
54
+ ):
55
+ # TODO: This is necessary, we need to factor out CompletionCreateParams due to weird behavior
56
+ agent_id = str(completion_request.get("user", None))
57
+ if agent_id is None:
58
+ error_msg = "Must pass agent_id in the 'user' field"
59
+ logger.error(error_msg)
60
+ raise HTTPException(status_code=400, detail=error_msg)
61
+ model = completion_request.get("model")
62
+
63
+ actor = server.user_manager.get_user_or_default(user_id=user_id)
64
+ client = openai.AsyncClient(
65
+ api_key=model_settings.openai_api_key,
66
+ max_retries=0,
67
+ http_client=httpx.AsyncClient(
68
+ timeout=httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
69
+ follow_redirects=True,
70
+ limits=httpx.Limits(
71
+ max_connections=50,
72
+ max_keepalive_connections=50,
73
+ keepalive_expiry=120,
74
+ ),
75
+ ),
76
+ )
77
+
78
+ # Magic message manipulating
79
+ input_message = get_messages_from_completion_request(completion_request)[-1]
80
+ completion_request.pop("messages")
81
+
82
+ # Get in context messages
83
+ in_context_messages = server.agent_manager.get_in_context_messages(agent_id=agent_id, actor=actor)
84
+ openai_dict_in_context_messages = convert_letta_messages_to_openai(in_context_messages)
85
+ openai_dict_in_context_messages.append(input_message)
86
+
87
+ async def event_stream():
88
+ # TODO: Factor this out into separate interface
89
+ response_accumulator = []
90
+
91
+ stream = await client.chat.completions.create(**completion_request, messages=openai_dict_in_context_messages)
92
+
93
+ async with stream:
94
+ async for chunk in stream:
95
+ if chunk.choices and chunk.choices[0].delta.content:
96
+ # TODO: This does not support tool calling right now
97
+ response_accumulator.append(chunk.choices[0].delta.content)
98
+ yield f"data: {chunk.model_dump_json()}\n\n"
99
+
100
+ # Construct messages
101
+ user_message = create_user_message(input_message=input_message, agent_id=agent_id, actor=actor)
102
+ assistant_message = create_assistant_message_from_openai_response(
103
+ response_text="".join(response_accumulator), agent_id=agent_id, model=str(model), actor=actor
104
+ )
105
+
106
+ # Persist both in one synchronous DB call, done in a threadpool
107
+ await run_in_threadpool(
108
+ server.agent_manager.append_to_in_context_messages,
109
+ [user_message, assistant_message],
110
+ agent_id=agent_id,
111
+ actor=actor,
112
+ )
113
+
114
+ yield "data: [DONE]\n\n"
115
+
116
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
117
+
118
+
28
119
  @router.post(
29
120
  "/chat/completions",
30
121
  response_model=None,
@@ -44,26 +135,8 @@ async def create_chat_completions(
44
135
  user_id: Optional[str] = Header(None, alias="user_id"),
45
136
  ):
46
137
  # Validate and process fields
47
- try:
48
- messages = list(cast(Iterable[ChatCompletionMessageParam], completion_request["messages"]))
49
- except KeyError:
50
- # Handle the case where "messages" is not present in the request
51
- raise HTTPException(status_code=400, detail="The 'messages' field is missing in the request.")
52
- except TypeError:
53
- # Handle the case where "messages" is not iterable
54
- raise HTTPException(status_code=400, detail="The 'messages' field must be an iterable.")
55
- except Exception as e:
56
- # Catch any other unexpected errors and include the exception message
57
- raise HTTPException(status_code=400, detail=f"An error occurred while processing 'messages': {str(e)}")
58
-
59
- if messages[-1]["role"] != "user":
60
- logger.error(f"The last message does not have a `user` role: {messages}")
61
- raise HTTPException(status_code=400, detail="'messages[-1].role' must be a 'user'")
62
-
138
+ messages = get_messages_from_completion_request(completion_request)
63
139
  input_message = messages[-1]
64
- if not isinstance(input_message["content"], str):
65
- logger.error(f"The input message does not have valid content: {input_message}")
66
- raise HTTPException(status_code=400, detail="'messages[-1].content' must be a 'string'")
67
140
 
68
141
  # Process remaining fields
69
142
  if not completion_request["stream"]:
@@ -138,6 +211,7 @@ async def send_message_to_agent_chat_completions(
138
211
  agent_id=letta_agent.agent_state.id,
139
212
  messages=messages,
140
213
  interface=streaming_interface,
214
+ put_inner_thoughts_first=False,
141
215
  )
142
216
  )
143
217
 
@@ -1,6 +1,7 @@
1
1
  from letta.server.rest_api.routers.v1.agents import router as agents_router
2
2
  from letta.server.rest_api.routers.v1.blocks import router as blocks_router
3
3
  from letta.server.rest_api.routers.v1.health import router as health_router
4
+ from letta.server.rest_api.routers.v1.identities import router as identities_router
4
5
  from letta.server.rest_api.routers.v1.jobs import router as jobs_router
5
6
  from letta.server.rest_api.routers.v1.llms import router as llm_router
6
7
  from letta.server.rest_api.routers.v1.providers import router as providers_router
@@ -15,6 +16,7 @@ ROUTERS = [
15
16
  tools_router,
16
17
  sources_router,
17
18
  agents_router,
19
+ identities_router,
18
20
  llm_router,
19
21
  blocks_router,
20
22
  jobs_router,
@@ -1,3 +1,4 @@
1
+ import traceback
1
2
  from datetime import datetime
2
3
  from typing import Annotated, List, Optional
3
4
 
@@ -23,6 +24,7 @@ from letta.schemas.tool import Tool
23
24
  from letta.schemas.user import User
24
25
  from letta.server.rest_api.utils import get_letta_server
25
26
  from letta.server.server import SyncServer
27
+ from letta.tracing import trace_method
26
28
 
27
29
  # These can be forward refs, but because Fastapi needs them at runtime the must be imported normally
28
30
 
@@ -50,6 +52,7 @@ def list_agents(
50
52
  project_id: Optional[str] = Query(None, description="Search agents by project id"),
51
53
  template_id: Optional[str] = Query(None, description="Search agents by template id"),
52
54
  base_template_id: Optional[str] = Query(None, description="Search agents by base template id"),
55
+ identifier_keys: Optional[List[str]] = Query(None, description="Search agents by identifier keys"),
53
56
  ):
54
57
  """
55
58
  List all agents associated with a given user.
@@ -78,6 +81,7 @@ def list_agents(
78
81
  query_text=query_text,
79
82
  tags=tags,
80
83
  match_all_tags=match_all_tags,
84
+ identifier_keys=identifier_keys,
81
85
  **kwargs,
82
86
  )
83
87
  return agents
@@ -111,12 +115,17 @@ def create_agent(
111
115
  agent: CreateAgentRequest = Body(...),
112
116
  server: "SyncServer" = Depends(get_letta_server),
113
117
  user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
118
+ x_project: Optional[str] = Header(None, alias="X-Project"), # Only handled by next js middleware
114
119
  ):
115
120
  """
116
121
  Create a new agent with the specified configuration.
117
122
  """
118
- actor = server.user_manager.get_user_or_default(user_id=user_id)
119
- return server.create_agent(agent, actor=actor)
123
+ try:
124
+ actor = server.user_manager.get_user_or_default(user_id=user_id)
125
+ return server.create_agent(agent, actor=actor)
126
+ except Exception as e:
127
+ traceback.print_exc()
128
+ raise HTTPException(status_code=500, detail=str(e))
120
129
 
121
130
 
122
131
  @router.patch("/{agent_id}", response_model=AgentState, operation_id="modify_agent")
@@ -460,6 +469,7 @@ def modify_message(
460
469
  response_model=LettaResponse,
461
470
  operation_id="send_message",
462
471
  )
472
+ @trace_method("POST /v1/agents/{agent_id}/messages")
463
473
  async def send_message(
464
474
  agent_id: str,
465
475
  server: SyncServer = Depends(get_letta_server),
@@ -498,6 +508,7 @@ async def send_message(
498
508
  }
499
509
  },
500
510
  )
511
+ @trace_method("POST /v1/agents/{agent_id}/messages/stream")
501
512
  async def send_message_streaming(
502
513
  agent_id: str,
503
514
  server: SyncServer = Depends(get_letta_server),
@@ -509,7 +520,6 @@ async def send_message_streaming(
509
520
  This endpoint accepts a message from a user and processes it through the agent.
510
521
  It will stream the steps of the response always, and stream the tokens if 'stream_tokens' is set to True.
511
522
  """
512
-
513
523
  actor = server.user_manager.get_user_or_default(user_id=user_id)
514
524
  result = await server.send_message_to_agent(
515
525
  agent_id=agent_id,
@@ -574,6 +584,7 @@ async def process_message_background(
574
584
  response_model=Run,
575
585
  operation_id="create_agent_message_async",
576
586
  )
587
+ @trace_method("POST /v1/agents/{agent_id}/messages/async")
577
588
  async def send_message_async(
578
589
  agent_id: str,
579
590
  background_tasks: BackgroundTasks,
@@ -0,0 +1,121 @@
1
+ from typing import TYPE_CHECKING, List, Optional
2
+
3
+ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
4
+
5
+ from letta.orm.errors import NoResultFound
6
+ from letta.schemas.identity import Identity, IdentityCreate, IdentityType, IdentityUpdate
7
+ from letta.server.rest_api.utils import get_letta_server
8
+
9
+ if TYPE_CHECKING:
10
+ from letta.server.server import SyncServer
11
+
12
+ router = APIRouter(prefix="/identities", tags=["identities"])
13
+
14
+
15
+ @router.get("/", tags=["identities"], response_model=List[Identity], operation_id="list_identities")
16
+ def list_identities(
17
+ name: Optional[str] = Query(None),
18
+ project_id: Optional[str] = Query(None),
19
+ identifier_key: Optional[str] = Query(None),
20
+ identity_type: Optional[IdentityType] = Query(None),
21
+ before: Optional[str] = Query(None),
22
+ after: Optional[str] = Query(None),
23
+ limit: Optional[int] = Query(50),
24
+ server: "SyncServer" = Depends(get_letta_server),
25
+ user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
26
+ ):
27
+ """
28
+ Get a list of all identities in the database
29
+ """
30
+ try:
31
+ actor = server.user_manager.get_user_or_default(user_id=user_id)
32
+
33
+ identities = server.identity_manager.list_identities(
34
+ name=name,
35
+ project_id=project_id,
36
+ identifier_key=identifier_key,
37
+ identity_type=identity_type,
38
+ before=before,
39
+ after=after,
40
+ limit=limit,
41
+ actor=actor,
42
+ )
43
+ except HTTPException:
44
+ raise
45
+ except Exception as e:
46
+ raise HTTPException(status_code=500, detail=f"{e}")
47
+ return identities
48
+
49
+
50
+ @router.get("/{identity_id}", tags=["identities"], response_model=Identity, operation_id="retrieve_identity")
51
+ def retrieve_identity(
52
+ identity_id: str,
53
+ server: "SyncServer" = Depends(get_letta_server),
54
+ user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
55
+ ):
56
+ try:
57
+ actor = server.user_manager.get_user_or_default(user_id=user_id)
58
+ return server.identity_manager.get_identity(identity_id=identity_id, actor=actor)
59
+ except NoResultFound as e:
60
+ raise HTTPException(status_code=404, detail=str(e))
61
+
62
+
63
+ @router.post("/", tags=["identities"], response_model=Identity, operation_id="create_identity")
64
+ def create_identity(
65
+ identity: IdentityCreate = Body(...),
66
+ server: "SyncServer" = Depends(get_letta_server),
67
+ user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
68
+ x_project: Optional[str] = Header(None, alias="X-Project"), # Only handled by next js middleware
69
+ ):
70
+ try:
71
+ actor = server.user_manager.get_user_or_default(user_id=user_id)
72
+ return server.identity_manager.create_identity(identity=identity, actor=actor)
73
+ except HTTPException:
74
+ raise
75
+ except Exception as e:
76
+ raise HTTPException(status_code=500, detail=f"{e}")
77
+
78
+
79
+ @router.put("/", tags=["identities"], response_model=Identity, operation_id="upsert_identity")
80
+ def upsert_identity(
81
+ identity: IdentityCreate = Body(...),
82
+ server: "SyncServer" = Depends(get_letta_server),
83
+ user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
84
+ x_project: Optional[str] = Header(None, alias="X-Project"), # Only handled by next js middleware
85
+ ):
86
+ try:
87
+ actor = server.user_manager.get_user_or_default(user_id=user_id)
88
+ return server.identity_manager.upsert_identity(identity=identity, actor=actor)
89
+ except HTTPException:
90
+ raise
91
+ except Exception as e:
92
+ raise HTTPException(status_code=500, detail=f"{e}")
93
+
94
+
95
+ @router.patch("/{identity_id}", tags=["identities"], response_model=Identity, operation_id="update_identity")
96
+ def modify_identity(
97
+ identity_id: str,
98
+ identity: IdentityUpdate = Body(...),
99
+ server: "SyncServer" = Depends(get_letta_server),
100
+ user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
101
+ ):
102
+ try:
103
+ actor = server.user_manager.get_user_or_default(user_id=user_id)
104
+ return server.identity_manager.update_identity(identity_id=identity_id, identity=identity, actor=actor)
105
+ except HTTPException:
106
+ raise
107
+ except Exception as e:
108
+ raise HTTPException(status_code=500, detail=f"{e}")
109
+
110
+
111
+ @router.delete("/{identity_id}", tags=["identities"], operation_id="delete_identity")
112
+ def delete_identity(
113
+ identity_id: str,
114
+ server: "SyncServer" = Depends(get_letta_server),
115
+ user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
116
+ ):
117
+ """
118
+ Delete an identity by its identifier key
119
+ """
120
+ actor = server.user_manager.get_user_or_default(user_id=user_id)
121
+ server.identity_manager.delete_identity(identity_id=identity_id, actor=actor)
@@ -1,23 +1,33 @@
1
1
  import asyncio
2
2
  import json
3
3
  import os
4
+ import uuid
4
5
  import warnings
6
+ from datetime import datetime, timezone
5
7
  from enum import Enum
6
- from typing import TYPE_CHECKING, AsyncGenerator, Optional, Union
8
+ from typing import TYPE_CHECKING, AsyncGenerator, Dict, Iterable, List, Optional, Union, cast
7
9
 
8
- from fastapi import Header
10
+ import pytz
11
+ from fastapi import Header, HTTPException
12
+ from openai.types.chat import ChatCompletionMessageParam
13
+ from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall
14
+ from openai.types.chat.chat_completion_message_tool_call import Function as OpenAIFunction
15
+ from openai.types.chat.completion_create_params import CompletionCreateParams
9
16
  from pydantic import BaseModel
10
17
 
18
+ from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
11
19
  from letta.errors import ContextWindowExceededError, RateLimitExceededError
12
20
  from letta.log import get_logger
21
+ from letta.schemas.enums import MessageRole
22
+ from letta.schemas.letta_message import TextContent
23
+ from letta.schemas.message import Message
13
24
  from letta.schemas.usage import LettaUsageStatistics
25
+ from letta.schemas.user import User
14
26
  from letta.server.rest_api.interface import StreamingServerInterface
15
27
 
16
28
  if TYPE_CHECKING:
17
29
  from letta.server.server import SyncServer
18
30
 
19
- # from letta.orm.user import User
20
- # from letta.orm.utilities import get_db_session
21
31
 
22
32
  SSE_PREFIX = "data: "
23
33
  SSE_SUFFIX = "\n\n"
@@ -128,3 +138,172 @@ def log_error_to_sentry(e):
128
138
  import sentry_sdk
129
139
 
130
140
  sentry_sdk.capture_exception(e)
141
+
142
+
143
+ def create_user_message(input_message: dict, agent_id: str, actor: User) -> Message:
144
+ """
145
+ Converts a user input message into the internal structured format.
146
+ """
147
+ # Generate timestamp in the correct format
148
+ now = datetime.now(pytz.timezone("US/Pacific")).strftime("%Y-%m-%d %I:%M:%S %p %Z%z")
149
+
150
+ # Format message as structured JSON
151
+ structured_message = {"type": "user_message", "message": input_message["content"], "time": now}
152
+
153
+ # Construct the Message object
154
+ user_message = Message(
155
+ id=f"message-{uuid.uuid4()}",
156
+ role=MessageRole.user,
157
+ content=[TextContent(text=json.dumps(structured_message, indent=2))], # Store structured JSON
158
+ organization_id=actor.organization_id,
159
+ agent_id=agent_id,
160
+ model=None,
161
+ tool_calls=None,
162
+ tool_call_id=None,
163
+ created_at=datetime.now(timezone.utc),
164
+ )
165
+
166
+ return user_message
167
+
168
+
169
+ def create_assistant_message_from_openai_response(
170
+ response_text: str,
171
+ agent_id: str,
172
+ model: str,
173
+ actor: User,
174
+ ) -> Message:
175
+ """
176
+ Converts an OpenAI response into a Message that follows the internal
177
+ paradigm where LLM responses are structured as tool calls instead of content.
178
+ """
179
+ tool_call_id = str(uuid.uuid4())
180
+
181
+ # Construct the tool call with the assistant's message
182
+ tool_call = OpenAIToolCall(
183
+ id=tool_call_id,
184
+ function=OpenAIFunction(
185
+ name=DEFAULT_MESSAGE_TOOL,
186
+ arguments='{\n "' + DEFAULT_MESSAGE_TOOL_KWARG + '": ' + f'"{response_text}",\n "request_heartbeat": true\n' + "}",
187
+ ),
188
+ type="function",
189
+ )
190
+
191
+ # Construct the Message object
192
+ assistant_message = Message(
193
+ id=f"message-{uuid.uuid4()}",
194
+ role=MessageRole.assistant,
195
+ content=[],
196
+ organization_id=actor.organization_id,
197
+ agent_id=agent_id,
198
+ model=model,
199
+ tool_calls=[tool_call],
200
+ tool_call_id=None,
201
+ created_at=datetime.now(timezone.utc),
202
+ )
203
+
204
+ return assistant_message
205
+
206
+
207
+ def convert_letta_messages_to_openai(messages: List[Message]) -> List[dict]:
208
+ """
209
+ Flattens Letta's messages (with system, user, assistant, tool roles, etc.)
210
+ into standard OpenAI chat messages (system, user, assistant).
211
+
212
+ Transformation rules:
213
+ 1. Assistant + send_message tool_call => content = tool_call's "message"
214
+ 2. Tool (role=tool) referencing send_message => skip
215
+ 3. User messages might store actual text inside JSON => parse that into content
216
+ 4. System => pass through as normal
217
+ """
218
+
219
+ openai_messages = []
220
+
221
+ for msg in messages:
222
+ # 1. Assistant + 'send_message' tool_calls => flatten
223
+ if msg.role == MessageRole.assistant and msg.tool_calls:
224
+ # Find any 'send_message' tool_calls
225
+ send_message_calls = [tc for tc in msg.tool_calls if tc.function.name == "send_message"]
226
+ if send_message_calls:
227
+ # If we have multiple calls, just pick the first or merge them
228
+ # Typically there's only one.
229
+ tc = send_message_calls[0]
230
+ arguments = json.loads(tc.function.arguments)
231
+ # Extract the "message" string
232
+ extracted_text = arguments.get("message", "")
233
+
234
+ # Create a new content with the extracted text
235
+ msg = Message(
236
+ id=msg.id,
237
+ role=msg.role,
238
+ content=[TextContent(text=extracted_text)],
239
+ organization_id=msg.organization_id,
240
+ agent_id=msg.agent_id,
241
+ model=msg.model,
242
+ name=msg.name,
243
+ tool_calls=None, # no longer needed
244
+ tool_call_id=None,
245
+ created_at=msg.created_at,
246
+ )
247
+
248
+ # 2. If role=tool and it's referencing send_message => skip
249
+ if msg.role == MessageRole.tool and msg.name == "send_message":
250
+ # Usually 'tool' messages with `send_message` are just status/OK messages
251
+ # that OpenAI doesn't need to see. So skip them.
252
+ continue
253
+
254
+ # 3. User messages might store text in JSON => parse it
255
+ if msg.role == MessageRole.user:
256
+ # Example: content=[TextContent(text='{"type": "user_message","message":"Hello"}')]
257
+ # Attempt to parse JSON and extract "message"
258
+ if msg.content and msg.content[0].text.strip().startswith("{"):
259
+ try:
260
+ parsed = json.loads(msg.content[0].text)
261
+ # If there's a "message" field, use that as the content
262
+ if "message" in parsed:
263
+ actual_user_text = parsed["message"]
264
+ msg = Message(
265
+ id=msg.id,
266
+ role=msg.role,
267
+ content=[TextContent(text=actual_user_text)],
268
+ organization_id=msg.organization_id,
269
+ agent_id=msg.agent_id,
270
+ model=msg.model,
271
+ name=msg.name,
272
+ tool_calls=msg.tool_calls,
273
+ tool_call_id=msg.tool_call_id,
274
+ created_at=msg.created_at,
275
+ )
276
+ except json.JSONDecodeError:
277
+ pass # It's not JSON, leave as-is
278
+
279
+ # 4. System is left as-is (or any other role that doesn't need special handling)
280
+ #
281
+ # Finally, convert to dict using your existing method
282
+ openai_messages.append(msg.to_openai_dict())
283
+
284
+ return openai_messages
285
+
286
+
287
+ def get_messages_from_completion_request(completion_request: CompletionCreateParams) -> List[Dict]:
288
+ try:
289
+ messages = list(cast(Iterable[ChatCompletionMessageParam], completion_request["messages"]))
290
+ except KeyError:
291
+ # Handle the case where "messages" is not present in the request
292
+ raise HTTPException(status_code=400, detail="The 'messages' field is missing in the request.")
293
+ except TypeError:
294
+ # Handle the case where "messages" is not iterable
295
+ raise HTTPException(status_code=400, detail="The 'messages' field must be an iterable.")
296
+ except Exception as e:
297
+ # Catch any other unexpected errors and include the exception message
298
+ raise HTTPException(status_code=400, detail=f"An error occurred while processing 'messages': {str(e)}")
299
+
300
+ if messages[-1]["role"] != "user":
301
+ logger.error(f"The last message does not have a `user` role: {messages}")
302
+ raise HTTPException(status_code=400, detail="'messages[-1].role' must be a 'user'")
303
+
304
+ input_message = messages[-1]
305
+ if not isinstance(input_message["content"], str):
306
+ logger.error(f"The input message does not have valid content: {input_message}")
307
+ raise HTTPException(status_code=400, detail="'messages[-1].content' must be a 'string'")
308
+
309
+ return messages