diff --git a/letta/llm_api/openai.py b/letta/llm_api/openai.py index 8dc2318d..69bcaa54 100644 --- a/letta/llm_api/openai.py +++ b/letta/llm_api/openai.py @@ -524,7 +524,17 @@ def openai_chat_completions_request( log_event(name="llm_request_sent", attributes=data) chat_completion = client.chat.completions.create(**data) log_event(name="llm_response_received", attributes=chat_completion.model_dump()) - return ChatCompletionResponse(**chat_completion.model_dump()) + response = ChatCompletionResponse(**chat_completion.model_dump()) + + # Override tool_call IDs to ensure cross-provider compatibility (matches streaming path behavior) + # Some models (e.g. Kimi via OpenRouter) generate IDs like 'Read:93' which violate Anthropic's pattern + for choice in response.choices: + if choice.message.tool_calls: + for tool_call in choice.message.tool_calls: + if tool_call.id is not None: + tool_call.id = get_tool_call_id() + + return response def prepare_openai_payload(chat_completion_request: ChatCompletionRequest): diff --git a/letta/schemas/message.py b/letta/schemas/message.py index 71ff53d1..9274044a 100644 --- a/letta/schemas/message.py +++ b/letta/schemas/message.py @@ -64,7 +64,7 @@ from letta.schemas.letta_message_content import ( get_letta_message_content_union_str_json_schema, ) from letta.system import unpack_message -from letta.utils import parse_json, validate_function_response +from letta.utils import parse_json, sanitize_tool_call_id, validate_function_response def truncate_tool_return(content: Optional[str], limit: Optional[int]) -> Optional[str]: @@ -1973,7 +1973,7 @@ class Message(BaseMessage): content.append( { "type": "tool_use", - "id": tool_call.id, + "id": sanitize_tool_call_id(tool_call.id), "name": tool_call.function.name, "input": tool_call_input, } @@ -2017,7 +2017,7 @@ class Message(BaseMessage): content.append( { "type": "tool_result", - "tool_use_id": resolved_tool_call_id, + "tool_use_id": sanitize_tool_call_id(resolved_tool_call_id), "content": tool_result_content, } ) diff --git a/letta/utils.py b/letta/utils.py index 3c5fe5a8..62fe459a 100644 --- a/letta/utils.py +++ b/letta/utils.py @@ -491,6 +491,25 @@ def get_tool_call_id() -> str: return str(uuid.uuid4())[:TOOL_CALL_ID_MAX_LEN] +# Pattern for valid tool_call_id (required by Anthropic: ^[a-zA-Z0-9_-]+$) +TOOL_CALL_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") + + +def sanitize_tool_call_id(tool_id: str) -> str: + """Ensure tool_call_id matches cross-provider requirements: + - Anthropic: pattern ^[a-zA-Z0-9_-]+$ + - OpenAI: max length 29 characters + + Some models (e.g. Kimi via OpenRouter) generate IDs like 'Read:93' which + contain invalid characters. This sanitizes them for cross-provider compatibility. + """ + # Replace invalid characters with underscores + if not TOOL_CALL_ID_PATTERN.match(tool_id): + tool_id = re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id) + # Truncate to max length + return tool_id[:TOOL_CALL_ID_MAX_LEN] + + def assistant_function_to_tool(assistant_message: dict) -> dict: assert "function_call" in assistant_message new_msg = copy.deepcopy(assistant_message)