Compare commits

...
11 Commits
Author SHA1 Message Date
Mervin Praison 4f31ec9b63 Update PraisonAI and related dependencies to version 2.2.43 and praisonaiagents to version 0.0.116 across Dockerfiles, README, and project files for consistency and compatibility. 2025-07-09 00:37:21 +01:00
Mervin Praison abee86337d Merge pull request #774 from MervinPraison/claude/pr-773-20250708_232256
fix: add array schema validation to llm.py for MCP tools compatibility
2025-07-09 00:34:52 +01:00
claude[bot]andMervin Praison e6cf4ebfcd fix: add array schema validation to llm.py for MCP tools compatibility
- Add _fix_array_schemas method to recursively fix missing 'items' in array schemas
- Apply fix to pre-formatted tools in _format_tools_for_litellm
- Apply fix to generated tool definitions in _generate_tool_definition
- Ensures OpenAI compatibility for all tool types (MCP, pre-formatted, generated)
- Maintains backward compatibility by only adding missing fields

Related to PR #773

Co-authored-by: Mervin Praison <[email protected]>
2025-07-08 23:27:14 +00:00
Mervin Praison 68fb0aaaca Update PraisonAI and related dependencies to version 2.2.42 and praisonaiagents to version 0.0.115 across Dockerfiles, README, and project files for consistency and compatibility. 2025-07-09 00:24:00 +01:00
Mervin Praison 4c9009c3f5 Merge pull request #773 from MervinPraison/claude/pr-772-20250708_230928
fix: comprehensive array schema validation for MCP tools
2025-07-09 00:21:05 +01:00
claude[bot]andMervin Praison ac21e5d595 fix: add comprehensive array schema validation for all tool types
- Add _fix_array_schemas method to recursively fix array schemas
- Apply schema fix to all pre-formatted OpenAI tools in format_tools
- Ensures MCP tools and other external tools have properly formatted array schemas
- Resolves "array schema missing items" error for read_multiple_files and similar functions

Co-authored-by: Mervin Praison <[email protected]>
2025-07-08 23:14:56 +00:00
Mervin Praison 58b12b9f24 Merge pull request #772 from MervinPraison/claude/pr-771-20250708_230103
fix: add items property to array parameters in OpenAI tool definitions
2025-07-09 00:05:24 +01:00
claude[bot]andMervin Praison 09dcf9c114 fix: add items property to array parameters in OpenAI tool definitions
- Fix array schema validation error by adding required 'items' property
- Change type comparisons to use 'is' instead of '=='
- Remove unnecessary f-string prefixes where no placeholders exist
- Resolves OpenAI API error: "array schema missing items"

This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.

Co-authored-by: Mervin Praison <[email protected]>
2025-07-08 23:04:25 +00:00
Mervin Praison e25e0a895f Merge pull request #771 from MervinPraison/claude/issue-752-20250708_223652
refactor: make openai_client.py self-contained like llm.py
2025-07-08 23:58:16 +01:00
Mervin Praison 855ec4c10a Rename test_openai_refactor.py to src/praisonai/tests/unit/test_openai_refactor_2.py 2025-07-08 23:58:02 +01:00
claude[bot]andMervin Praison 2b672f4d40 refactor: make openai_client.py self-contained like llm.py
- Move all data classes from agent.py to openai_client.py
- Add comprehensive completion methods with streaming and tool support
- Add helper methods for message building and tool formatting
- Update agent.py to use openai_client methods
- Export data classes from llm module for easy import
- Add test script to verify refactoring

This makes openai_client.py fully self-contained with all OpenAI-specific
logic, following the same pattern as llm.py for better organization and
maintainability.

Co-authored-by: Mervin Praison <[email protected]>
2025-07-08 22:51:17 +00:00
16 changed files with 1274 additions and 388 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
flask \
"praisonai>=2.2.41" \
"praisonai>=2.2.43" \
"praisonai[api]" \
gunicorn \
markdown
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.2.41" \
"praisonai>=2.2.43" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.2.41" \
"praisonai>=2.2.43" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.2.41" \
"praisonai>=2.2.43" \
"praisonai[ui]" \
"praisonai[crewai]"
+2 -2
View File
@@ -121,7 +121,7 @@ healthcheck:
## 📦 Package Versions
All Docker images use consistent, up-to-date versions:
- PraisonAI: `>=2.2.41`
- PraisonAI: `>=2.2.43`
- PraisonAI Agents: `>=0.0.92`
- Python: `3.11-slim`
@@ -218,7 +218,7 @@ docker-compose up -d
### Version Pinning
To use specific versions, update the Dockerfile:
```dockerfile
RUN pip install "praisonai==2.2.41" "praisonaiagents==0.0.92"
RUN pip install "praisonai==2.2.43" "praisonaiagents==0.0.92"
```
## 🌐 Production Deployment
@@ -7,7 +7,17 @@ import asyncio
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Tuple
from rich.console import Console
from rich.live import Live
from ..llm import get_openai_client
from ..llm import (
get_openai_client,
ChatCompletionMessage,
Choice,
CompletionTokensDetails,
PromptTokensDetails,
CompletionUsage,
ChatCompletion,
ToolCall,
process_stream_chunks
)
from ..main import (
display_error,
display_tool_call,
@@ -21,7 +31,6 @@ from ..main import (
)
import inspect
import uuid
from dataclasses import dataclass
# Global variables for API server
_server_started = {} # Dict of port -> started boolean
@@ -35,181 +44,6 @@ if TYPE_CHECKING:
from ..main import TaskOutput
from ..handoff import Handoff
@dataclass
class ChatCompletionMessage:
content: str
role: str = "assistant"
refusal: Optional[str] = None
audio: Optional[str] = None
function_call: Optional[dict] = None
tool_calls: Optional[List] = None
reasoning_content: Optional[str] = None
@dataclass
class Choice:
finish_reason: Optional[str]
index: int
message: ChatCompletionMessage
logprobs: Optional[dict] = None
@dataclass
class CompletionTokensDetails:
accepted_prediction_tokens: Optional[int] = None
audio_tokens: Optional[int] = None
reasoning_tokens: Optional[int] = None
rejected_prediction_tokens: Optional[int] = None
@dataclass
class PromptTokensDetails:
audio_tokens: Optional[int] = None
cached_tokens: int = 0
@dataclass
class CompletionUsage:
completion_tokens: int = 0
prompt_tokens: int = 0
total_tokens: int = 0
completion_tokens_details: Optional[CompletionTokensDetails] = None
prompt_tokens_details: Optional[PromptTokensDetails] = None
prompt_cache_hit_tokens: int = 0
prompt_cache_miss_tokens: int = 0
@dataclass
class ChatCompletion:
id: str
choices: List[Choice]
created: int
model: str
object: str = "chat.completion"
system_fingerprint: Optional[str] = None
service_tier: Optional[str] = None
usage: Optional[CompletionUsage] = None
@dataclass
class ToolCall:
"""Tool call representation compatible with OpenAI format"""
id: str
type: str
function: Dict[str, Any]
def process_stream_chunks(chunks):
"""Process streaming chunks into combined response"""
if not chunks:
return None
try:
first_chunk = chunks[0]
last_chunk = chunks[-1]
# Basic metadata
id = getattr(first_chunk, "id", None)
created = getattr(first_chunk, "created", None)
model = getattr(first_chunk, "model", None)
system_fingerprint = getattr(first_chunk, "system_fingerprint", None)
# Track usage
completion_tokens = 0
prompt_tokens = 0
content_list = []
reasoning_list = []
tool_calls = []
current_tool_call = None
# First pass: Get initial tool call data
for chunk in chunks:
if not hasattr(chunk, "choices") or not chunk.choices:
continue
delta = getattr(chunk.choices[0], "delta", None)
if not delta:
continue
# Handle content and reasoning
if hasattr(delta, "content") and delta.content:
content_list.append(delta.content)
if hasattr(delta, "reasoning_content") and delta.reasoning_content:
reasoning_list.append(delta.reasoning_content)
# Handle tool calls
if hasattr(delta, "tool_calls") and delta.tool_calls:
for tool_call_delta in delta.tool_calls:
if tool_call_delta.index is not None and tool_call_delta.id:
# Found the initial tool call
current_tool_call = {
"id": tool_call_delta.id,
"type": "function",
"function": {
"name": tool_call_delta.function.name,
"arguments": ""
}
}
while len(tool_calls) <= tool_call_delta.index:
tool_calls.append(None)
tool_calls[tool_call_delta.index] = current_tool_call
current_tool_call = tool_calls[tool_call_delta.index]
elif current_tool_call is not None and hasattr(tool_call_delta.function, "arguments"):
if tool_call_delta.function.arguments:
current_tool_call["function"]["arguments"] += tool_call_delta.function.arguments
# Remove any None values and empty tool calls
tool_calls = [tc for tc in tool_calls if tc and tc["id"] and tc["function"]["name"]]
combined_content = "".join(content_list) if content_list else ""
combined_reasoning = "".join(reasoning_list) if reasoning_list else None
finish_reason = getattr(last_chunk.choices[0], "finish_reason", None) if hasattr(last_chunk, "choices") and last_chunk.choices else None
# Create ToolCall objects
processed_tool_calls = []
if tool_calls:
try:
for tc in tool_calls:
tool_call = ToolCall(
id=tc["id"],
type=tc["type"],
function={
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"]
}
)
processed_tool_calls.append(tool_call)
except Exception as e:
print(f"Error processing tool call: {e}")
message = ChatCompletionMessage(
content=combined_content,
role="assistant",
reasoning_content=combined_reasoning,
tool_calls=processed_tool_calls if processed_tool_calls else None
)
choice = Choice(
finish_reason=finish_reason or "tool_calls" if processed_tool_calls else None,
index=0,
message=message
)
usage = CompletionUsage(
completion_tokens=completion_tokens,
prompt_tokens=prompt_tokens,
total_tokens=completion_tokens + prompt_tokens,
completion_tokens_details=CompletionTokensDetails(),
prompt_tokens_details=PromptTokensDetails()
)
return ChatCompletion(
id=id,
choices=[choice],
created=created,
model=model,
system_fingerprint=system_fingerprint,
usage=usage
)
except Exception as e:
print(f"Error processing chunks: {e}")
return None
class Agent:
def _generate_tool_definition(self, function_name):
"""
@@ -852,8 +686,6 @@ Your Goal: {self.goal}
Returns:
tuple: (messages list, original prompt)
"""
messages = []
# Build system prompt if enabled
system_prompt = None
if self.use_system_prompt:
@@ -861,35 +693,15 @@ Your Goal: {self.goal}
Your Role: {self.role}\n
Your Goal: {self.goal}
"""
if output_json:
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(output_json.model_json_schema())}"
elif output_pydantic:
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(output_pydantic.model_json_schema())}"
messages.append({"role": "system", "content": system_prompt})
# Add chat history
messages.extend(self.chat_history)
# Handle prompt modifications for JSON output
original_prompt = prompt
if output_json or output_pydantic:
if isinstance(prompt, str):
prompt = prompt + "\nReturn ONLY a valid JSON object. No other text or explanation."
elif isinstance(prompt, list):
# Create a deep copy to avoid modifying the original
prompt = copy.deepcopy(prompt)
for item in prompt:
if item.get("type") == "text":
item["text"] = item["text"] + "\nReturn ONLY a valid JSON object. No other text or explanation."
break
# Add prompt to messages
if isinstance(prompt, list):
# If we receive a multimodal prompt list, place it directly in the user message
messages.append({"role": "user", "content": prompt})
else:
messages.append({"role": "user", "content": prompt})
# Use openai_client's build_messages method
messages, original_prompt = self._openai_client.build_messages(
prompt=prompt,
system_prompt=system_prompt,
chat_history=self.chat_history,
output_json=output_json,
output_pydantic=output_pydantic
)
return messages, original_prompt
@@ -1131,50 +943,16 @@ Your Goal: {self.goal}
def _process_stream_response(self, messages, temperature, start_time, formatted_tools=None, reasoning_steps=False):
"""Process streaming response and return final response"""
try:
# Create the response stream
response_stream = self._openai_client.sync_client.chat.completions.create(
model=self.llm,
messages=messages,
temperature=temperature,
tools=formatted_tools if formatted_tools else None,
stream=True
)
full_response_text = ""
reasoning_content = ""
chunks = []
# Create Live display with proper configuration
with Live(
display_generating("", start_time),
console=self.console,
refresh_per_second=4,
transient=True,
vertical_overflow="ellipsis",
auto_refresh=True
) as live:
for chunk in response_stream:
chunks.append(chunk)
if chunk.choices[0].delta.content:
full_response_text += chunk.choices[0].delta.content
live.update(display_generating(full_response_text, start_time))
# Update live display with reasoning content if enabled
if reasoning_steps and hasattr(chunk.choices[0].delta, "reasoning_content"):
rc = chunk.choices[0].delta.reasoning_content
if rc:
reasoning_content += rc
live.update(display_generating(f"{full_response_text}\n[Reasoning: {reasoning_content}]", start_time))
# Clear the last generating display with a blank line
self.console.print()
final_response = process_stream_chunks(chunks)
return final_response
except Exception as e:
display_error(f"Error in stream processing: {e}")
return None
return self._openai_client.process_stream_response(
messages=messages,
model=self.llm,
temperature=temperature,
tools=formatted_tools,
start_time=start_time,
console=self.console,
display_fn=display_generating,
reasoning_steps=reasoning_steps
)
def _chat_completion(self, messages, temperature=0.2, tools=None, stream=True, reasoning_steps=False):
start_time = time.time()
@@ -1223,117 +1001,27 @@ Your Goal: {self.goal}
reasoning_steps=reasoning_steps
)
else:
# Use the standard OpenAI client approach
# Continue tool execution loop until no more tool calls are needed
max_iterations = 10 # Prevent infinite loops
iteration_count = 0
# Use the standard OpenAI client approach with tool support
def custom_display_fn(text, start_time):
if self.verbose:
return display_generating(text, start_time)
return ""
while iteration_count < max_iterations:
if stream:
# Process as streaming response with formatted tools
final_response = self._process_stream_response(
messages,
temperature,
start_time,
formatted_tools=formatted_tools if formatted_tools else None,
reasoning_steps=reasoning_steps
)
else:
# Process as regular non-streaming response
final_response = self._openai_client.sync_client.chat.completions.create(
model=self.llm,
messages=messages,
temperature=temperature,
tools=formatted_tools if formatted_tools else None,
stream=False
)
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
if tool_calls:
# Convert ToolCall dataclass objects to dict for JSON serialization
serializable_tool_calls = []
for tc in tool_calls:
if isinstance(tc, ToolCall):
# Convert dataclass to dict
serializable_tool_calls.append({
"id": tc.id,
"type": tc.type,
"function": tc.function
})
else:
# Already an OpenAI object, keep as is
serializable_tool_calls.append(tc)
messages.append({
"role": "assistant",
"content": final_response.choices[0].message.content,
"tool_calls": serializable_tool_calls
})
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
arguments = json.loads(tool_call.function["arguments"])
else:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
if self.verbose:
display_tool_call(f"Agent {self.name} is calling function '{function_name}' with arguments: {arguments}")
tool_result = self.execute_tool(function_name, arguments)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
if self.verbose:
display_tool_call(f"Function '{function_name}' returned: {results_str}")
messages.append({
"role": "tool",
"tool_call_id": tool_call.id if hasattr(tool_call, 'id') else tool_call['id'],
"content": results_str
})
# Check if we should continue (for tools like sequential thinking)
should_continue = False
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
arguments = json.loads(tool_call.function["arguments"])
else:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
# For sequential thinking tool, check if nextThoughtNeeded is True
if function_name == "sequentialthinking" and arguments.get("nextThoughtNeeded", False):
should_continue = True
break
if not should_continue:
# Get final response after tool calls
if stream:
final_response = self._process_stream_response(
messages,
temperature,
start_time,
formatted_tools=formatted_tools if formatted_tools else None,
reasoning_steps=reasoning_steps
)
else:
final_response = self._openai_client.sync_client.chat.completions.create(
model=self.llm,
messages=messages,
temperature=temperature,
stream=False
)
break
iteration_count += 1
else:
# No tool calls, we're done
break
# Note: openai_client expects tools in various formats and will format them internally
# But since we already have formatted_tools, we can pass them directly
final_response = self._openai_client.chat_completion_with_tools(
messages=messages,
model=self.llm,
temperature=temperature,
tools=formatted_tools, # Already formatted for OpenAI
execute_tool_fn=self.execute_tool,
stream=stream,
console=self.console if self.verbose else None,
display_fn=display_generating if stream and self.verbose else None,
reasoning_steps=reasoning_steps,
verbose=self.verbose,
max_iterations=10
)
return final_response
@@ -20,7 +20,18 @@ logging.basicConfig(level=logging.WARNING)
# Import after suppressing warnings
from .llm import LLM, LLMContextLengthExceededException
from .openai_client import OpenAIClient, get_openai_client
from .openai_client import (
OpenAIClient,
get_openai_client,
ChatCompletionMessage,
Choice,
CompletionTokensDetails,
PromptTokensDetails,
CompletionUsage,
ChatCompletion,
ToolCall,
process_stream_chunks
)
# Ensure telemetry is disabled after import as well
try:
@@ -29,4 +40,17 @@ try:
except ImportError:
pass
__all__ = ["LLM", "LLMContextLengthExceededException", "OpenAIClient", "get_openai_client"]
__all__ = [
"LLM",
"LLMContextLengthExceededException",
"OpenAIClient",
"get_openai_client",
"ChatCompletionMessage",
"Choice",
"CompletionTokensDetails",
"PromptTokensDetails",
"CompletionUsage",
"ChatCompletion",
"ToolCall",
"process_stream_chunks"
]
@@ -364,6 +364,46 @@ class LLM:
return messages, original_prompt
def _fix_array_schemas(self, schema: Dict) -> Dict:
"""
Recursively fix array schemas by adding missing 'items' attribute.
This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas in properties
if "properties" in fixed_schema and isinstance(fixed_schema["properties"], dict):
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
if isinstance(prop_schema, dict):
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
else:
fixed_properties[prop_name] = prop_schema
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema and isinstance(fixed_schema["items"], dict):
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
return fixed_schema
def _format_tools_for_litellm(self, tools: Optional[List[Any]]) -> Optional[List[Dict]]:
"""Format tools for LiteLLM - handles all tool formats.
@@ -389,7 +429,11 @@ class LLM:
# Validate nested dictionary structure before accessing
if 'function' in tool and isinstance(tool['function'], dict) and 'name' in tool['function']:
logging.debug(f"Using pre-formatted OpenAI tool: {tool['function']['name']}")
formatted_tools.append(tool)
# Fix array schemas in the tool parameters
fixed_tool = tool.copy()
if 'parameters' in fixed_tool['function']:
fixed_tool['function']['parameters'] = self._fix_array_schemas(fixed_tool['function']['parameters'])
formatted_tools.append(fixed_tool)
else:
logging.debug(f"Skipping malformed OpenAI tool: missing function or name")
# Handle lists of tools (e.g. from MCP.to_openai_tool())
@@ -399,7 +443,11 @@ class LLM:
# Validate nested dictionary structure before accessing
if 'function' in subtool and isinstance(subtool['function'], dict) and 'name' in subtool['function']:
logging.debug(f"Using pre-formatted OpenAI tool from list: {subtool['function']['name']}")
formatted_tools.append(subtool)
# Fix array schemas in the tool parameters
fixed_tool = subtool.copy()
if 'parameters' in fixed_tool['function']:
fixed_tool['function']['parameters'] = self._fix_array_schemas(fixed_tool['function']['parameters'])
formatted_tools.append(fixed_tool)
else:
logging.debug(f"Skipping malformed OpenAI tool in list: missing function or name")
elif callable(tool):
@@ -2153,7 +2201,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"function": {
"name": function_name,
"description": docstring.split('\n\n')[0] if docstring else "No description available",
"parameters": parameters
"parameters": self._fix_array_schemas(parameters)
}
}
logging.debug(f"Generated tool definition: {tool_def}")
@@ -7,15 +7,199 @@ supporting both synchronous and asynchronous operations.
import os
import logging
from typing import Any, Dict, List, Optional, Union, AsyncIterator, Iterator
import time
import json
import asyncio
from typing import Any, Dict, List, Optional, Union, AsyncIterator, Iterator, Callable, Tuple
from openai import OpenAI, AsyncOpenAI
from openai.types.chat import ChatCompletionChunk
import asyncio
from pydantic import BaseModel
from dataclasses import dataclass
from rich.console import Console
from rich.live import Live
import inspect
# Constants
LOCAL_SERVER_API_KEY_PLACEHOLDER = "not-needed"
# Data Classes for OpenAI Response Structure
@dataclass
class ChatCompletionMessage:
content: str
role: str = "assistant"
refusal: Optional[str] = None
audio: Optional[str] = None
function_call: Optional[dict] = None
tool_calls: Optional[List] = None
reasoning_content: Optional[str] = None
@dataclass
class Choice:
finish_reason: Optional[str]
index: int
message: ChatCompletionMessage
logprobs: Optional[dict] = None
@dataclass
class CompletionTokensDetails:
accepted_prediction_tokens: Optional[int] = None
audio_tokens: Optional[int] = None
reasoning_tokens: Optional[int] = None
rejected_prediction_tokens: Optional[int] = None
@dataclass
class PromptTokensDetails:
audio_tokens: Optional[int] = None
cached_tokens: int = 0
@dataclass
class CompletionUsage:
completion_tokens: int = 0
prompt_tokens: int = 0
total_tokens: int = 0
completion_tokens_details: Optional[CompletionTokensDetails] = None
prompt_tokens_details: Optional[PromptTokensDetails] = None
prompt_cache_hit_tokens: int = 0
prompt_cache_miss_tokens: int = 0
@dataclass
class ChatCompletion:
id: str
choices: List[Choice]
created: int
model: str
object: str = "chat.completion"
system_fingerprint: Optional[str] = None
service_tier: Optional[str] = None
usage: Optional[CompletionUsage] = None
@dataclass
class ToolCall:
"""Tool call representation compatible with OpenAI format"""
id: str
type: str
function: Dict[str, Any]
def process_stream_chunks(chunks):
"""Process streaming chunks into combined response"""
if not chunks:
return None
try:
first_chunk = chunks[0]
last_chunk = chunks[-1]
# Basic metadata
id = getattr(first_chunk, "id", None)
created = getattr(first_chunk, "created", None)
model = getattr(first_chunk, "model", None)
system_fingerprint = getattr(first_chunk, "system_fingerprint", None)
# Track usage
completion_tokens = 0
prompt_tokens = 0
content_list = []
reasoning_list = []
tool_calls = []
current_tool_call = None
# First pass: Get initial tool call data
for chunk in chunks:
if not hasattr(chunk, "choices") or not chunk.choices:
continue
delta = getattr(chunk.choices[0], "delta", None)
if not delta:
continue
# Handle content and reasoning
if hasattr(delta, "content") and delta.content:
content_list.append(delta.content)
if hasattr(delta, "reasoning_content") and delta.reasoning_content:
reasoning_list.append(delta.reasoning_content)
# Handle tool calls
if hasattr(delta, "tool_calls") and delta.tool_calls:
for tool_call_delta in delta.tool_calls:
if tool_call_delta.index is not None and tool_call_delta.id:
# Found the initial tool call
current_tool_call = {
"id": tool_call_delta.id,
"type": "function",
"function": {
"name": tool_call_delta.function.name,
"arguments": ""
}
}
while len(tool_calls) <= tool_call_delta.index:
tool_calls.append(None)
tool_calls[tool_call_delta.index] = current_tool_call
current_tool_call = tool_calls[tool_call_delta.index]
elif current_tool_call is not None and hasattr(tool_call_delta.function, "arguments"):
if tool_call_delta.function.arguments:
current_tool_call["function"]["arguments"] += tool_call_delta.function.arguments
# Remove any None values and empty tool calls
tool_calls = [tc for tc in tool_calls if tc and tc["id"] and tc["function"]["name"]]
combined_content = "".join(content_list) if content_list else ""
combined_reasoning = "".join(reasoning_list) if reasoning_list else None
finish_reason = getattr(last_chunk.choices[0], "finish_reason", None) if hasattr(last_chunk, "choices") and last_chunk.choices else None
# Create ToolCall objects
processed_tool_calls = []
if tool_calls:
try:
for tc in tool_calls:
tool_call = ToolCall(
id=tc["id"],
type=tc["type"],
function={
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"]
}
)
processed_tool_calls.append(tool_call)
except Exception as e:
print(f"Error processing tool call: {e}")
message = ChatCompletionMessage(
content=combined_content,
role="assistant",
reasoning_content=combined_reasoning,
tool_calls=processed_tool_calls if processed_tool_calls else None
)
choice = Choice(
finish_reason=finish_reason or "tool_calls" if processed_tool_calls else None,
index=0,
message=message
)
usage = CompletionUsage(
completion_tokens=completion_tokens,
prompt_tokens=prompt_tokens,
total_tokens=completion_tokens + prompt_tokens,
completion_tokens_details=CompletionTokensDetails(),
prompt_tokens_details=PromptTokensDetails()
)
return ChatCompletion(
id=id,
choices=[choice],
created=created,
model=model,
system_fingerprint=system_fingerprint,
usage=usage
)
except Exception as e:
print(f"Error processing chunks: {e}")
return None
class OpenAIClient:
"""
Unified OpenAI client wrapper for sync/async operations.
@@ -52,6 +236,9 @@ class OpenAIClient:
# Set up logging
self.logger = logging.getLogger(__name__)
# Initialize console for display
self.console = Console()
@property
def sync_client(self) -> OpenAI:
@@ -65,6 +252,427 @@ class OpenAIClient:
self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
return self._async_client
def build_messages(
self,
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
chat_history: Optional[List[Dict]] = None,
output_json: Optional[BaseModel] = None,
output_pydantic: Optional[BaseModel] = None
) -> Tuple[List[Dict], Union[str, List[Dict]]]:
"""
Build messages list for OpenAI completion.
Args:
prompt: The user prompt (str or list)
system_prompt: Optional system prompt
chat_history: Optional list of previous messages
output_json: Optional Pydantic model for JSON output
output_pydantic: Optional Pydantic model for JSON output (alias)
Returns:
tuple: (messages list, original prompt)
"""
messages = []
# Handle system prompt
if system_prompt:
# Append JSON schema if needed
if output_json:
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(output_json.model_json_schema())}"
elif output_pydantic:
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(output_pydantic.model_json_schema())}"
messages.append({"role": "system", "content": system_prompt})
# Add chat history if provided
if chat_history:
messages.extend(chat_history)
# Handle prompt modifications for JSON output
original_prompt = prompt
if output_json or output_pydantic:
if isinstance(prompt, str):
prompt = prompt + "\nReturn ONLY a valid JSON object. No other text or explanation."
elif isinstance(prompt, list):
# Create a copy to avoid modifying the original
prompt = prompt.copy()
for item in prompt:
if item.get("type") == "text":
item["text"] = item["text"] + "\nReturn ONLY a valid JSON object. No other text or explanation."
break
# Add prompt to messages
if isinstance(prompt, list):
messages.append({"role": "user", "content": prompt})
else:
messages.append({"role": "user", "content": prompt})
return messages, original_prompt
def _fix_array_schemas(self, schema: Dict) -> Dict:
"""
Recursively fix array schemas by adding missing 'items' attribute.
This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas in properties
if "properties" in fixed_schema and isinstance(fixed_schema["properties"], dict):
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
if isinstance(prop_schema, dict):
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
else:
fixed_properties[prop_name] = prop_schema
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema and isinstance(fixed_schema["items"], dict):
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
return fixed_schema
def format_tools(self, tools: Optional[List[Any]]) -> Optional[List[Dict]]:
"""
Format tools for OpenAI API.
Supports:
- Pre-formatted OpenAI tools (dicts with type='function')
- Lists of pre-formatted tools
- Callable functions
- String function names
- MCP tools
Args:
tools: List of tools in various formats
Returns:
List of formatted tools or None
"""
if not tools:
return None
formatted_tools = []
for tool in tools:
# Check if the tool is already in OpenAI format
if isinstance(tool, dict) and 'type' in tool and tool['type'] == 'function':
if 'function' in tool and isinstance(tool['function'], dict) and 'name' in tool['function']:
logging.debug(f"Using pre-formatted OpenAI tool: {tool['function']['name']}")
# Fix array schemas in the tool parameters
fixed_tool = tool.copy()
if 'parameters' in fixed_tool['function']:
fixed_tool['function']['parameters'] = self._fix_array_schemas(fixed_tool['function']['parameters'])
formatted_tools.append(fixed_tool)
else:
logging.debug("Skipping malformed OpenAI tool: missing function or name")
# Handle lists of tools
elif isinstance(tool, list):
for subtool in tool:
if isinstance(subtool, dict) and 'type' in subtool and subtool['type'] == 'function':
if 'function' in subtool and isinstance(subtool['function'], dict) and 'name' in subtool['function']:
logging.debug(f"Using pre-formatted OpenAI tool from list: {subtool['function']['name']}")
# Fix array schemas in the tool parameters
fixed_tool = subtool.copy()
if 'parameters' in fixed_tool['function']:
fixed_tool['function']['parameters'] = self._fix_array_schemas(fixed_tool['function']['parameters'])
formatted_tools.append(fixed_tool)
else:
logging.debug("Skipping malformed OpenAI tool in list: missing function or name")
elif callable(tool):
tool_def = self._generate_tool_definition(tool)
if tool_def:
formatted_tools.append(tool_def)
elif isinstance(tool, str):
tool_def = self._generate_tool_definition_from_name(tool)
if tool_def:
formatted_tools.append(tool_def)
else:
logging.debug(f"Skipping tool of unsupported type: {type(tool)}")
# Validate JSON serialization before returning
if formatted_tools:
try:
json.dumps(formatted_tools) # Validate serialization
except (TypeError, ValueError) as e:
logging.error(f"Tools are not JSON serializable: {e}")
return None
return formatted_tools if formatted_tools else None
def _generate_tool_definition(self, func: Callable) -> Optional[Dict]:
"""Generate a tool definition from a callable function."""
try:
sig = inspect.signature(func)
# Skip self, *args, **kwargs
parameters_list = []
for name, param in sig.parameters.items():
if name == "self":
continue
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue
parameters_list.append((name, param))
parameters = {
"type": "object",
"properties": {},
"required": []
}
# Parse docstring for parameter descriptions
docstring = inspect.getdoc(func)
param_descriptions = {}
if docstring:
import re
param_section = re.split(r'\s*Args:\s*', docstring)
if len(param_section) > 1:
param_lines = param_section[1].split('\n')
for line in param_lines:
line = line.strip()
if line and ':' in line:
param_name, param_desc = line.split(':', 1)
param_descriptions[param_name.strip()] = param_desc.strip()
for name, param in parameters_list:
param_type = "string" # Default type
param_info = {}
if param.annotation != inspect.Parameter.empty:
if param.annotation is int:
param_type = "integer"
elif param.annotation is float:
param_type = "number"
elif param.annotation is bool:
param_type = "boolean"
elif param.annotation is list:
param_type = "array"
# OpenAI requires 'items' for array types
param_info["items"] = {"type": "string"}
elif param.annotation is dict:
param_type = "object"
param_info["type"] = param_type
if name in param_descriptions:
param_info["description"] = param_descriptions[name]
parameters["properties"][name] = param_info
if param.default == inspect.Parameter.empty:
parameters["required"].append(name)
# Extract description from docstring
description = docstring.split('\n')[0] if docstring else f"Function {func.__name__}"
return {
"type": "function",
"function": {
"name": func.__name__,
"description": description,
"parameters": parameters
}
}
except Exception as e:
logging.error(f"Error generating tool definition: {e}")
return None
def _generate_tool_definition_from_name(self, function_name: str) -> Optional[Dict]:
"""Generate a tool definition from a function name."""
# This is a placeholder - in agent.py this would look up the function
# For now, return None as the actual implementation would need access to the function
logging.debug(f"Tool definition generation from name '{function_name}' requires function reference")
return None
def process_stream_response(
self,
messages: List[Dict],
model: str,
temperature: float = 0.7,
tools: Optional[List[Dict]] = None,
start_time: Optional[float] = None,
console: Optional[Console] = None,
display_fn: Optional[Callable] = None,
reasoning_steps: bool = False,
**kwargs
) -> Optional[ChatCompletion]:
"""
Process streaming response and return final response.
Args:
messages: List of messages for the conversation
model: Model to use
temperature: Temperature for generation
tools: Optional formatted tools
start_time: Start time for timing display
console: Console for output
display_fn: Display function for live updates
reasoning_steps: Whether to show reasoning steps
**kwargs: Additional parameters for the API
Returns:
ChatCompletion object or None if error
"""
try:
# Default start time and console if not provided
if start_time is None:
start_time = time.time()
if console is None:
console = self.console
# Create the response stream
response_stream = self._sync_client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
tools=tools if tools else None,
stream=True,
**kwargs
)
full_response_text = ""
reasoning_content = ""
chunks = []
# If display function provided, use Live display
if display_fn:
with Live(
display_fn("", start_time),
console=console,
refresh_per_second=4,
transient=True,
vertical_overflow="ellipsis",
auto_refresh=True
) as live:
for chunk in response_stream:
chunks.append(chunk)
if chunk.choices[0].delta.content:
full_response_text += chunk.choices[0].delta.content
live.update(display_fn(full_response_text, start_time))
# Update live display with reasoning content if enabled
if reasoning_steps and hasattr(chunk.choices[0].delta, "reasoning_content"):
rc = chunk.choices[0].delta.reasoning_content
if rc:
reasoning_content += rc
live.update(display_fn(f"{full_response_text}\n[Reasoning: {reasoning_content}]", start_time))
# Clear the last generating display with a blank line
console.print()
else:
# Just collect chunks without display
for chunk in response_stream:
chunks.append(chunk)
final_response = process_stream_chunks(chunks)
return final_response
except Exception as e:
self.logger.error(f"Error in stream processing: {e}")
return None
async def process_stream_response_async(
self,
messages: List[Dict],
model: str,
temperature: float = 0.7,
tools: Optional[List[Dict]] = None,
start_time: Optional[float] = None,
console: Optional[Console] = None,
display_fn: Optional[Callable] = None,
reasoning_steps: bool = False,
**kwargs
) -> Optional[ChatCompletion]:
"""
Async version of process_stream_response.
Args:
messages: List of messages for the conversation
model: Model to use
temperature: Temperature for generation
tools: Optional formatted tools
start_time: Start time for timing display
console: Console for output
display_fn: Display function for live updates
reasoning_steps: Whether to show reasoning steps
**kwargs: Additional parameters for the API
Returns:
ChatCompletion object or None if error
"""
try:
# Default start time and console if not provided
if start_time is None:
start_time = time.time()
if console is None:
console = self.console
# Create the response stream
response_stream = await self.async_client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
tools=tools if tools else None,
stream=True,
**kwargs
)
full_response_text = ""
reasoning_content = ""
chunks = []
# If display function provided, use Live display
if display_fn:
with Live(
display_fn("", start_time),
console=console,
refresh_per_second=4,
transient=True,
vertical_overflow="ellipsis",
auto_refresh=True
) as live:
async for chunk in response_stream:
chunks.append(chunk)
if chunk.choices[0].delta.content:
full_response_text += chunk.choices[0].delta.content
live.update(display_fn(full_response_text, start_time))
# Update live display with reasoning content if enabled
if reasoning_steps and hasattr(chunk.choices[0].delta, "reasoning_content"):
rc = chunk.choices[0].delta.reasoning_content
if rc:
reasoning_content += rc
live.update(display_fn(f"{full_response_text}\n[Reasoning: {reasoning_content}]", start_time))
# Clear the last generating display with a blank line
console.print()
else:
# Just collect chunks without display
async for chunk in response_stream:
chunks.append(chunk)
final_response = process_stream_chunks(chunks)
return final_response
except Exception as e:
self.logger.error(f"Error in async stream processing: {e}")
return None
def create_completion(
self,
messages: List[Dict[str, Any]],
@@ -155,6 +763,357 @@ class OpenAIClient:
self.logger.error(f"Error creating async completion: {e}")
raise
def chat_completion_with_tools(
self,
messages: List[Dict[str, Any]],
model: str = "gpt-4o",
temperature: float = 0.7,
tools: Optional[List[Any]] = None,
execute_tool_fn: Optional[Callable] = None,
stream: bool = True,
console: Optional[Console] = None,
display_fn: Optional[Callable] = None,
reasoning_steps: bool = False,
verbose: bool = True,
max_iterations: int = 10,
**kwargs
) -> Optional[ChatCompletion]:
"""
Create a chat completion with tool support and streaming.
This method handles the full tool execution loop, including:
- Formatting tools for OpenAI API
- Making the initial API call
- Executing tool calls if present
- Getting final response after tool execution
Args:
messages: List of message dictionaries
model: Model to use
temperature: Temperature for generation
tools: List of tools (can be callables, dicts, or strings)
execute_tool_fn: Function to execute tools
stream: Whether to stream responses
console: Console for output
display_fn: Display function for streaming
reasoning_steps: Whether to show reasoning
verbose: Whether to show verbose output
max_iterations: Maximum tool calling iterations
**kwargs: Additional API parameters
Returns:
Final ChatCompletion response or None if error
"""
start_time = time.time()
# Format tools for OpenAI API
formatted_tools = self.format_tools(tools)
# Continue tool execution loop until no more tool calls are needed
iteration_count = 0
while iteration_count < max_iterations:
if stream:
# Process as streaming response with formatted tools
final_response = self.process_stream_response(
messages=messages,
model=model,
temperature=temperature,
tools=formatted_tools,
start_time=start_time,
console=console,
display_fn=display_fn,
reasoning_steps=reasoning_steps,
**kwargs
)
else:
# Process as regular non-streaming response
final_response = self.create_completion(
messages=messages,
model=model,
temperature=temperature,
tools=formatted_tools,
stream=False,
**kwargs
)
if not final_response:
return None
# Check for tool calls
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
if tool_calls and execute_tool_fn:
# Convert ToolCall dataclass objects to dict for JSON serialization
serializable_tool_calls = []
for tc in tool_calls:
if isinstance(tc, ToolCall):
# Convert dataclass to dict
serializable_tool_calls.append({
"id": tc.id,
"type": tc.type,
"function": tc.function
})
else:
# Already an OpenAI object, keep as is
serializable_tool_calls.append(tc)
messages.append({
"role": "assistant",
"content": final_response.choices[0].message.content,
"tool_calls": serializable_tool_calls
})
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
arguments = json.loads(tool_call.function["arguments"])
else:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
if verbose and console:
console.print(f"[bold]Calling function:[/bold] {function_name}")
console.print(f"[dim]Arguments:[/dim] {arguments}")
# Execute the tool
tool_result = execute_tool_fn(function_name, arguments)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
if verbose and console:
console.print(f"[dim]Result:[/dim] {results_str}")
messages.append({
"role": "tool",
"tool_call_id": tool_call.id if hasattr(tool_call, 'id') else tool_call['id'],
"content": results_str
})
# Check if we should continue (for tools like sequential thinking)
should_continue = False
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
arguments = json.loads(tool_call.function["arguments"])
else:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
# For sequential thinking tool, check if nextThoughtNeeded is True
if function_name == "sequentialthinking" and arguments.get("nextThoughtNeeded", False):
should_continue = True
break
if not should_continue:
# Get final response after tool calls
if stream:
final_response = self.process_stream_response(
messages=messages,
model=model,
temperature=temperature,
tools=formatted_tools,
start_time=start_time,
console=console,
display_fn=display_fn,
reasoning_steps=reasoning_steps,
**kwargs
)
else:
final_response = self.create_completion(
messages=messages,
model=model,
temperature=temperature,
stream=False,
**kwargs
)
break
iteration_count += 1
else:
# No tool calls, we're done
break
return final_response
async def achat_completion_with_tools(
self,
messages: List[Dict[str, Any]],
model: str = "gpt-4o",
temperature: float = 0.7,
tools: Optional[List[Any]] = None,
execute_tool_fn: Optional[Callable] = None,
stream: bool = True,
console: Optional[Console] = None,
display_fn: Optional[Callable] = None,
reasoning_steps: bool = False,
verbose: bool = True,
max_iterations: int = 10,
**kwargs
) -> Optional[ChatCompletion]:
"""
Async version of chat_completion_with_tools.
Args:
messages: List of message dictionaries
model: Model to use
temperature: Temperature for generation
tools: List of tools (can be callables, dicts, or strings)
execute_tool_fn: Async function to execute tools
stream: Whether to stream responses
console: Console for output
display_fn: Display function for streaming
reasoning_steps: Whether to show reasoning
verbose: Whether to show verbose output
max_iterations: Maximum tool calling iterations
**kwargs: Additional API parameters
Returns:
Final ChatCompletion response or None if error
"""
start_time = time.time()
# Format tools for OpenAI API
formatted_tools = self.format_tools(tools)
# Continue tool execution loop until no more tool calls are needed
iteration_count = 0
while iteration_count < max_iterations:
if stream:
# Process as streaming response with formatted tools
final_response = await self.process_stream_response_async(
messages=messages,
model=model,
temperature=temperature,
tools=formatted_tools,
start_time=start_time,
console=console,
display_fn=display_fn,
reasoning_steps=reasoning_steps,
**kwargs
)
else:
# Process as regular non-streaming response
final_response = await self.acreate_completion(
messages=messages,
model=model,
temperature=temperature,
tools=formatted_tools,
stream=False,
**kwargs
)
if not final_response:
return None
# Check for tool calls
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
if tool_calls and execute_tool_fn:
# Convert ToolCall dataclass objects to dict for JSON serialization
serializable_tool_calls = []
for tc in tool_calls:
if isinstance(tc, ToolCall):
# Convert dataclass to dict
serializable_tool_calls.append({
"id": tc.id,
"type": tc.type,
"function": tc.function
})
else:
# Already an OpenAI object, keep as is
serializable_tool_calls.append(tc)
messages.append({
"role": "assistant",
"content": final_response.choices[0].message.content,
"tool_calls": serializable_tool_calls
})
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
arguments = json.loads(tool_call.function["arguments"])
else:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
if verbose and console:
console.print(f"[bold]Calling function:[/bold] {function_name}")
console.print(f"[dim]Arguments:[/dim] {arguments}")
# Execute the tool (async)
if asyncio.iscoroutinefunction(execute_tool_fn):
tool_result = await execute_tool_fn(function_name, arguments)
else:
# Run sync function in executor
loop = asyncio.get_event_loop()
tool_result = await loop.run_in_executor(
None,
lambda: execute_tool_fn(function_name, arguments)
)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
if verbose and console:
console.print(f"[dim]Result:[/dim] {results_str}")
messages.append({
"role": "tool",
"tool_call_id": tool_call.id if hasattr(tool_call, 'id') else tool_call['id'],
"content": results_str
})
# Check if we should continue (for tools like sequential thinking)
should_continue = False
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
arguments = json.loads(tool_call.function["arguments"])
else:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
# For sequential thinking tool, check if nextThoughtNeeded is True
if function_name == "sequentialthinking" and arguments.get("nextThoughtNeeded", False):
should_continue = True
break
if not should_continue:
# Get final response after tool calls
if stream:
final_response = await self.process_stream_response_async(
messages=messages,
model=model,
temperature=temperature,
tools=formatted_tools,
start_time=start_time,
console=console,
display_fn=display_fn,
reasoning_steps=reasoning_steps,
**kwargs
)
else:
final_response = await self.acreate_completion(
messages=messages,
model=model,
temperature=temperature,
stream=False,
**kwargs
)
break
iteration_count += 1
else:
# No tool calls, we're done
break
return final_response
def parse_structured_output(
self,
messages: List[Dict[str, Any]],
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.0.114"
version = "0.0.116"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
requires-python = ">=3.10"
authors = [
+1 -1
View File
@@ -2375,7 +2375,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.114"
version = "0.0.116"
source = { editable = "." }
dependencies = [
{ name = "mcp" },
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.41.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.41.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.43.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.43.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.2.41 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.2.43 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+4 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "PraisonAI"
version = "2.2.41"
version = "2.2.43"
description = "PraisonAI is an AI Agents Framework with Self Reflection. PraisonAI application combines PraisonAI Agents, AutoGen, and CrewAI into a low-code solution for building and managing multi-agent LLM systems, focusing on simplicity, customisation, and efficient human-agent collaboration."
readme = "README.md"
license = ""
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.0.114",
"praisonaiagents>=0.0.116",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
@@ -95,7 +95,7 @@ autogen = ["pyautogen>=0.2.19", "praisonai-tools>=0.0.15", "crewai"]
[tool.poetry]
name = "PraisonAI"
version = "2.2.41"
version = "2.2.43"
description = "PraisonAI is an AI Agents Framework with Self Reflection. PraisonAI application combines PraisonAI Agents, AutoGen, and CrewAI into a low-code solution for building and managing multi-agent LLM systems, focusing on simplicity, customisation, and efficient human-agent collaboration."
authors = ["Mervin Praison"]
license = ""
@@ -113,7 +113,7 @@ python = ">=3.10,<3.13"
rich = ">=13.7"
markdown = ">=3.5"
pyparsing = ">=3.0.0"
praisonaiagents = ">=0.0.114"
praisonaiagents = ">=0.0.116"
python-dotenv = ">=0.19.0"
instructor = ">=1.3.3"
PyYAML = ">=6.0"
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""
Test script to verify the OpenAI refactoring works correctly.
"""
import asyncio
from praisonaiagents import Agent
from praisonaiagents.llm import (
get_openai_client,
ChatCompletionMessage,
Choice,
CompletionUsage,
ChatCompletion,
ToolCall,
process_stream_chunks
)
def test_data_classes():
"""Test that data classes are properly imported and work"""
print("Testing data classes...")
# Create a message
msg = ChatCompletionMessage(
content="Hello, world!",
role="assistant"
)
assert msg.content == "Hello, world!"
assert msg.role == "assistant"
print("✓ ChatCompletionMessage works")
# Create a choice
choice = Choice(
finish_reason="stop",
index=0,
message=msg
)
assert choice.finish_reason == "stop"
assert choice.message.content == "Hello, world!"
print("✓ Choice works")
# Create a tool call
tool_call = ToolCall(
id="call_123",
type="function",
function={"name": "test_tool", "arguments": "{}"}
)
assert tool_call.id == "call_123"
assert tool_call.function["name"] == "test_tool"
print("✓ ToolCall works")
print("All data classes test passed!\n")
def test_openai_client():
"""Test that OpenAI client is properly initialized"""
print("Testing OpenAI client...")
try:
# This might fail if OPENAI_API_KEY is not set, which is OK for testing
client = get_openai_client()
print("✓ OpenAI client created successfully")
# Test build_messages method
messages, original = client.build_messages(
prompt="Test prompt",
system_prompt="You are a helpful assistant"
)
assert len(messages) == 2
assert messages[0]["role"] == "system"
assert messages[1]["role"] == "user"
print("✓ build_messages method works")
# Test format_tools method (with no tools)
tools = client.format_tools(None)
assert tools is None
print("✓ format_tools method works")
except ValueError as e:
if "OPENAI_API_KEY" in str(e):
print("⚠ OpenAI client requires API key (expected in test environment)")
else:
raise
print("OpenAI client tests completed!\n")
def test_agent_integration():
"""Test that Agent class works with the refactored code"""
print("Testing Agent integration...")
try:
# Create a simple agent
agent = Agent(
name="Test Agent",
role="Tester",
goal="Test the refactored code",
instructions="You are a test agent"
)
print("✓ Agent created successfully")
# Test _build_messages
messages, original = agent._build_messages(
prompt="Test prompt",
temperature=0.5
)
assert len(messages) >= 1
assert messages[-1]["content"] == "Test prompt"
print("✓ Agent._build_messages works")
# Test _format_tools_for_completion
def sample_tool():
"""A sample tool for testing"""
pass
formatted = agent._format_tools_for_completion([sample_tool])
assert isinstance(formatted, list)
print("✓ Agent._format_tools_for_completion works")
except Exception as e:
print(f"⚠ Agent integration test failed: {e}")
# This is OK if dependencies are missing
print("Agent integration tests completed!\n")
async def test_async_functionality():
"""Test async functionality"""
print("Testing async functionality...")
try:
client = get_openai_client()
# Test that async client can be accessed
async_client = client.async_client
print("✓ Async client accessible")
# Test build_messages (which is sync but used in async context)
messages, _ = client.build_messages("Test async")
assert len(messages) >= 1
print("✓ build_messages works in async context")
except ValueError as e:
if "OPENAI_API_KEY" in str(e):
print("⚠ Async tests require API key")
else:
raise
print("Async functionality tests completed!\n")
def main():
"""Run all tests"""
print("=" * 50)
print("OpenAI Refactoring Test Suite")
print("=" * 50)
print()
# Run sync tests
test_data_classes()
test_openai_client()
test_agent_integration()
# Run async tests
asyncio.run(test_async_functionality())
print("=" * 50)
print("All tests completed!")
print("=" * 50)
if __name__ == "__main__":
main()
+5 -5
View File
@@ -3931,7 +3931,7 @@ wheels = [
[[package]]
name = "praisonai"
version = "2.2.41"
version = "2.2.43"
source = { editable = "." }
dependencies = [
{ name = "instructor" },
@@ -4073,7 +4073,7 @@ requires-dist = [
{ name = "plotly", marker = "extra == 'realtime'", specifier = ">=5.24.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.0.15" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.0.15" },
{ name = "praisonaiagents", specifier = ">=0.0.114" },
{ name = "praisonaiagents", specifier = ">=0.0.116" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = ">=0.2.19" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4130,7 +4130,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.114"
version = "0.0.116"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mcp" },
@@ -4139,9 +4139,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/c5/51b821f7ffd1450242a2916781f0f1426924b074b3c994f42a1e293f842d/praisonaiagents-0.0.114.tar.gz", hash = "sha256:adc137e372296eb29a9fb3472d830d39fa4397f1f8a79ce4c9093d796d6c424b", size = 168621 }
sdist = { url = "https://files.pythonhosted.org/packages/5e/3b/61a7b4b57817b2d5eabdac89df126ef39ba06c7a43d7b17786d7d6882f44/praisonaiagents-0.0.116.tar.gz", hash = "sha256:6e5de5ece6c2550fee9a59cf3d0c9a8da09cd22acfaf8037873b7649493962ca", size = 172642 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/47/d5a125dc9eec338bcb2af0244fce38fb8d182c9ca67ce8d201c638b85cdb/praisonaiagents-0.0.114-py3-none-any.whl", hash = "sha256:59e35296f25ff95785071bc84e86a95e304298ea5afafd73f49c327fe054bc0d", size = 189030 },
{ url = "https://files.pythonhosted.org/packages/21/4f/5fbe1e129f5b0ab7386e17dbb4f7cc97ec896291db82b601605d4721616d/praisonaiagents-0.0.116-py3-none-any.whl", hash = "sha256:2449de40da7653100b247c3338afe0dcde3b0dc4227c3edd0111e24b4f5083ab", size = 193268 },
]
[[package]]