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.
- letta/__init__.py +1 -1
- letta/agent.py +19 -2
- letta/client/client.py +2 -0
- letta/constants.py +2 -0
- letta/functions/schema_generator.py +6 -6
- letta/helpers/converters.py +153 -0
- letta/helpers/tool_rule_solver.py +11 -1
- letta/llm_api/anthropic.py +10 -5
- letta/llm_api/aws_bedrock.py +1 -1
- letta/llm_api/deepseek.py +303 -0
- letta/llm_api/helpers.py +20 -10
- letta/llm_api/llm_api_tools.py +85 -2
- letta/llm_api/openai.py +16 -1
- letta/local_llm/chat_completion_proxy.py +15 -2
- letta/local_llm/lmstudio/api.py +75 -1
- letta/orm/__init__.py +2 -0
- letta/orm/agent.py +11 -4
- letta/orm/custom_columns.py +31 -110
- letta/orm/identities_agents.py +13 -0
- letta/orm/identity.py +60 -0
- letta/orm/organization.py +2 -0
- letta/orm/sqlalchemy_base.py +4 -0
- letta/schemas/agent.py +11 -1
- letta/schemas/identity.py +67 -0
- letta/schemas/llm_config.py +2 -0
- letta/schemas/message.py +1 -1
- letta/schemas/openai/chat_completion_response.py +2 -0
- letta/schemas/providers.py +72 -1
- letta/schemas/tool_rule.py +9 -1
- letta/serialize_schemas/__init__.py +1 -0
- letta/serialize_schemas/agent.py +36 -0
- letta/serialize_schemas/base.py +12 -0
- letta/serialize_schemas/custom_fields.py +69 -0
- letta/serialize_schemas/message.py +15 -0
- letta/server/db.py +111 -0
- letta/server/rest_api/app.py +8 -0
- letta/server/rest_api/chat_completions_interface.py +45 -21
- letta/server/rest_api/interface.py +114 -9
- letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +98 -24
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +14 -3
- letta/server/rest_api/routers/v1/identities.py +121 -0
- letta/server/rest_api/utils.py +183 -4
- letta/server/server.py +23 -117
- letta/services/agent_manager.py +53 -6
- letta/services/block_manager.py +1 -1
- letta/services/identity_manager.py +156 -0
- letta/services/job_manager.py +1 -1
- letta/services/message_manager.py +1 -1
- letta/services/organization_manager.py +1 -1
- letta/services/passage_manager.py +1 -1
- letta/services/provider_manager.py +1 -1
- letta/services/sandbox_config_manager.py +1 -1
- letta/services/source_manager.py +1 -1
- letta/services/step_manager.py +1 -1
- letta/services/tool_manager.py +1 -1
- letta/services/user_manager.py +1 -1
- letta/settings.py +3 -0
- letta/streaming_interface.py +6 -2
- letta/tracing.py +205 -0
- letta/utils.py +4 -0
- {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.29.dev20250221033538.dist-info}/METADATA +9 -2
- {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.29.dev20250221033538.dist-info}/RECORD +66 -52
- {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.29.dev20250221033538.dist-info}/LICENSE +0 -0
- {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.29.dev20250221033538.dist-info}/WHEEL +0 -0
- {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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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)
|
letta/server/rest_api/utils.py
CHANGED
|
@@ -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
|
-
|
|
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
|