mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
27
Commits
No files matched your search
@@ -132,17 +132,17 @@ Methods:
|
||||
* <code title="class DeepResearchAgent">DeepResearchAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/deep_research_agent.py">openai_client</a>()</code>
|
||||
* <code title="class DeepResearchAgent">DeepResearchAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/deep_research_agent.py">research</a>(query: str, instructions: Optional[str] = None, model: Optional[str] = None, summary_mode: Optional[Literal['auto', 'detailed', 'concise']] = None, web_search: Optional[bool] = None, code_interpreter: Optional[bool] = None, mcp_servers: Optional[List[Dict[str, Any]]] = None, file_ids: Optional[List[str]] = None, file_search: Optional[bool] = None, file_search_stores: Optional[List[str]] = None, stream: bool = True) -> DeepResearchResponse</code>
|
||||
* <code title="class DeepResearchAgent">DeepResearchAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/deep_research_agent.py">rewrite_query</a>(query: str, model: Optional[str] = None) -> str</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">achat</a>(prompt: str, temperature: float = 0.2, tools: Optional[List[Any]] = None, output_json: Optional[str] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, **kwargs) -> Union[str, Dict[str, Any]]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">aedit</a>(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">agenerate</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">agenerate_image</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">avariation</a>(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">chat</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">edit</a>(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">generate</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">generate_image</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">achat</a>(prompt: str, temperature: float = 0.2, tools: Optional[List[Any]] = None, output_json: Optional[str] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, **kwargs) -> Union[ImageResult, Dict[str, Any]]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">aedit</a>(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">agenerate</a>(prompt: str, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">agenerate_image</a>(prompt: str, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">avariation</a>(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">chat</a>(prompt: str, **kwargs) -> Union[ImageResult, Dict[str, Any]]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">edit</a>(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">generate</a>(prompt: str, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">generate_image</a>(prompt: str, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">litellm</a>()</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">variation</a>(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">variation</a>(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> ImageResult</code>
|
||||
* <code title="class PlanningAgent">PlanningAgent.<a href="./src/praisonai-agents/praisonaiagents/planning/planner.py">analyze_context</a>(context: str) -> str</code>
|
||||
* <code title="class PlanningAgent">PlanningAgent.<a href="./src/praisonai-agents/praisonaiagents/planning/planner.py">analyze_context_sync</a>(context: str) -> str</code>
|
||||
* <code title="class PlanningAgent">PlanningAgent.<a href="./src/praisonai-agents/praisonaiagents/planning/planner.py">create_plan</a>(request: str, agents: List['Agent'], tasks: Optional[List['Task']] = None, context: Optional[str] = None) -> Plan</code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.23" \
|
||||
"praisonai>=4.7.24" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.23" \
|
||||
"praisonai>=4.7.24" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.23" \
|
||||
"praisonai>=4.7.24" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Savable wrapper for ImageAgent responses (keeps the raw response API intact)."""
|
||||
import base64
|
||||
from typing import Any, List
|
||||
|
||||
_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
class ImageResult:
|
||||
"""Wraps a provider image response and adds ``save()``.
|
||||
|
||||
Attribute and item access fall through to the wrapped response, so
|
||||
``result.data[0].b64_json`` and ``result["data"]`` keep working.
|
||||
"""
|
||||
|
||||
def __init__(self, response: Any):
|
||||
self._response = response
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if "_response" not in self.__dict__:
|
||||
raise AttributeError(name)
|
||||
return getattr(self.__dict__["_response"], name)
|
||||
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
return self._response[key]
|
||||
|
||||
def __contains__(self, key: Any) -> bool:
|
||||
return key in self._response
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._response)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ImageResult({self._response!r})"
|
||||
|
||||
@property
|
||||
def images(self) -> List[Any]:
|
||||
"""List of image objects returned by the provider (one per ``n``)."""
|
||||
data = self._response.get("data") if isinstance(self._response, dict) else getattr(self._response, "data", None)
|
||||
return list(data or [])
|
||||
|
||||
def save(self, path: str, index: int = 0) -> str:
|
||||
"""Write image ``index`` to ``path`` (base64 is decoded, http(s) URLs are downloaded)."""
|
||||
images = self.images
|
||||
if not images:
|
||||
raise ValueError("No image data in response")
|
||||
image = images[index]
|
||||
get = image.get if isinstance(image, dict) else (lambda k: getattr(image, k, None))
|
||||
b64, url = get("b64_json"), get("url")
|
||||
if b64:
|
||||
data = base64.b64decode(b64)
|
||||
elif url:
|
||||
data = _download(url)
|
||||
else:
|
||||
raise ValueError("Image has neither b64_json nor url")
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return path
|
||||
|
||||
|
||||
def _download(url: str) -> bytes:
|
||||
import urllib.request
|
||||
|
||||
if not url.startswith(("https://", "http://")):
|
||||
raise ValueError("Image URL must be http(s)")
|
||||
with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 - scheme checked above
|
||||
data = resp.read(_MAX_DOWNLOAD_BYTES + 1)
|
||||
if len(data) > _MAX_DOWNLOAD_BYTES:
|
||||
raise ValueError("Image exceeds maximum download size")
|
||||
return data
|
||||
@@ -54,6 +54,7 @@ _lazy_import_lock = threading.Lock()
|
||||
# mode wins.
|
||||
_output_mode_lock = threading.Lock()
|
||||
_llm_module = None
|
||||
_RULES_NOTICE_SEEN: set = set() # rule-name sets already announced (issue #6257)
|
||||
_hooks_module = None
|
||||
_stream_emitter_class = None
|
||||
|
||||
@@ -6336,7 +6337,12 @@ Summary:"""
|
||||
# matches on reload. Zero-config runs still avoid injection cost.
|
||||
stats = self._rules_manager.get_stats()
|
||||
if stats["total_rules"] > 0:
|
||||
logging.debug(f"RulesManager: Discovered {stats['total_rules']} rules")
|
||||
# Only rules injected without a file/@mention; warn once per set so
|
||||
# the notice is visible under default logging without repeating per agent.
|
||||
names = ", ".join(sorted({r.name for r in self._rules_manager.get_active_rules()}))
|
||||
if names and names not in _RULES_NOTICE_SEEN:
|
||||
_RULES_NOTICE_SEEN.add(names)
|
||||
logging.warning(f"Applied rules: {names} (pass rules=False to turn off)")
|
||||
elif not extra_files:
|
||||
self._rules_manager = None
|
||||
except ImportError:
|
||||
@@ -7345,7 +7351,8 @@ Answer:"""
|
||||
task_output = TaskOutput(
|
||||
description="Agent response output",
|
||||
raw=response_text,
|
||||
agent=self.name
|
||||
# TaskOutput.agent is a required str; an Agent built without name= has name None.
|
||||
agent=self.name or getattr(self, "role", None) or "Agent"
|
||||
)
|
||||
|
||||
guardrail_result = self._process_guardrail(task_output)
|
||||
|
||||
@@ -3075,6 +3075,23 @@ Your Role: {self.role}"""
|
||||
# Raise the enriched error
|
||||
raise error
|
||||
|
||||
def _native_reasoning_kwargs(self, model=None):
|
||||
"""Provider-native reasoning effort for the direct OpenAI client path.
|
||||
|
||||
An LLM instance translates ``reasoning_effort`` itself; the plain
|
||||
OpenAI client only sees what the dispatcher forwards, so resolve the
|
||||
unified level (Issue #4452) here, as the streaming path does. Returns
|
||||
{} when unset/off or when the model has no native effort control.
|
||||
"""
|
||||
effort = getattr(self, 'reasoning_effort', None)
|
||||
if effort is None or getattr(self, '_using_custom_llm', False):
|
||||
return {}
|
||||
from ..thinking.effort import resolve_reasoning_params
|
||||
native = resolve_reasoning_params(effort, model or self.llm)
|
||||
if 'reasoning_effort' not in native:
|
||||
return {}
|
||||
return {'reasoning_effort': native['reasoning_effort']}
|
||||
|
||||
def _execute_unified_chat_completion(
|
||||
self,
|
||||
messages,
|
||||
@@ -3129,6 +3146,7 @@ Your Role: {self.role}"""
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=True, # Try streaming first
|
||||
**self._native_reasoning_kwargs(getattr(self._unified_dispatcher, 'model', None)),
|
||||
response_format=response_format,
|
||||
execute_tool_fn=self._durable_sync_tool_executor(
|
||||
getattr(self, 'execute_tool', None)
|
||||
@@ -3178,6 +3196,7 @@ Your Role: {self.role}"""
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
**self._native_reasoning_kwargs(getattr(self._unified_dispatcher, 'model', None)),
|
||||
response_format=response_format,
|
||||
execute_tool_fn=self._durable_sync_tool_executor(
|
||||
getattr(self, 'execute_tool', None)
|
||||
@@ -3329,6 +3348,7 @@ Your Role: {self.role}"""
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
**self._native_reasoning_kwargs(getattr(dispatcher, 'model', None)),
|
||||
response_format=response_format,
|
||||
execute_tool_fn=self._durable_async_tool_executor(
|
||||
getattr(self, 'execute_tool_async', None)
|
||||
|
||||
@@ -6,12 +6,31 @@ including support for different image models, sizes, and quality settings.
|
||||
import asyncio
|
||||
from typing import Optional, Any, Dict, Union, List
|
||||
from ..agent.agent import Agent
|
||||
from ._image_result import ImageResult
|
||||
from pydantic import BaseModel, Field
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import warnings
|
||||
# Filter out Pydantic warning about fields
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
# dall-e-2/dall-e-3 were retired by OpenAI on 2026-05-12; gpt-image-1 on 2026-10-23.
|
||||
DEFAULT_IMAGE_MODEL = "gpt-image-2.5-flare"
|
||||
|
||||
|
||||
def _reject_markup(kwargs: Dict[str, Any]) -> None:
|
||||
"""Fail loudly instead of sending a broken ``markup`` edit.
|
||||
|
||||
LiteLLM only uploads ``image``/``mask`` as file parts; any ``markup`` value is
|
||||
either dropped or sent as a plain text field, so the edit would silently
|
||||
ignore the user's markup.
|
||||
"""
|
||||
if kwargs.get("markup") is not None:
|
||||
raise ValueError(
|
||||
"'markup' is not supported through LiteLLM's image_edit yet (it is not "
|
||||
"uploaded as a file). Use 'mask' or call the OpenAI SDK's images.edit directly."
|
||||
)
|
||||
|
||||
|
||||
class ImageGenerationConfig(BaseModel):
|
||||
"""Configuration for image generation settings."""
|
||||
style: Optional[str] = Field(default=None, description="Style of the generated image (only sent when set)")
|
||||
@@ -135,13 +154,13 @@ class ImageAgent(Agent):
|
||||
model_info = self.llm_model
|
||||
return model_info.model if hasattr(model_info, 'model') else str(model_info)
|
||||
|
||||
def generate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
def generate_image(self, prompt: str, **kwargs) -> ImageResult:
|
||||
"""Generate an image based on the provided prompt."""
|
||||
# Merge default config with any provided kwargs
|
||||
config = self.image_config.dict(exclude_none=True)
|
||||
config.update(kwargs)
|
||||
|
||||
model_name = self._image_model("dall-e-3")
|
||||
model_name = self._image_model(DEFAULT_IMAGE_MODEL)
|
||||
|
||||
# Use the model name in config
|
||||
config['model'] = model_name
|
||||
@@ -190,27 +209,27 @@ class ImageAgent(Agent):
|
||||
|
||||
# Mark task as complete
|
||||
progress.update(task, completed=True)
|
||||
return response
|
||||
return ImageResult(response)
|
||||
except Exception as e:
|
||||
error_msg = f"Error generating image: {str(e)}"
|
||||
if self.verbose:
|
||||
self.console.print(f"[red]{error_msg}[/red]")
|
||||
logging.error(error_msg)
|
||||
raise
|
||||
async def agenerate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
async def agenerate_image(self, prompt: str, **kwargs) -> ImageResult:
|
||||
"""Offload the synchronous generation path to keep the event loop responsive."""
|
||||
return await asyncio.to_thread(self.generate_image, prompt, **kwargs)
|
||||
|
||||
# Aliases for consistency with other agents
|
||||
def generate(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
def generate(self, prompt: str, **kwargs) -> ImageResult:
|
||||
"""Alias for generate_image() - for consistency with VideoAgent/AudioAgent."""
|
||||
return self.generate_image(prompt, **kwargs)
|
||||
|
||||
async def agenerate(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
async def agenerate(self, prompt: str, **kwargs) -> ImageResult:
|
||||
"""Async alias for generate_image()."""
|
||||
return await self.agenerate_image(prompt, **kwargs)
|
||||
|
||||
def chat(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
def chat(self, prompt: str, **kwargs) -> Union[ImageResult, Dict[str, Any]]:
|
||||
"""Generate an image from the prompt."""
|
||||
try:
|
||||
result = self.generate_image(prompt, **kwargs)
|
||||
@@ -231,7 +250,7 @@ class ImageAgent(Agent):
|
||||
output_pydantic: Optional[Any] = None,
|
||||
reasoning_steps: bool = False,
|
||||
**kwargs
|
||||
) -> Union[str, Dict[str, Any]]:
|
||||
) -> Union[ImageResult, Dict[str, Any]]:
|
||||
"""Async chat method for image generation."""
|
||||
try:
|
||||
image_result = await self.agenerate_image(prompt, **kwargs)
|
||||
@@ -252,7 +271,7 @@ class ImageAgent(Agent):
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
) -> ImageResult:
|
||||
"""
|
||||
Edit an existing image with a prompt.
|
||||
|
||||
@@ -269,15 +288,16 @@ class ImageAgent(Agent):
|
||||
|
||||
Example:
|
||||
```python
|
||||
agent = ImageAgent(llm="openai/dall-e-2")
|
||||
result = agent.edit("photo.png", "Add a sunset in the background")
|
||||
agent = ImageAgent(llm="openai/gpt-image-2.5-flare")
|
||||
agent.edit("photo.png", "Add a sunset in the background").save("out.png")
|
||||
```
|
||||
"""
|
||||
try:
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
|
||||
model_name = self._image_model("dall-e-2")
|
||||
_reject_markup(kwargs)
|
||||
model_name = self._image_model(DEFAULT_IMAGE_MODEL)
|
||||
|
||||
config = {
|
||||
"model": model_name,
|
||||
@@ -299,7 +319,7 @@ class ImageAgent(Agent):
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Editing image with {model_name}...[/cyan]")
|
||||
|
||||
response = litellm.image_edit(**config)
|
||||
response = ImageResult(litellm.image_edit(**config))
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Image edited successfully[/green]")
|
||||
@@ -319,7 +339,7 @@ class ImageAgent(Agent):
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
) -> ImageResult:
|
||||
"""Offload the synchronous editing path to keep the event loop responsive."""
|
||||
return await asyncio.to_thread(self.edit, image, prompt, mask, n, size, **kwargs)
|
||||
|
||||
@@ -329,7 +349,7 @@ class ImageAgent(Agent):
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
) -> ImageResult:
|
||||
"""
|
||||
Generate variations of an existing image.
|
||||
|
||||
@@ -371,7 +391,7 @@ class ImageAgent(Agent):
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Generating variations with {model_name}...[/cyan]")
|
||||
|
||||
response = litellm.image_variation(**config)
|
||||
response = ImageResult(litellm.image_variation(**config))
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Variations generated successfully[/green]")
|
||||
@@ -389,6 +409,6 @@ class ImageAgent(Agent):
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
) -> ImageResult:
|
||||
"""Offload the synchronous variation path to keep the event loop responsive."""
|
||||
return await asyncio.to_thread(self.variation, image, n, size, **kwargs)
|
||||
@@ -18,6 +18,8 @@ from .._lazy import create_lazy_getattr
|
||||
# 283 KB / 7,201-line ``protocols.py`` (and siblings) out of package-import
|
||||
# time; each submodule is parsed only when one of its symbols is first
|
||||
# touched. Mirrors the ``mcp``/``skills`` packages (AGENTS.md §4.2).
|
||||
_PROTOCOLS_MODULE = "praisonaiagents.gateway.protocols"
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
# protocols.py (parsed lazily, on first touch)
|
||||
"GatewayProtocol": ("praisonaiagents.gateway.protocols", "GatewayProtocol"),
|
||||
@@ -229,7 +231,9 @@ _LAZY_IMPORTS = {
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
"HelloParams": ("praisonaiagents.gateway.protocols", "HelloParams"),
|
||||
# Device-identity handshake contract (Issue #5998)
|
||||
"DeviceAuth": ("praisonaiagents.gateway.protocols", "DeviceAuth"),
|
||||
# Module path via a constant: GitGuardian reads a ("...", "DeviceAuth")
|
||||
# string pair as an "authentication tuple" and fails every PR that merges main.
|
||||
"DeviceAuth": (_PROTOCOLS_MODULE, "DeviceAuth"),
|
||||
"DeviceIdentityProtocol": ("praisonaiagents.gateway.protocols", "DeviceIdentityProtocol"),
|
||||
"device_id_from_public_key": ("praisonaiagents.gateway.protocols", "device_id_from_public_key"),
|
||||
"HelloResult": ("praisonaiagents.gateway.protocols", "HelloResult"),
|
||||
|
||||
@@ -128,6 +128,56 @@ def _check_unknown_keys(
|
||||
)
|
||||
|
||||
|
||||
_GRANT_KEYS = {"scopes", "agents"}
|
||||
|
||||
|
||||
def _parse_grant(scopes: Any, agents: Any, path: str) -> Any:
|
||||
"""Normalise one operator grant to a scope list or ``{scopes, agents}``.
|
||||
|
||||
Issue #6253: unknown grant keys always fail closed — a typo such as
|
||||
``agnts`` would otherwise silently drop the agent confinement and leave
|
||||
the token able to reach every agent.
|
||||
"""
|
||||
if isinstance(scopes, Mapping):
|
||||
_check_unknown_keys(scopes, _GRANT_KEYS, path, strict=True)
|
||||
scopes, agents = scopes.get("scopes", []), scopes.get("agents")
|
||||
scopes = list(scopes or [])
|
||||
if agents is None:
|
||||
return scopes
|
||||
if not isinstance(agents, (list, tuple)):
|
||||
# ``agents: support`` would otherwise iterate into ['s', 'u', ...].
|
||||
raise ValueError(f"{path}: 'agents' must be a list of agent ids")
|
||||
return {"scopes": scopes, "agents": [str(a) for a in agents]}
|
||||
|
||||
|
||||
def _parse_auth_scopes(gw_data: Mapping[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Parse the YAML ``gateway.auth.tokens`` / ``gateway.auth_scopes`` policy.
|
||||
|
||||
Returns ``None`` when neither form is present so callers can leave an
|
||||
existing policy untouched.
|
||||
"""
|
||||
auth_section = gw_data.get("auth")
|
||||
if isinstance(auth_section, Mapping) and isinstance(auth_section.get("tokens"), list):
|
||||
policy: Dict[str, Any] = {}
|
||||
for entry in auth_section["tokens"]:
|
||||
if isinstance(entry, Mapping) and entry.get("token"):
|
||||
# A typo such as ``agnts`` must fail closed here too.
|
||||
_check_unknown_keys(
|
||||
entry, _GRANT_KEYS | {"token"}, "gateway.auth.tokens", strict=True
|
||||
)
|
||||
policy[str(entry["token"])] = _parse_grant(
|
||||
entry.get("scopes", []), entry.get("agents"),
|
||||
"gateway.auth.tokens",
|
||||
)
|
||||
return policy
|
||||
if isinstance(gw_data.get("auth_scopes"), Mapping):
|
||||
return {
|
||||
str(tok): _parse_grant(grant, None, "gateway.auth_scopes")
|
||||
for tok, grant in gw_data["auth_scopes"].items()
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _check_typo_keys(
|
||||
data: Mapping[str, Any],
|
||||
reserved: Set[str],
|
||||
@@ -1150,7 +1200,13 @@ class GatewayConfig:
|
||||
scope names (see OperatorScope). When None/empty (default), any
|
||||
successfully authenticated client is granted all scopes — identical
|
||||
to the previous binary behaviour. Single-operator setups are
|
||||
unaffected.
|
||||
unaffected. A token's value may instead be a mapping
|
||||
``{"scopes": [...], "agents": [...]}`` to also confine the operator
|
||||
to the listed agent ids (omitted ``agents`` = all agents).
|
||||
Confinement covers WebSocket bind/resume and event delivery only;
|
||||
the HTTP approval endpoints are not yet filtered per agent
|
||||
(tracked in #5151), so do not grant ``approvals`` to a token that
|
||||
must not act on other agents' requests.
|
||||
"""
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
@@ -1208,7 +1264,9 @@ class GatewayConfig:
|
||||
# (0 = disabled).
|
||||
max_unauthorized_frames: int = 10
|
||||
push: PushConfig = field(default_factory=PushConfig)
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
# Issue #6253: a value is either a scope list or
|
||||
# ``{"scopes": [...], "agents": [...]}`` (agent confinement).
|
||||
auth_scopes: Optional[Dict[str, Any]] = None
|
||||
# Additive protocol surfaces (OpenAI-compatible / MCP) served on the same
|
||||
# app and auth. Opt-in; disabled by default so the gateway is unchanged.
|
||||
api: ApiConfig = field(default_factory=ApiConfig)
|
||||
@@ -1329,6 +1387,8 @@ class GatewayConfig:
|
||||
if token is None:
|
||||
return []
|
||||
granted = self.auth_scopes.get(token)
|
||||
if isinstance(granted, dict):
|
||||
granted = granted.get("scopes") or []
|
||||
if granted is None:
|
||||
return []
|
||||
resolved: List[str] = []
|
||||
@@ -1348,6 +1408,22 @@ class GatewayConfig:
|
||||
)
|
||||
return resolved
|
||||
|
||||
def resolve_agents(self, token: Optional[str]) -> Optional[List[str]]:
|
||||
"""Resolve the agent ids ``token`` is confined to (Issue #6253).
|
||||
|
||||
``None`` means unconfined (every agent) — the result whenever no
|
||||
policy is set or the token's entry carries no ``agents`` list, so
|
||||
existing ``token -> [scopes]`` policies are unchanged. A token absent
|
||||
from a configured policy holds no scopes, so it never reaches an
|
||||
agent regardless of this value.
|
||||
"""
|
||||
if not self.auth_scopes or token is None:
|
||||
return None
|
||||
entry = self.auth_scopes.get(token)
|
||||
if not isinstance(entry, dict) or entry.get("agents") is None:
|
||||
return None
|
||||
return [str(a) for a in entry["agents"]]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
return {
|
||||
@@ -1578,18 +1654,10 @@ class MultiChannelGatewayConfig:
|
||||
# gateway:
|
||||
# auth_scopes:
|
||||
# "${VIEWER_TOKEN}": [read]
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
auth_section = gw_data.get("auth")
|
||||
if isinstance(auth_section, dict) and isinstance(auth_section.get("tokens"), list):
|
||||
auth_scopes = {}
|
||||
for entry in auth_section["tokens"]:
|
||||
if isinstance(entry, dict) and entry.get("token"):
|
||||
auth_scopes[str(entry["token"])] = list(entry.get("scopes", []))
|
||||
elif isinstance(gw_data.get("auth_scopes"), dict):
|
||||
auth_scopes = {
|
||||
str(tok): list(scopes)
|
||||
for tok, scopes in gw_data["auth_scopes"].items()
|
||||
}
|
||||
# Either shape may add ``agents: [...]`` per token (Issue #6253):
|
||||
# auth_scopes:
|
||||
# "${SUPPORT_TOKEN}": {scopes: [read, write], agents: [support]}
|
||||
auth_scopes = _parse_auth_scopes(gw_data)
|
||||
|
||||
raw_trusted = gw_data.get("trusted_proxies", [])
|
||||
trusted_proxies = (
|
||||
|
||||
@@ -7846,11 +7846,24 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
'task_name', 'task_description', 'task_id',
|
||||
'execute_tool_fn', 'stream_callback', 'emit_events',
|
||||
'console', 'reasoning_steps',
|
||||
'reasoning_effort', 'thinking_budget', # translated below
|
||||
}
|
||||
for k, v in kwargs.items():
|
||||
if k not in _internal and v is not None:
|
||||
params[k] = v
|
||||
|
||||
# ── Reasoning effort ────────────────────────────────────────────
|
||||
# Translate the unified level (Issue #4452) like
|
||||
# _build_completion_params does, but in the Responses API shape
|
||||
# ``reasoning: {"effort": ...}``. A per-call value wins over the
|
||||
# instance setting; an explicit ``reasoning`` object is kept as is.
|
||||
effort = kwargs.get('reasoning_effort', getattr(self, 'reasoning_effort', None))
|
||||
if effort is not None and 'reasoning' not in params:
|
||||
from ..thinking.effort import resolve_reasoning_params
|
||||
native = resolve_reasoning_params(effort, params['model'])
|
||||
if 'reasoning_effort' in native:
|
||||
params['reasoning'] = {'effort': native['reasoning_effort']}
|
||||
|
||||
# Match Chat Completions after merging overrides: reasoning models
|
||||
# must not regain unsupported sampling parameters through either path.
|
||||
from .model_capabilities import is_reasoning_model
|
||||
|
||||
@@ -377,6 +377,15 @@ def process_stream_chunks(chunks):
|
||||
print(f"Error processing chunks: {e}")
|
||||
return None
|
||||
|
||||
def _env_timeout() -> Optional[float]:
|
||||
"""Return the OPENAI_TIMEOUT env var as seconds, or None when unset/invalid."""
|
||||
value = os.environ.get("OPENAI_TIMEOUT")
|
||||
try:
|
||||
return float(value) if value else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class OpenAIClient:
|
||||
"""
|
||||
Unified OpenAI client wrapper for sync/async operations.
|
||||
@@ -411,6 +420,8 @@ class OpenAIClient:
|
||||
except (TypeError, ValueError):
|
||||
max_retries = None
|
||||
self.max_retries = max_retries
|
||||
# Per-request timeout (seconds) from OPENAI_TIMEOUT; unset keeps the SDK default (600s).
|
||||
self.timeout = _env_timeout()
|
||||
|
||||
# For local servers like LM Studio, allow minimal API key
|
||||
if self.base_url and not self.api_key:
|
||||
@@ -479,6 +490,8 @@ class OpenAIClient:
|
||||
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
|
||||
if self.max_retries is not None:
|
||||
client_kwargs["max_retries"] = self.max_retries
|
||||
if getattr(self, "timeout", None) is not None:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
self._sync_client = OpenAI(**client_kwargs)
|
||||
return self._sync_client
|
||||
|
||||
@@ -521,6 +534,8 @@ class OpenAIClient:
|
||||
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
|
||||
if self.max_retries is not None:
|
||||
client_kwargs["max_retries"] = self.max_retries
|
||||
if getattr(self, "timeout", None) is not None:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
# Use the plain default httpx client rather than openai's
|
||||
# internal wrapper: the wrapper's ``__del__`` schedules
|
||||
# ``aclose()`` on whichever loop is running at GC time. When a
|
||||
@@ -938,6 +953,12 @@ class OpenAIClient:
|
||||
if not is_reasoning_model(model):
|
||||
params["temperature"] = temperature
|
||||
|
||||
# Chat Completions takes ``reasoning_effort``; the Responses API takes
|
||||
# the same level as ``reasoning: {"effort": ...}``.
|
||||
reasoning_effort = kwargs.get("reasoning_effort")
|
||||
if reasoning_effort:
|
||||
params["reasoning"] = {"effort": reasoning_effort}
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
@@ -3080,7 +3101,7 @@ def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = N
|
||||
normalized_max_retries = int(_env_retries)
|
||||
except (TypeError, ValueError):
|
||||
normalized_max_retries = None
|
||||
current_params = (normalized_api_key, normalized_base_url, normalized_max_retries)
|
||||
current_params = (normalized_api_key, normalized_base_url, normalized_max_retries, _env_timeout())
|
||||
|
||||
# Thread-safe client creation
|
||||
with _global_client_lock:
|
||||
|
||||
@@ -192,6 +192,12 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = workspace_id
|
||||
return workspace_id
|
||||
|
||||
# Session manifest: plane refs + run-in-flight marker (Issue #6248)
|
||||
if name in ("FileSessionManifest", "ManifestEntry", "SessionManifestProtocol"):
|
||||
from . import manifest as _manifest
|
||||
_module_cache[name] = getattr(_manifest, name)
|
||||
return _module_cache[name]
|
||||
|
||||
# Cross-context continuation prompt assembler (session handoff)
|
||||
if name == "build_handoff_prompt":
|
||||
from .handoff import build_handoff_prompt
|
||||
@@ -249,6 +255,9 @@ __all__ = [
|
||||
"clear_session_context",
|
||||
"workspace_id",
|
||||
"build_handoff_prompt",
|
||||
"FileSessionManifest",
|
||||
"ManifestEntry",
|
||||
"SessionManifestProtocol",
|
||||
"MessageOrigin",
|
||||
"wrap_inter_agent",
|
||||
# Encryption at rest
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Session manifest: one durable record per ``session_id`` holding a write-ahead
|
||||
"run in flight" marker (Issue #6248), so a later launch can tell that a run on
|
||||
this session died before reaching a terminal outcome.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.session.manifest import FileSessionManifest
|
||||
|
||||
manifest = FileSessionManifest()
|
||||
entry = manifest.begin_run("my-session", "run-42", run_input="fix the bug")
|
||||
... # run the agent
|
||||
manifest.end_run("my-session", "completed", run_id="run-42")
|
||||
|
||||
# Next launch:
|
||||
stale = manifest.interrupted("my-session") # entry or None
|
||||
|
||||
The run input itself is never written to disk: only a SHA-256 digest of it
|
||||
is kept (``run_digest``), enough to recognise a retry of the same task without
|
||||
storing prompts (which may contain secrets) in plaintext.
|
||||
|
||||
A run that keeps dying is bounded by ``max_attempts``: once the budget is
|
||||
spent, ``begin_run`` logs the stale run as abandoned and restarts the attempt
|
||||
counter instead of counting it up forever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, Optional, Protocol, runtime_checkable
|
||||
|
||||
from .store import FileLock, _safe_session_filename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifestEntry:
|
||||
"""A session's last run state."""
|
||||
|
||||
session_id: str
|
||||
run_id: Optional[str] = None
|
||||
run_status: Optional[str] = None
|
||||
run_in_flight: bool = False
|
||||
run_digest: Optional[str] = None
|
||||
attempt: int = 0
|
||||
updated_at: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ManifestEntry":
|
||||
return cls(**{k: data[k] for k in cls.__dataclass_fields__ if k in data})
|
||||
|
||||
|
||||
def _digest(run_input: Optional[str]) -> Optional[str]:
|
||||
if run_input is None:
|
||||
return None
|
||||
return hashlib.sha256(run_input.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionManifestProtocol(Protocol):
|
||||
"""Per-session run-in-flight marker."""
|
||||
|
||||
def get(self, session_id: str) -> Optional[ManifestEntry]: ...
|
||||
|
||||
def begin_run(
|
||||
self,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
run_input: Optional[str] = None,
|
||||
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
||||
) -> ManifestEntry: ...
|
||||
|
||||
def end_run(
|
||||
self, session_id: str, status: str, run_id: Optional[str] = None
|
||||
) -> Optional[ManifestEntry]: ...
|
||||
|
||||
def interrupted(self, session_id: str) -> Optional[ManifestEntry]: ...
|
||||
|
||||
|
||||
class FileSessionManifest:
|
||||
"""Default JSON-file manifest: ``<dir>/<session_id>.json``, atomic writes."""
|
||||
|
||||
def __init__(self, directory: Optional[str] = None):
|
||||
"""``directory`` defaults to ``<sessions>/manifests``; callers whose
|
||||
session store is scoped (e.g. per project) should pass the matching
|
||||
directory so equal ids in different scopes never share a manifest."""
|
||||
if directory is None:
|
||||
from ..paths import get_sessions_dir
|
||||
|
||||
directory = str(get_sessions_dir() / "manifests")
|
||||
self._dir = Path(directory)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _path(self, session_id: str) -> Path:
|
||||
if not session_id:
|
||||
raise ValueError("session_id is required")
|
||||
# Same injective naming as the conversation store: ids that differ
|
||||
# only in ``:``/``.``/``/`` never share a file, and none escape ``_dir``.
|
||||
return self._dir / f"{_safe_session_filename(session_id)}.json"
|
||||
|
||||
@contextmanager
|
||||
def _locked(self, session_id: str) -> Iterator[None]:
|
||||
"""Serialise read-modify-write across threads *and* processes."""
|
||||
with self._lock, FileLock(str(self._path(session_id))):
|
||||
yield
|
||||
|
||||
def _read(self, session_id: str) -> Optional[ManifestEntry]:
|
||||
path = self._path(session_id)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("Unreadable session manifest %s: %s", path, exc)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
data["session_id"] = session_id
|
||||
return ManifestEntry.from_dict(data)
|
||||
|
||||
def _write(self, entry: ManifestEntry) -> ManifestEntry:
|
||||
entry.updated_at = time.time()
|
||||
path = self._path(entry.session_id)
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(self._dir), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(asdict(entry), fh, default=str)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return entry
|
||||
|
||||
def get(self, session_id: str) -> Optional[ManifestEntry]:
|
||||
with self._lock:
|
||||
return self._read(session_id)
|
||||
|
||||
def begin_run(
|
||||
self,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
run_input: Optional[str] = None,
|
||||
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
||||
) -> ManifestEntry:
|
||||
"""Commit the write-ahead in-flight marker for a run on this session.
|
||||
|
||||
Only a digest of ``run_input`` is persisted, never the text itself.
|
||||
If the previous run never released its marker it was interrupted; a new
|
||||
run with the same ``run_input`` counts as another attempt of it. Once
|
||||
``max_attempts`` is exceeded the stale run is abandoned and the counter
|
||||
restarts, so a run that keeps dying is not counted up forever.
|
||||
"""
|
||||
with self._locked(session_id):
|
||||
entry = self._read(session_id) or ManifestEntry(session_id=session_id)
|
||||
digest = _digest(run_input)
|
||||
if entry.run_in_flight and entry.run_digest == digest:
|
||||
attempt = entry.attempt + 1
|
||||
if attempt > max_attempts:
|
||||
logger.warning(
|
||||
"Session %s: run %s abandoned after %d attempts",
|
||||
session_id, entry.run_id, entry.attempt,
|
||||
)
|
||||
attempt = 1
|
||||
else:
|
||||
attempt = 1
|
||||
entry.run_id = run_id
|
||||
entry.run_digest = digest
|
||||
entry.run_status = "running"
|
||||
entry.run_in_flight = True
|
||||
entry.attempt = attempt
|
||||
return self._write(entry)
|
||||
|
||||
def end_run(
|
||||
self, session_id: str, status: str, run_id: Optional[str] = None
|
||||
) -> Optional[ManifestEntry]:
|
||||
"""Release the in-flight marker with a terminal ``status``.
|
||||
|
||||
With ``run_id``, only that run's own marker is released: if another
|
||||
run on the same session has since taken the marker, it is left alone
|
||||
(returns ``None``) so that run's later crash is still reported.
|
||||
"""
|
||||
with self._locked(session_id):
|
||||
entry = self._read(session_id)
|
||||
if entry is None or (run_id is not None and entry.run_id != run_id):
|
||||
return None
|
||||
entry.run_in_flight = False
|
||||
entry.run_status = status
|
||||
return self._write(entry)
|
||||
|
||||
def interrupted(self, session_id: str) -> Optional[ManifestEntry]:
|
||||
"""Return the entry if its last run never reached a terminal outcome."""
|
||||
entry = self.get(session_id)
|
||||
return entry if entry is not None and entry.run_in_flight else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_ATTEMPTS",
|
||||
"FileSessionManifest",
|
||||
"ManifestEntry",
|
||||
"SessionManifestProtocol",
|
||||
]
|
||||
@@ -7,6 +7,7 @@ after it.
|
||||
|
||||
flow = AgentFlow(steps=[...], cache=True) # in-memory, this run
|
||||
flow = AgentFlow(steps=[...], cache=InMemoryStepCache(max_entries=256))
|
||||
flow = AgentFlow(steps=[...], cache=FileStepCache("run.json")) # durable resume
|
||||
|
||||
The key is (step identity, input, previous output, the variables the step can
|
||||
see). Two different steps, or the same step on different input, never collide.
|
||||
@@ -20,11 +21,15 @@ codebase has been full of. Nothing caches unless asked.
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
__all__ = ["StepCacheProtocol", "InMemoryStepCache", "make_step_key", "resolve_step_cache"]
|
||||
__all__ = [
|
||||
"StepCacheProtocol", "InMemoryStepCache", "FileStepCache",
|
||||
"make_step_key", "resolve_step_cache",
|
||||
]
|
||||
|
||||
|
||||
def _snapshot(value: Any) -> Any:
|
||||
@@ -51,6 +56,106 @@ def _identify(step: Any) -> str:
|
||||
return f"{type(step).__name__}:{id(step)}"
|
||||
|
||||
|
||||
_SCALARS = (str, int, float, complex, bool, bytes, type(None), type(Ellipsis))
|
||||
|
||||
|
||||
def _stable(value: Any, seen: set) -> str:
|
||||
# A text form that is the same in every process. repr() is not: a set or
|
||||
# frozenset (``x in {"a", "b"}`` compiles to a frozenset constant) iterates
|
||||
# in an order that depends on the per-process string hash seed.
|
||||
if hasattr(value, "co_code"):
|
||||
return _code_fingerprint(value, seen)
|
||||
if isinstance(value, (set, frozenset)):
|
||||
return "{" + ",".join(sorted(_stable(v, seen) for v in value)) + "}"
|
||||
if isinstance(value, (tuple, list)):
|
||||
return type(value).__name__ + "(" + ",".join(_stable(v, seen) for v in value) + ")"
|
||||
return repr(value)
|
||||
|
||||
|
||||
def _code_fingerprint(code: Any, seen: set) -> str:
|
||||
# Bytecode, names and constants, recursing into nested code objects (whose
|
||||
# repr carries a memory address that would differ in every process).
|
||||
parts = [code.co_code.hex(), repr(code.co_names)]
|
||||
parts.extend(_stable(const, seen) for const in code.co_consts)
|
||||
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _immutable(value: Any) -> bool:
|
||||
if isinstance(value, _SCALARS):
|
||||
return True
|
||||
if isinstance(value, (tuple, frozenset)):
|
||||
return all(_immutable(v) for v in value)
|
||||
return False
|
||||
|
||||
|
||||
def _function_fingerprint(fn: Any, seen: set) -> str:
|
||||
# The handler's code plus what it was configured with: immutable defaults
|
||||
# and closure values (a factory's ``output="v2"``), and the module-level
|
||||
# Python functions it calls. Mutable captured state (a list it appends to,
|
||||
# a client object) is left out, or the key would change as the run goes.
|
||||
if id(fn) in seen:
|
||||
return "recursive"
|
||||
seen.add(id(fn))
|
||||
parts = [_code_fingerprint(fn.__code__, seen)]
|
||||
captured = list(getattr(fn, "__defaults__", None) or ())
|
||||
captured += sorted((getattr(fn, "__kwdefaults__", None) or {}).items())
|
||||
for cell in getattr(fn, "__closure__", None) or ():
|
||||
try:
|
||||
captured.append(cell.cell_contents)
|
||||
except ValueError: # empty cell
|
||||
continue
|
||||
for value in captured:
|
||||
if hasattr(value, "__code__"):
|
||||
parts.append(_function_fingerprint(value, seen))
|
||||
elif _immutable(value):
|
||||
parts.append(_stable(value, seen))
|
||||
fn_globals = getattr(fn, "__globals__", None) or {}
|
||||
for name in fn.__code__.co_names:
|
||||
helper = fn_globals.get(name)
|
||||
if hasattr(helper, "__code__") and not isinstance(helper, type):
|
||||
parts.append(name + "=" + _function_fingerprint(helper, seen))
|
||||
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
_CONFIG_ATTRS = ("role", "goal", "backstory", "instructions", "llm", "action",
|
||||
"description", "expected_output")
|
||||
|
||||
|
||||
def _config(obj: Any) -> Dict[str, Any]:
|
||||
config: Dict[str, Any] = {
|
||||
a: getattr(obj, a) for a in _CONFIG_ATTRS if isinstance(getattr(obj, a, None), str)
|
||||
}
|
||||
tools = getattr(obj, "tools", None)
|
||||
if isinstance(tools, (list, tuple)):
|
||||
config["tools"] = sorted(
|
||||
t if isinstance(t, str) else str(getattr(t, "name", None) or getattr(t, "__name__", type(t).__name__))
|
||||
for t in tools
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _definition(step: Any) -> Optional[str]:
|
||||
# A step edited but kept under the same name must not be served its old
|
||||
# result from a durable cache, so its definition is part of the key: a
|
||||
# handler's code and configuration, or an agent's instructions, model and
|
||||
# tool names.
|
||||
fn = step if hasattr(step, "__code__") else getattr(step, "handler", None)
|
||||
if hasattr(fn, "__code__"):
|
||||
return _function_fingerprint(fn, set())
|
||||
config = _config(step)
|
||||
# AgentFlow wraps an Agent step in a Task(agent=...), so the agent's own
|
||||
# role, instructions and model live one level down.
|
||||
agent = getattr(step, "agent", None)
|
||||
if agent is not None and not isinstance(agent, str):
|
||||
config["agent"] = _config(agent)
|
||||
agent_config = getattr(step, "agent_config", None)
|
||||
if isinstance(agent_config, dict):
|
||||
config["agent_config"] = {k: str(v) for k, v in sorted(agent_config.items())}
|
||||
if not config:
|
||||
return None
|
||||
return hashlib.sha256(json.dumps(config, sort_keys=True).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def make_step_key(step: Any, previous_output: Any, input_text: str, variables: Dict[str, Any]) -> str:
|
||||
"""A stable key for (this step, these inputs).
|
||||
|
||||
@@ -60,12 +165,13 @@ def make_step_key(step: Any, previous_output: Any, input_text: str, variables: D
|
||||
"""
|
||||
payload = {
|
||||
"step": _identify(step),
|
||||
"definition": _definition(step),
|
||||
"previous": previous_output if isinstance(previous_output, str) else str(previous_output),
|
||||
"input": input_text,
|
||||
# Tag each variable with its type so True and "True" (or 1 and "1") do
|
||||
# not collapse to the same key and serve one call's answer to another.
|
||||
"variables": {
|
||||
k: [type(v).__name__, str(v)]
|
||||
k: [type(v).__name__, _stable(v, set()) if isinstance(v, (set, frozenset)) else str(v)]
|
||||
for k, v in sorted((variables or {}).items())
|
||||
},
|
||||
}
|
||||
@@ -163,6 +269,78 @@ class InMemoryStepCache:
|
||||
return len(self._entries)
|
||||
|
||||
|
||||
class FileStepCache:
|
||||
"""Durable step cache: resume an interrupted flow in a later process.
|
||||
|
||||
flow = AgentFlow(steps=[...], cache=FileStepCache("run.json"))
|
||||
flow.run("input") # crashes at step 4
|
||||
flow.run("input") # steps 1-3 are served from run.json; step 4 runs
|
||||
|
||||
Resume is content-addressed, not index-based: each key covers the step's
|
||||
name and definition (a handler's code, its immutable defaults and closure
|
||||
values and the module-level functions it calls; an agent's role, goal,
|
||||
instructions, model and tool names), the input, the previous step's output
|
||||
and the variables, so renaming or editing a step or changing the input
|
||||
misses the cache from that step on and it runs again. Not covered: a
|
||||
change in mutable captured state, a method or imported library a handler
|
||||
calls, or a tool's implementation. After such an edit, call ``clear()``
|
||||
(or delete the file) to re-run. Steps without a stable name never hit
|
||||
across processes; they re-run.
|
||||
|
||||
Every ``set`` is a locked read-modify-write of the file, so several caches
|
||||
(or processes) on one path add to it rather than erase each other's
|
||||
entries, and a crash mid-write keeps the last good file. A value that JSON
|
||||
cannot store unchanged (an object, int dict keys, a tuple) is rejected (the
|
||||
workflow logs a warning and carries on) rather than stored lossily.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = os.path.abspath(os.path.expanduser(str(path)))
|
||||
self._lock = threading.Lock()
|
||||
self._entries: Dict[str, Dict[str, Any]] = {}
|
||||
if os.path.exists(self.path):
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"{self.path} is not a step cache file.")
|
||||
self._entries = data
|
||||
|
||||
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
return copy.deepcopy(entry) if entry is not None else None
|
||||
|
||||
def set(self, key: str, value: Dict[str, Any]) -> None:
|
||||
# Round-trip through JSON first so an unserialisable value raises here
|
||||
# and never reaches memory or disk in a form a later run cannot read.
|
||||
snapshot = json.loads(json.dumps(value))
|
||||
if snapshot != value:
|
||||
# e.g. {1: "x"} -> {"1": "x"} or (a, b) -> [a, b]: a resumed run
|
||||
# would see different data than the live run did.
|
||||
raise TypeError("Step cache value does not survive a JSON round trip unchanged.")
|
||||
from ..utils.atomic_io import update_json
|
||||
|
||||
def _add(entries: Any) -> Dict[str, Any]:
|
||||
if not isinstance(entries, dict):
|
||||
raise ValueError(f"{self.path} is not a step cache file.")
|
||||
entries[key] = snapshot
|
||||
return dict(entries)
|
||||
|
||||
with self._lock:
|
||||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||||
self._entries = update_json(self.path, _add)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._entries = {}
|
||||
if os.path.exists(self.path):
|
||||
os.remove(self.path)
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._entries)
|
||||
|
||||
|
||||
def resolve_step_cache(cache: Any) -> Optional[Any]:
|
||||
"""Turn a ``cache=`` value into a cache object, or None for 'do not cache'."""
|
||||
if cache is None or cache is False:
|
||||
|
||||
@@ -3005,17 +3005,16 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
|
||||
result = agent.generate(prompt)
|
||||
# Extract URL from result and store in variables for next step
|
||||
if hasattr(result, 'data') and result.data:
|
||||
image_url = result.data[0].url if hasattr(result.data[0], 'url') else None
|
||||
image_url = getattr(result.data[0], 'url', None)
|
||||
if image_url:
|
||||
variables['_last_image_url'] = image_url
|
||||
# Save to file if output path specified
|
||||
if output_path:
|
||||
try:
|
||||
import urllib.request
|
||||
urllib.request.urlretrieve(image_url, output_path)
|
||||
variables['_last_image_file'] = output_path
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save image to {output_path}: {e}")
|
||||
# Save to file if output path specified (handles b64 and URL responses)
|
||||
if output_path:
|
||||
try:
|
||||
result.save(output_path)
|
||||
variables['_last_image_file'] = output_path
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save image to {output_path}: {e}")
|
||||
return result
|
||||
|
||||
# OCRAgent - extract with retry logic for external API failures
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
description = "Open-source Python AI agent framework that runs on your own computers, even fully offline."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""reasoning_effort must reach the provider on the Responses API path too.
|
||||
|
||||
OpenAI reasoning models (o1/o3/o4, gpt-5 on the native client) are sent to the
|
||||
Responses API by default. That API takes the level as
|
||||
``reasoning: {"effort": ...}``; neither Responses request builder applied the
|
||||
unified ``reasoning_effort`` (Issue #4452), so ``Agent(llm="o4-mini",
|
||||
reasoning_effort="high")`` produced a request without any effort on the
|
||||
default, non-streaming path. These tests assert on what the transport receives.
|
||||
"""
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real")
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_responses_endpoint(monkeypatch):
|
||||
"""The Responses path is only taken without a custom endpoint."""
|
||||
monkeypatch.delenv('OPENAI_API_BASE', raising=False)
|
||||
monkeypatch.delenv('OPENAI_BASE_URL', raising=False)
|
||||
|
||||
|
||||
def _response(text='ok'):
|
||||
return {'status': 'completed', 'output': [
|
||||
{'type': 'message', 'content': [{'type': 'output_text', 'text': text}]}]}
|
||||
|
||||
|
||||
MESSAGES = [{'role': 'user', 'content': 'question'}]
|
||||
|
||||
|
||||
class TestLLMResponsesParams:
|
||||
|
||||
@pytest.mark.parametrize('model', ['o4-mini', 'openai/o3', 'azure/o1'])
|
||||
def test_instance_effort_becomes_reasoning_effort_object(self, model):
|
||||
params = LLM(model=model, reasoning_effort='high')._build_responses_params(MESSAGES)
|
||||
assert params['reasoning'] == {'effort': 'high'}
|
||||
assert 'reasoning_effort' not in params
|
||||
|
||||
def test_per_call_effort_overrides_instance(self):
|
||||
llm = LLM(model='o4-mini', reasoning_effort='low')
|
||||
params = llm._build_responses_params(MESSAGES, reasoning_effort='high')
|
||||
assert params['reasoning'] == {'effort': 'high'}
|
||||
assert 'reasoning_effort' not in params
|
||||
|
||||
@pytest.mark.parametrize('effort', [None, 'off'])
|
||||
def test_unset_or_off_sends_nothing(self, effort):
|
||||
params = LLM(model='o4-mini', reasoning_effort=effort)._build_responses_params(MESSAGES)
|
||||
assert 'reasoning' not in params
|
||||
|
||||
def test_model_without_reasoning_control_is_untouched(self):
|
||||
params = LLM(model='gpt-4o-mini', reasoning_effort='high')._build_responses_params(MESSAGES)
|
||||
assert 'reasoning' not in params
|
||||
assert 'reasoning_effort' not in params
|
||||
|
||||
def test_explicit_reasoning_object_is_kept(self):
|
||||
llm = LLM(model='o4-mini', reasoning_effort='high')
|
||||
explicit = {'effort': 'low', 'summary': 'auto'}
|
||||
params = llm._build_responses_params(MESSAGES, reasoning=explicit)
|
||||
assert params['reasoning'] == explicit
|
||||
|
||||
def test_public_get_response_sends_effort(self):
|
||||
llm = LLM(model='o4-mini', reasoning_effort='high')
|
||||
requests = []
|
||||
|
||||
def respond(**kwargs):
|
||||
requests.append(kwargs)
|
||||
return _response()
|
||||
|
||||
llm._call_responses_api = respond
|
||||
assert llm.get_response('question', stream=False, verbose=False) == 'ok'
|
||||
assert requests[0]['reasoning'] == {'effort': 'high'}
|
||||
|
||||
|
||||
class TestOpenAIClientResponsesInput:
|
||||
|
||||
def test_reasoning_effort_kwarg_becomes_reasoning_object(self):
|
||||
client = OpenAIClient.__new__(OpenAIClient)
|
||||
params = client._build_responses_input(MESSAGES, 'o4-mini', None, None, reasoning_effort='high')
|
||||
assert params['reasoning'] == {'effort': 'high'}
|
||||
assert 'reasoning_effort' not in params
|
||||
|
||||
def test_no_effort_no_reasoning_object(self):
|
||||
client = OpenAIClient.__new__(OpenAIClient)
|
||||
params = client._build_responses_input(MESSAGES, 'o4-mini', None, None)
|
||||
assert 'reasoning' not in params
|
||||
|
||||
|
||||
def _agent_request(model, **agent_kwargs):
|
||||
"""Drive a real Agent on the native OpenAI client, faking only responses.create."""
|
||||
requests = []
|
||||
|
||||
def respond(**kwargs):
|
||||
requests.append(kwargs)
|
||||
return _response()
|
||||
|
||||
async def arespond(**kwargs):
|
||||
return respond(**kwargs)
|
||||
|
||||
client = OpenAIClient(api_key='sk-test-not-real')
|
||||
client._sync_client = SimpleNamespace(responses=SimpleNamespace(create=respond))
|
||||
client._async_client = SimpleNamespace(responses=SimpleNamespace(create=arespond))
|
||||
agent = Agent(name='t', instructions='x', llm=model, **agent_kwargs)
|
||||
agent._Agent__openai_client = client
|
||||
assert agent.start('hi') == 'ok'
|
||||
assert requests, 'no Responses API request was made'
|
||||
return requests[-1]
|
||||
|
||||
|
||||
class TestAgentNativeOpenAIPath:
|
||||
|
||||
def test_reasoning_model_gets_effort(self):
|
||||
assert _agent_request('o4-mini', reasoning_effort='high')['reasoning'] == {'effort': 'high'}
|
||||
|
||||
def test_unset_effort_sends_nothing(self):
|
||||
assert 'reasoning' not in _agent_request('o4-mini')
|
||||
|
||||
def test_model_without_reasoning_control_is_untouched(self):
|
||||
assert 'reasoning' not in _agent_request('gpt-4o-mini', reasoning_effort='high')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the session manifest (Issue #6248)."""
|
||||
|
||||
from praisonaiagents.session import FileSessionManifest, SessionManifestProtocol
|
||||
|
||||
|
||||
def test_satisfies_protocol(tmp_path):
|
||||
assert isinstance(FileSessionManifest(str(tmp_path)), SessionManifestProtocol)
|
||||
|
||||
|
||||
def test_missing_session_has_no_entry(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
assert m.get("missing") is None and m.interrupted("missing") is None
|
||||
|
||||
|
||||
def test_legacy_entry_with_unknown_keys_still_loads(tmp_path):
|
||||
(tmp_path / "s1.json").write_text(
|
||||
'{"session_id": "s1", "refs": {"plan": "p"}, "run_in_flight": true}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert FileSessionManifest(str(tmp_path)).interrupted("s1") is not None
|
||||
|
||||
|
||||
def test_completed_run_is_not_interrupted(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("s1", "r1", run_input="task")
|
||||
assert m.interrupted("s1") is not None
|
||||
m.end_run("s1", "completed")
|
||||
assert m.interrupted("s1") is None
|
||||
assert m.get("s1").run_status == "completed"
|
||||
|
||||
|
||||
def test_interrupted_run_survives_new_process(tmp_path):
|
||||
FileSessionManifest(str(tmp_path)).begin_run("s1", "r1", run_input="task")
|
||||
# A new process (new instance) sees the surviving write-ahead marker.
|
||||
stale = FileSessionManifest(str(tmp_path)).interrupted("s1")
|
||||
assert stale is not None and stale.run_id == "r1" and stale.run_digest
|
||||
|
||||
|
||||
def test_attempt_budget_is_bounded(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
attempts = [m.begin_run("s1", f"r{i}", run_input="task", max_attempts=2).attempt
|
||||
for i in range(4)]
|
||||
# Retries of the same interrupted task count up, then the budget resets.
|
||||
assert attempts == [1, 2, 1, 2]
|
||||
|
||||
|
||||
def test_new_task_after_interruption_is_a_fresh_attempt(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("s1", "r1", run_input="task a")
|
||||
assert m.begin_run("s1", "r2", run_input="task b").attempt == 1
|
||||
|
||||
|
||||
def test_unsafe_session_id_stays_in_directory(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("../../etc/x", "r1")
|
||||
assert all(p.parent == tmp_path for p in tmp_path.iterdir())
|
||||
assert m.get("../../etc/x").run_id == "r1"
|
||||
|
||||
|
||||
def test_run_input_is_never_written_in_plaintext(tmp_path):
|
||||
secret = "deploy with token sk-SECRET-12345"
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("s1", "r1", run_input=secret)
|
||||
for f in tmp_path.iterdir():
|
||||
assert "sk-SECRET-12345" not in f.read_text(encoding="utf-8")
|
||||
# The digest still recognises a retry of the same task.
|
||||
assert m.begin_run("s1", "r2", run_input=secret).attempt == 2
|
||||
|
||||
|
||||
def test_absolute_and_backslash_ids_stay_in_directory(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
for sid in ("/etc/passwd", "..\\..\\x", "..", "C:\\x"):
|
||||
m.begin_run(sid, sid)
|
||||
assert m.get(sid).run_id == sid
|
||||
assert all(p.parent == tmp_path for p in tmp_path.iterdir())
|
||||
|
||||
|
||||
def test_ids_differing_only_in_punctuation_stay_separate(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("a:b", "colon")
|
||||
m.begin_run("a_b", "underscore")
|
||||
assert m.get("a:b").run_id == "colon"
|
||||
assert m.get("a_b").run_id == "underscore"
|
||||
|
||||
|
||||
def test_end_run_only_releases_its_own_marker(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("s1", "r1", run_input="first")
|
||||
m.begin_run("s1", "r2", run_input="second") # second terminal takes over
|
||||
assert m.end_run("s1", "completed", run_id="r1") is None
|
||||
stale = m.interrupted("s1")
|
||||
assert stale is not None and stale.run_id == "r2"
|
||||
m.end_run("s1", "completed", run_id="r2")
|
||||
assert m.interrupted("s1") is None
|
||||
@@ -9,7 +9,7 @@ from praisonaiagents import ImageAgent
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_kwargs,expected", [
|
||||
({}, {"generate": "dall-e-3", "edit": "dall-e-2", "variation": "dall-e-2"}),
|
||||
({}, {"generate": "gpt-image-2.5-flare", "edit": "gpt-image-2.5-flare", "variation": "dall-e-2"}),
|
||||
({"llm": "openai/gpt-image-1"}, dict.fromkeys(("generate", "edit", "variation"), "openai/gpt-image-1")),
|
||||
({"model": "openai/gpt-image-1"}, dict.fromkeys(("generate", "edit", "variation"), "openai/gpt-image-1")),
|
||||
])
|
||||
@@ -37,3 +37,27 @@ def test_image_model_resolution(monkeypatch, tmp_path, model_kwargs, expected):
|
||||
agent.variation(str(image))
|
||||
|
||||
assert {name: kwargs["model"] for name, kwargs in calls.items()} == expected
|
||||
|
||||
|
||||
IMAGES_26_PARAMS = {"quality": "low", "frame_count": 4, "frame_duration_ms": 100,
|
||||
"complexity": "low", "output_format": "gif"}
|
||||
|
||||
|
||||
def test_generate_forwards_images_26_params(monkeypatch):
|
||||
calls = {}
|
||||
agent = ImageAgent(llm="gpt-image-2.6-flare", verbose=False)
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: calls.update(kw) or {"data": []})
|
||||
monkeypatch.setitem(sys.modules, "litellm", types.SimpleNamespace(
|
||||
get_llm_provider=lambda model: (model, "openai", None, None)))
|
||||
agent.generate_image("x", **IMAGES_26_PARAMS)
|
||||
assert IMAGES_26_PARAMS.items() <= calls.items()
|
||||
|
||||
|
||||
def test_edit_rejects_markup_instead_of_dropping_it(monkeypatch):
|
||||
called = []
|
||||
agent = ImageAgent(llm="gpt-image-2.5-flare", verbose=False)
|
||||
monkeypatch.setitem(sys.modules, "litellm", types.SimpleNamespace(
|
||||
image_edit=lambda **kw: called.append(kw)))
|
||||
with pytest.raises(ValueError, match="markup"):
|
||||
agent.edit("photo.png", "make the circled cup blue", markup="markup.png")
|
||||
assert not called
|
||||
@@ -0,0 +1,158 @@
|
||||
"""ImageAgent results expose .save() while keeping the raw response API (#6260)."""
|
||||
|
||||
import base64
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import ImageAgent
|
||||
|
||||
GIF = b"GIF89a\x01\x00\x01\x00fake"
|
||||
SVG = b'<svg xmlns="http://www.w3.org/2000/svg"></svg>'
|
||||
|
||||
|
||||
def _response(*payloads):
|
||||
return types.SimpleNamespace(
|
||||
data=[types.SimpleNamespace(b64_json=base64.b64encode(p).decode(), url=None) for p in payloads],
|
||||
usage={"total_tokens": 5},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent(monkeypatch):
|
||||
agent = ImageAgent(llm="openai/gpt-image-2.6-flare", verbose=False)
|
||||
sent = {}
|
||||
|
||||
def fake(name, payloads):
|
||||
def call(**kwargs):
|
||||
sent[name] = kwargs
|
||||
return _response(*payloads)
|
||||
return call
|
||||
|
||||
monkeypatch.setattr(agent, "_litellm", fake("generate", [GIF]))
|
||||
monkeypatch.setitem(sys.modules, "litellm", types.SimpleNamespace(
|
||||
image_edit=fake("edit", [SVG, GIF]),
|
||||
image_variation=fake("variation", [GIF]),
|
||||
get_llm_provider=lambda model: (model, "openai", None, None),
|
||||
))
|
||||
agent.sent = sent
|
||||
return agent
|
||||
|
||||
|
||||
def test_generate_save_writes_decoded_bytes(agent, tmp_path):
|
||||
out = agent.generate("A cat waving", output_format="gif", frame_count=8).save(str(tmp_path / "cat.gif"))
|
||||
assert open(out, "rb").read() == GIF
|
||||
assert agent.sent["generate"]["output_format"] == "gif"
|
||||
assert agent.sent["generate"]["frame_count"] == 8
|
||||
|
||||
|
||||
def test_edit_save_index_and_raw_access(agent, tmp_path):
|
||||
src = tmp_path / "photo.png"
|
||||
src.write_bytes(b"png")
|
||||
result = agent.edit(str(src), "make the cup blue", n=2)
|
||||
assert agent.sent["edit"]["n"] == 2
|
||||
assert len(result.images) == 2
|
||||
assert result.usage == {"total_tokens": 5}
|
||||
assert base64.b64decode(result.data[1].b64_json) == GIF
|
||||
result.save(str(tmp_path / "a.svg"))
|
||||
assert (tmp_path / "a.svg").read_bytes() == SVG
|
||||
|
||||
|
||||
def test_variation_and_dict_response(agent, tmp_path, monkeypatch):
|
||||
src = tmp_path / "photo.png"
|
||||
src.write_bytes(b"png")
|
||||
assert agent.variation(str(src)).save(str(tmp_path / "v.gif"))
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: {"data": [{"b64_json": base64.b64encode(SVG).decode()}]})
|
||||
result = agent.generate("logo")
|
||||
assert result["data"][0]["b64_json"]
|
||||
result.save(str(tmp_path / "logo.svg"))
|
||||
assert (tmp_path / "logo.svg").read_bytes() == SVG
|
||||
|
||||
|
||||
def test_save_without_image_data_raises(agent, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: {"data": []})
|
||||
with pytest.raises(ValueError):
|
||||
agent.generate("nothing").save(str(tmp_path / "x.png"))
|
||||
|
||||
|
||||
def test_save_nonzero_index(agent, tmp_path):
|
||||
src = tmp_path / "photo.png"
|
||||
src.write_bytes(b"png")
|
||||
result = agent.edit(str(src), "two takes", n=2)
|
||||
assert result.save(str(tmp_path / "b.gif"), index=1) == str(tmp_path / "b.gif")
|
||||
assert (tmp_path / "b.gif").read_bytes() == GIF
|
||||
|
||||
|
||||
class _FakeHTTP:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self, n=-1):
|
||||
return self.body if n < 0 else self.body[:n]
|
||||
|
||||
|
||||
def _url_response(url):
|
||||
return {"data": [{"b64_json": None, "url": url}]}
|
||||
|
||||
|
||||
def test_save_downloads_url(agent, tmp_path, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_urlopen(url, timeout=None):
|
||||
seen.update(url=url, timeout=timeout)
|
||||
return _FakeHTTP(GIF)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: _url_response("https://example.com/i.gif"))
|
||||
agent.generate("cat").save(str(tmp_path / "u.gif"))
|
||||
assert (tmp_path / "u.gif").read_bytes() == GIF
|
||||
assert seen == {"url": "https://example.com/i.gif", "timeout": 30}
|
||||
|
||||
|
||||
def test_save_rejects_non_http_url(agent, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: _url_response("file:///etc/passwd"))
|
||||
with pytest.raises(ValueError, match="http"):
|
||||
agent.generate("x").save(str(tmp_path / "x.png"))
|
||||
assert not (tmp_path / "x.png").exists()
|
||||
|
||||
|
||||
def test_save_rejects_oversized_download(agent, tmp_path, monkeypatch):
|
||||
import urllib.request
|
||||
from praisonaiagents.agent import _image_result
|
||||
|
||||
monkeypatch.setattr(_image_result, "_MAX_DOWNLOAD_BYTES", 4)
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=None: _FakeHTTP(b"123456"))
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: _url_response("https://example.com/big.png"))
|
||||
with pytest.raises(ValueError, match="maximum"):
|
||||
agent.generate("x").save(str(tmp_path / "big.png"))
|
||||
assert not (tmp_path / "big.png").exists()
|
||||
|
||||
|
||||
def test_container_protocol_passes_through(agent):
|
||||
from litellm.types.utils import ImageResponse
|
||||
|
||||
raw = ImageResponse(data=[{"b64_json": base64.b64encode(GIF).decode()}])
|
||||
agent._litellm = lambda **kw: raw
|
||||
result = agent.generate("cat")
|
||||
assert "data" in result
|
||||
assert dict(result).keys() == dict(raw).keys()
|
||||
|
||||
|
||||
def test_workflow_output_saves_b64_image(agent, tmp_path):
|
||||
from praisonaiagents.workflows.workflows import AgentFlow
|
||||
|
||||
flow = object.__new__(AgentFlow)
|
||||
out = str(tmp_path / "wf.gif")
|
||||
variables = {"output": out}
|
||||
flow._execute_specialized_agent(agent, "ImageAgent", "a cat", None, variables, False)
|
||||
assert (tmp_path / "wf.gif").read_bytes() == GIF
|
||||
assert variables["_last_image_file"] == out
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Regression tests for issue #6257 (beginner harness demo)."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
|
||||
def test_guardrail_runs_when_agent_has_no_name():
|
||||
seen = []
|
||||
|
||||
def check(output):
|
||||
seen.append(output.agent)
|
||||
return True, output
|
||||
|
||||
agent = Agent(instructions="test", llm="gpt-4o-mini", guardrails=check)
|
||||
assert agent.name is None or isinstance(agent.name, str)
|
||||
|
||||
ok, result, error = agent._validate_with_guardrail("hello")
|
||||
|
||||
assert ok is True and error is None
|
||||
assert result == "hello"
|
||||
assert seen and isinstance(seen[0], str)
|
||||
|
||||
|
||||
def test_openai_timeout_env_var(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "45")
|
||||
assert OpenAIClient(api_key="sk-test").timeout == 45.0
|
||||
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "not-a-number")
|
||||
assert OpenAIClient(api_key="sk-test").timeout is None
|
||||
|
||||
monkeypatch.delenv("OPENAI_TIMEOUT")
|
||||
assert OpenAIClient(api_key="sk-test").timeout is None
|
||||
|
||||
|
||||
def test_openai_timeout_reaches_sync_client(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "12")
|
||||
client = OpenAIClient(api_key="sk-test")
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.llm.openai_client.check_model_request", lambda *a, **k: None
|
||||
)
|
||||
assert client.sync_client.timeout == 12.0
|
||||
|
||||
|
||||
def test_rules_notice_lists_only_active_rules_once(tmp_path, monkeypatch, caplog):
|
||||
import logging
|
||||
from praisonaiagents.agent import agent as agent_mod
|
||||
|
||||
rules_dir = tmp_path / ".praisonai" / "rules"
|
||||
rules_dir.mkdir(parents=True)
|
||||
(rules_dir / "always_on.md").write_text("---\nactivation: always\n---\nBe brief.\n")
|
||||
(rules_dir / "manual_only.md").write_text("---\nactivation: manual\n---\nOnly on @mention.\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setattr(agent_mod, "_RULES_NOTICE_SEEN", set())
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
for _ in range(2):
|
||||
a = Agent(instructions="test", llm="gpt-4o-mini")
|
||||
a._init_rules_manager()
|
||||
|
||||
notices = [r.getMessage() for r in caplog.records if "Applied rules" in r.getMessage()]
|
||||
assert len(notices) == 1
|
||||
assert "always_on" in notices[0] and "manual_only" not in notices[0]
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Durable step cache: an interrupted AgentFlow resumes in a new process (#6249)."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.workflows.step_cache import FileStepCache
|
||||
from praisonaiagents.workflows.workflows import AgentFlow
|
||||
|
||||
|
||||
def _step(name, calls, output=None, fail=False):
|
||||
def handler(ctx):
|
||||
calls.append(name)
|
||||
if fail:
|
||||
raise RuntimeError("interrupted")
|
||||
return output if output is not None else f"{name}-out"
|
||||
handler.__name__ = name
|
||||
return handler
|
||||
|
||||
|
||||
def test_resume_skips_completed_steps_across_instances(tmp_path):
|
||||
path = tmp_path / "run.json"
|
||||
calls = []
|
||||
first = AgentFlow(
|
||||
steps=[_step("a", calls), _step("b", calls, fail=True)],
|
||||
cache=FileStepCache(str(path)),
|
||||
)
|
||||
try:
|
||||
first.run("in", verbose=False)
|
||||
except Exception:
|
||||
pass
|
||||
assert calls[0] == "a" and "b" in calls
|
||||
assert path.exists()
|
||||
|
||||
# A fresh flow and a fresh cache object: simulates a new process.
|
||||
calls.clear()
|
||||
second = AgentFlow(
|
||||
steps=[_step("a", calls), _step("b", calls), _step("c", calls)],
|
||||
cache=FileStepCache(str(path)),
|
||||
)
|
||||
result = second.run("in", verbose=False)
|
||||
assert calls == ["b", "c"]
|
||||
assert result["output"] == "c-out"
|
||||
|
||||
|
||||
def test_changed_input_does_not_serve_stale_results(tmp_path):
|
||||
path = str(tmp_path / "run.json")
|
||||
calls = []
|
||||
AgentFlow(steps=[_step("a", calls)], cache=FileStepCache(path)).run("x", verbose=False)
|
||||
AgentFlow(steps=[_step("a", calls)], cache=FileStepCache(path)).run("y", verbose=False)
|
||||
assert calls == ["a", "a"]
|
||||
|
||||
|
||||
def test_changed_upstream_output_reruns_downstream(tmp_path):
|
||||
path = str(tmp_path / "run.json")
|
||||
calls = []
|
||||
AgentFlow(
|
||||
steps=[_step("a", calls, output="v1"), _step("b", calls)], cache=FileStepCache(path)
|
||||
).run("x", verbose=False)
|
||||
calls.clear()
|
||||
# Step "a" was reconfigured under the SAME name (its closure value
|
||||
# changed), so it and everything after it must re-run.
|
||||
AgentFlow(
|
||||
steps=[_step("a", calls, output="v2"), _step("b", calls)], cache=FileStepCache(path)
|
||||
).run("x", verbose=False)
|
||||
assert calls == ["a", "b"]
|
||||
|
||||
|
||||
def test_unserialisable_value_is_rejected_not_stored(tmp_path):
|
||||
path = tmp_path / "run.json"
|
||||
cache = FileStepCache(str(path))
|
||||
with pytest.raises(TypeError):
|
||||
cache.set("k", {"output": object()})
|
||||
assert cache.get("k") is None and not path.exists()
|
||||
|
||||
|
||||
def test_get_returns_a_copy_and_clear_removes_file(tmp_path):
|
||||
path = tmp_path / "run.json"
|
||||
cache = FileStepCache(str(path))
|
||||
cache.set("k", {"output": "o", "variables": {"x": 1}})
|
||||
cache.get("k")["variables"]["x"] = 2
|
||||
assert FileStepCache(str(path)).get("k") == {"output": "o", "variables": {"x": 1}}
|
||||
assert json.loads(path.read_text())["k"]["output"] == "o"
|
||||
cache.clear()
|
||||
assert len(cache) == 0 and not path.exists()
|
||||
|
||||
|
||||
def test_edited_handler_with_same_name_reruns(tmp_path):
|
||||
path = str(tmp_path / "run.json")
|
||||
calls = []
|
||||
|
||||
def a(ctx):
|
||||
calls.append("v1")
|
||||
return "v1"
|
||||
|
||||
AgentFlow(steps=[a], cache=FileStepCache(path)).run("x", verbose=False)
|
||||
|
||||
def a(ctx): # noqa: F811 - same name, edited body
|
||||
calls.append("v2")
|
||||
return "v2"
|
||||
|
||||
result = AgentFlow(steps=[a], cache=FileStepCache(path)).run("x", verbose=False)
|
||||
assert calls == ["v1", "v2"] and result["output"] == "v2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [{"variables": {"lookup": {1: "one"}}}, {"output": ("a", "b")}])
|
||||
def test_value_that_changes_shape_in_json_is_rejected(tmp_path, value):
|
||||
cache = FileStepCache(str(tmp_path / "run.json"))
|
||||
with pytest.raises(TypeError):
|
||||
cache.set("k", value)
|
||||
assert cache.get("k") is None
|
||||
|
||||
|
||||
def test_two_writers_on_one_path_keep_both_entries(tmp_path):
|
||||
path = str(tmp_path / "run.json")
|
||||
a, b = FileStepCache(path), FileStepCache(path)
|
||||
a.set("a", {"output": "1"})
|
||||
b.set("b", {"output": "2"})
|
||||
reopened = FileStepCache(path)
|
||||
assert reopened.get("a") == {"output": "1"} and reopened.get("b") == {"output": "2"}
|
||||
|
||||
|
||||
|
||||
def test_set_constants_are_encoded_independent_of_iteration_order():
|
||||
# A set literal compiles to a frozenset constant whose iteration order
|
||||
# depends on PYTHONHASHSEED; the key must not, or resume silently re-runs.
|
||||
from praisonaiagents.workflows.step_cache import _stable, make_step_key
|
||||
|
||||
words = ["apple", "banana", "cherry", "date", "elder"]
|
||||
expected = "{" + ",".join(sorted(repr(w) for w in words)) + "}"
|
||||
assert _stable(frozenset(words), set()) == expected
|
||||
assert _stable(frozenset(reversed(words)), set()) == expected
|
||||
|
||||
def lookup(ctx):
|
||||
return "fruit" if ctx.input in {"apple", "banana", "cherry", "date", "elder"} else "other"
|
||||
|
||||
consts = lookup.__code__.co_consts
|
||||
assert any(isinstance(c, frozenset) for c in consts)
|
||||
# Same constants, frozenset rebuilt: the key must not change.
|
||||
rebuilt = lookup.__code__.replace(
|
||||
co_consts=tuple(frozenset(sorted(c, reverse=True)) if isinstance(c, frozenset) else c for c in consts)
|
||||
)
|
||||
import types
|
||||
twin = types.FunctionType(rebuilt, lookup.__globals__, "lookup")
|
||||
assert make_step_key(lookup, None, "apple", {}) == make_step_key(twin, None, "apple", {})
|
||||
assert make_step_key(lookup, None, "x", {"t": {"b", "a"}}) == make_step_key(lookup, None, "x", {"t": {"a", "b"}})
|
||||
|
||||
def test_edited_helper_or_default_reruns(tmp_path):
|
||||
from praisonaiagents.workflows.step_cache import make_step_key
|
||||
|
||||
def helper_v1():
|
||||
return 1
|
||||
|
||||
def helper_v2():
|
||||
return 2
|
||||
|
||||
def make(default):
|
||||
def step(ctx, mode=default):
|
||||
return mode
|
||||
return step
|
||||
|
||||
assert make_step_key(make("fast"), None, "x", {}) != make_step_key(make("slow"), None, "x", {})
|
||||
assert make_step_key(make("fast"), None, "x", {}) == make_step_key(make("fast"), None, "x", {})
|
||||
|
||||
import types
|
||||
|
||||
def step(ctx):
|
||||
return helper() # noqa: F821 - resolved from the globals given below
|
||||
|
||||
def bind(helper):
|
||||
return types.FunctionType(step.__code__, {"helper": helper}, "step")
|
||||
|
||||
assert make_step_key(bind(helper_v1), None, "x", {}) != make_step_key(bind(helper_v2), None, "x", {})
|
||||
assert make_step_key(bind(helper_v1), None, "x", {}) == make_step_key(bind(helper_v1), None, "x", {})
|
||||
|
||||
|
||||
def test_agent_step_with_same_name_but_new_instructions_misses():
|
||||
from praisonaiagents.workflows.step_cache import make_step_key
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, instructions, llm="gpt-4o-mini"):
|
||||
self.name, self.instructions, self.llm, self.tools = "writer", instructions, llm, []
|
||||
|
||||
k = lambda a: make_step_key(a, None, "x", {})
|
||||
assert k(FakeAgent("be brief")) == k(FakeAgent("be brief"))
|
||||
assert k(FakeAgent("be brief")) != k(FakeAgent("be verbose"))
|
||||
assert k(FakeAgent("be brief")) != k(FakeAgent("be brief", llm="gpt-4o"))
|
||||
|
||||
|
||||
def test_agent_wrapped_in_task_keys_on_agent_config():
|
||||
# AgentFlow normalises an Agent step to Task(agent=...) before keying it,
|
||||
# so the wrapped agent's instructions and model must reach the key.
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.workflows.step_cache import make_step_key
|
||||
|
||||
def key(instructions, llm="gpt-4o-mini"):
|
||||
agent = Agent(name="writer", instructions=instructions, llm=llm)
|
||||
task = AgentFlow(steps=[agent])._normalize_single_step(agent, 0)
|
||||
assert task.agent is agent
|
||||
return make_step_key(task, None, "x", {})
|
||||
|
||||
assert key("be brief") == key("be brief")
|
||||
assert key("be brief") != key("be verbose")
|
||||
assert key("be brief") != key("be brief", llm="gpt-4o")
|
||||
@@ -157,11 +157,12 @@ class TestCorrectnessOnAHit:
|
||||
|
||||
def test_legacy_cache_entry_restores_output_variable_and_status(self):
|
||||
cache = InMemoryStepCache()
|
||||
cache.set(make_step_key("upstream", None, "same", {"input": "same"}), {
|
||||
calls = []
|
||||
upstream = _counting_step("upstream", calls)
|
||||
cache.set(make_step_key(upstream, None, "same", {"input": "same"}), {
|
||||
"output": "cached", "variables": {"upstream_output": "cached"},
|
||||
})
|
||||
calls = []
|
||||
flow = AgentFlow(steps=[_counting_step("upstream", calls)], cache=cache)
|
||||
flow = AgentFlow(steps=[upstream], cache=cache)
|
||||
result = flow.run("same", verbose=False)
|
||||
assert calls == []
|
||||
assert result["output"] == "cached"
|
||||
|
||||
Generated
+1
-1
@@ -3162,7 +3162,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -873,6 +873,15 @@ class HealthMonitorSchema(BaseModel):
|
||||
breaker_cooldown_s: float = Field(120.0, ge=0)
|
||||
|
||||
|
||||
class OperatorGrantSchema(BaseModel):
|
||||
"""Per-token operator grant (Issue #6253). Unknown keys are rejected so a
|
||||
typo (``agnts``) cannot silently drop an agent confinement."""
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scopes: List[str] = Field(default_factory=list)
|
||||
agents: Optional[List[str]] = None
|
||||
|
||||
|
||||
class GatewayServerSchema(BaseModel):
|
||||
"""Typed schema for the ``gateway:`` server block (issue #3050).
|
||||
|
||||
@@ -896,7 +905,8 @@ class GatewayServerSchema(BaseModel):
|
||||
allowed_origins: Optional[List[str]] = None
|
||||
auth_token: Optional[str] = None
|
||||
auth: Optional[Dict[str, Any]] = None
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
# Issue #6253: token -> [scopes] or {scopes: [...], agents: [...]}.
|
||||
auth_scopes: Optional[Dict[str, Union[List[str], OperatorGrantSchema]]] = None
|
||||
max_connections: Optional[int] = Field(None, ge=0)
|
||||
max_sessions_per_agent: Optional[int] = Field(None, ge=0)
|
||||
session_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
@@ -1427,6 +1427,36 @@
|
||||
"title": "HookSchema",
|
||||
"type": "object"
|
||||
},
|
||||
"OperatorGrantSchema": {
|
||||
"additionalProperties": false,
|
||||
"description": "Per-token operator grant (Issue #6253). Unknown keys are rejected so a\ntypo (``agnts``) cannot silently drop an agent confinement.",
|
||||
"properties": {
|
||||
"scopes": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Scopes",
|
||||
"type": "array"
|
||||
},
|
||||
"agents": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Agents"
|
||||
}
|
||||
},
|
||||
"title": "OperatorGrantSchema",
|
||||
"type": "object"
|
||||
},
|
||||
"OutboundHookSchema": {
|
||||
"additionalProperties": false,
|
||||
"description": "``gateway.hooks_outbound[]`` \u2014 a core ``CommandHook`` on lifecycle events (#6140).",
|
||||
@@ -1559,10 +1589,17 @@
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/OperatorGrantSchema"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -1287,6 +1287,9 @@ class WebSocketGateway:
|
||||
# {client_id: SessionSharingRole}). The owner stays ``session.client_id``.
|
||||
self._session_observers: Dict[str, Dict[str, Any]] = {}
|
||||
self._client_scopes: Dict[str, List[str]] = {} # client_id -> operator scopes
|
||||
# Issue #6253: client_id -> agent ids the operator is confined to
|
||||
# (absent/None = every agent).
|
||||
self._client_agents: Dict[str, Optional[List[str]]] = {}
|
||||
# Issue #3467: in-flight turn registry so a running turn can be aborted
|
||||
# (by a WS ``abort`` frame or a portable ``/stop`` chat command) and so
|
||||
# a per-turn timeout can cancel a runaway turn. Maps session_id ->
|
||||
@@ -2070,6 +2073,10 @@ class WebSocketGateway:
|
||||
self._register_client_conn(client_id, websocket)
|
||||
# Resolve operator scopes for this connection (all scopes if no policy).
|
||||
self._client_scopes[client_id] = self.config.resolve_scopes(operator_token)
|
||||
_resolve_agents = getattr(self.config, "resolve_agents", None) # older core: unconfined
|
||||
self._client_agents[client_id] = (
|
||||
_resolve_agents(operator_token) if _resolve_agents else None
|
||||
)
|
||||
# Issue #2661: stamp the session with the active secret's generation
|
||||
# so a later rotation can force-close it (instant revocation).
|
||||
self._client_auth_generation[client_id] = self._auth_generation()
|
||||
@@ -2120,6 +2127,7 @@ class WebSocketGateway:
|
||||
self._clients.pop(client_id, None)
|
||||
await self._teardown_client_conn(client_id)
|
||||
self._client_scopes.pop(client_id, None)
|
||||
self._client_agents.pop(client_id, None)
|
||||
self._client_auth_generation.pop(client_id, None)
|
||||
self._release_client_session(client_id)
|
||||
|
||||
@@ -3992,6 +4000,7 @@ class WebSocketGateway:
|
||||
self._client_conns.clear()
|
||||
self._client_sessions.clear()
|
||||
self._client_scopes.clear()
|
||||
self._client_agents.clear()
|
||||
self._client_auth_generation.clear()
|
||||
|
||||
for session_id in list(self._sessions.keys()):
|
||||
@@ -4104,8 +4113,14 @@ class WebSocketGateway:
|
||||
if msg_type == "hello":
|
||||
agent_id = data.get("agent_id")
|
||||
|
||||
# Check if agent exists
|
||||
if not agent_id or agent_id not in self._agents:
|
||||
# Check if agent exists. An agent outside the operator's grant
|
||||
# (Issue #6253) is reported identically so its existence is not
|
||||
# disclosed to a confined operator.
|
||||
if (
|
||||
not agent_id
|
||||
or agent_id not in self._agents
|
||||
or not self._client_may_reach_agent(client_id, agent_id)
|
||||
):
|
||||
error = HelloError(
|
||||
code=ConnectErrorCode.AGENT_NOT_FOUND,
|
||||
message=f"Agent not found: {agent_id}",
|
||||
@@ -4311,7 +4326,11 @@ class WebSocketGateway:
|
||||
# Keep backward compatibility with old join message
|
||||
elif msg_type == "join":
|
||||
agent_id = data.get("agent_id")
|
||||
if agent_id and agent_id in self._agents:
|
||||
if (
|
||||
agent_id
|
||||
and agent_id in self._agents
|
||||
and self._client_may_reach_agent(client_id, agent_id)
|
||||
):
|
||||
# Protocol version negotiation with validation
|
||||
try:
|
||||
client_min_version = int(data.get("min_version", MIN_PROTOCOL_VERSION))
|
||||
@@ -4373,6 +4392,18 @@ class WebSocketGateway:
|
||||
})
|
||||
return
|
||||
|
||||
# Issue #6253: deny resuming another agent's live session
|
||||
# *before* the resume call touches it.
|
||||
existing = self.get_session(session_id) if isinstance(session_id, str) else None
|
||||
if existing is not None and not self._client_may_reach_agent(
|
||||
client_id, existing.agent_id
|
||||
):
|
||||
await self._send_to_client(client_id, {
|
||||
"type": "error",
|
||||
"message": "Session does not belong to the requested agent",
|
||||
})
|
||||
return
|
||||
|
||||
# Resume or create session
|
||||
session, replay_events = self.resume_or_create_session(
|
||||
session_id=session_id,
|
||||
@@ -4380,7 +4411,17 @@ class WebSocketGateway:
|
||||
client_id=client_id,
|
||||
since_cursor=since_cursor,
|
||||
)
|
||||
|
||||
|
||||
# Issue #6253: a resumed session_id must not smuggle a confined
|
||||
# operator onto another agent's session (covers sessions
|
||||
# rehydrated from the persistent store).
|
||||
if not self._client_may_reach_agent(client_id, session.agent_id):
|
||||
await self._send_to_client(client_id, {
|
||||
"type": "error",
|
||||
"message": "Session does not belong to the requested agent",
|
||||
})
|
||||
return
|
||||
|
||||
# Set negotiated protocol version for the session
|
||||
session._protocol_version = negotiated_version
|
||||
|
||||
@@ -5372,6 +5413,7 @@ class WebSocketGateway:
|
||||
# Mirror the normal disconnect cleanup so evicted clients do not leave
|
||||
# stale scope/session entries that accumulate until shutdown.
|
||||
self._client_scopes.pop(client_id, None)
|
||||
self._client_agents.pop(client_id, None)
|
||||
self._client_auth_generation.pop(client_id, None)
|
||||
self._release_client_session(client_id)
|
||||
if ws is not None:
|
||||
@@ -5442,6 +5484,7 @@ class WebSocketGateway:
|
||||
ws = self._clients.pop(client_id, None)
|
||||
await self._teardown_client_conn(client_id)
|
||||
self._client_scopes.pop(client_id, None)
|
||||
self._client_agents.pop(client_id, None)
|
||||
self._client_auth_generation.pop(client_id, None)
|
||||
self._release_client_session(client_id)
|
||||
if ws is not None:
|
||||
@@ -5494,6 +5537,7 @@ class WebSocketGateway:
|
||||
ws = self._clients.pop(client_id, None)
|
||||
await self._teardown_client_conn(client_id)
|
||||
self._client_scopes.pop(client_id, None)
|
||||
self._client_agents.pop(client_id, None)
|
||||
self._client_auth_generation.pop(client_id, None)
|
||||
self._release_client_session(client_id)
|
||||
self._reaped_connections += 1
|
||||
@@ -6839,6 +6883,9 @@ class WebSocketGateway:
|
||||
role = SessionSharingRole(role)
|
||||
if role is SessionSharingRole.OWNER or client_id == session.client_id:
|
||||
raise ValueError("The owner is bound at session creation, not attached")
|
||||
# Issue #6253: a confined operator must not join another agent's session.
|
||||
if not self._client_may_reach_agent(client_id, session.agent_id):
|
||||
raise PermissionError(f"Client {client_id} may not reach this session's agent")
|
||||
visibility = SessionVisibility(session._visibility)
|
||||
if visibility is SessionVisibility.PRIVATE:
|
||||
raise PermissionError(f"Session {session_id} is private")
|
||||
@@ -7001,6 +7048,15 @@ class WebSocketGateway:
|
||||
return func
|
||||
return decorator
|
||||
|
||||
def _client_may_reach_agent(self, client_id: str, agent_id: Any) -> bool:
|
||||
"""Whether a connected client's operator grant covers ``agent_id``.
|
||||
|
||||
Issue #6253: unconfined clients (no ``agents`` in their grant) reach
|
||||
every agent, preserving prior behaviour.
|
||||
"""
|
||||
allowed = self._client_agents.get(client_id)
|
||||
return allowed is None or agent_id in allowed
|
||||
|
||||
def _client_has_scope(self, client_id: str, scope: OperatorScope) -> bool:
|
||||
"""Whether a connected client holds ``scope`` (ADMIN implies all).
|
||||
|
||||
@@ -7045,6 +7101,11 @@ class WebSocketGateway:
|
||||
|
||||
def _event_visible_to(self, event: GatewayEvent, client_id: str) -> bool:
|
||||
"""Whether ``event`` should be delivered to ``client_id`` given its scopes."""
|
||||
# Issue #6253: an event sourced from an agent outside the client's
|
||||
# operator grant is withheld; non-agent (system) events are unaffected.
|
||||
source = getattr(event, "source", None)
|
||||
if source in self._agents and not self._client_may_reach_agent(client_id, source):
|
||||
return False
|
||||
return self._client_has_scope(client_id, self._event_required_scope(event))
|
||||
|
||||
async def emit(self, event: GatewayEvent) -> None:
|
||||
@@ -12062,6 +12123,42 @@ class WebSocketGateway:
|
||||
self.config.trusted_proxies = _trusted
|
||||
except Exception: # pragma: no cover - config is always a GatewayConfig
|
||||
pass
|
||||
# Issue #6253: apply the YAML operator policy (scopes and agent
|
||||
# confinement). Only overwrite when the YAML declares one, so a policy
|
||||
# a Python caller set on ``GatewayConfig`` survives.
|
||||
try:
|
||||
from praisonaiagents.gateway.config import _parse_auth_scopes
|
||||
except ImportError: # older core without per-agent grants
|
||||
_parse_auth_scopes = None
|
||||
_a = gw_cfg.get("auth")
|
||||
if gw_cfg.get("auth_scopes") or (isinstance(_a, dict) and _a.get("tokens")):
|
||||
# A declared policy that cannot be enforced must not start
|
||||
# with every operator unconfined (fail closed).
|
||||
raise ValueError(
|
||||
"gateway operator policy is declared but the installed "
|
||||
"praisonaiagents cannot enforce it; upgrade praisonaiagents"
|
||||
)
|
||||
_yaml_scopes = _parse_auth_scopes(gw_cfg) if _parse_auth_scopes else None
|
||||
if _yaml_scopes is not None:
|
||||
_auth = gw_cfg.get("auth")
|
||||
_tokens_form = isinstance(_auth, dict) and isinstance(_auth.get("tokens"), list)
|
||||
# ``load_gateway_config`` resolves ``${VAR}`` in values only, so
|
||||
# ``auth_scopes`` *keys* still hold placeholders here. An unset
|
||||
# var resolves to "" and that entry is dropped (it matches no one).
|
||||
_policy = {}
|
||||
for _tok, _grant in _yaml_scopes.items():
|
||||
_tok = _tok if _tokens_form else self._substitute_env_vars(_tok)
|
||||
if _tok:
|
||||
_policy[_tok] = _grant
|
||||
_declared = _auth.get("tokens") if _tokens_form else gw_cfg.get("auth_scopes")
|
||||
if _declared and not _policy:
|
||||
# An empty policy means "no policy" (every scope, every agent),
|
||||
# so refuse to start rather than fail open.
|
||||
raise ValueError(
|
||||
"gateway operator policy: every token resolved to empty "
|
||||
"(unset ${VAR}?); refusing to start without it"
|
||||
)
|
||||
self.config.auth_scopes = _policy
|
||||
# Issue #3593: the multi-bot CLI builds this gateway with a *default*
|
||||
# session config, so ``__init__`` already picked a store before this
|
||||
# YAML loaded. Re-read the ``session:`` block here and re-select the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-bot"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Operator agent confinement on the gateway (Issue #6253).
|
||||
|
||||
``auth_scopes`` may carry ``{"scopes": [...], "agents": [...]}`` per token so
|
||||
an authenticated operator is confined to specific agents; the plain
|
||||
``token -> [scopes]`` form keeps today's gateway-wide behaviour.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.gateway import GatewayConfig
|
||||
from praisonaiagents.gateway.protocols import EventType, GatewayEvent
|
||||
from praisonai_bot.gateway.server import WebSocketGateway
|
||||
|
||||
|
||||
class _WS:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
async def send_json(self, data):
|
||||
self.sent.append(data)
|
||||
|
||||
async def close(self, code=None, reason=None):
|
||||
pass
|
||||
|
||||
|
||||
POLICY = {
|
||||
"tok_support": {"scopes": ["read", "write"], "agents": ["support"]},
|
||||
"tok_legacy": ["read", "write"],
|
||||
}
|
||||
|
||||
|
||||
def test_resolve_agents_contract():
|
||||
cfg = GatewayConfig(auth_token="t", auth_scopes=POLICY)
|
||||
assert cfg.resolve_scopes("tok_support") == ["read", "write"]
|
||||
assert cfg.resolve_agents("tok_support") == ["support"]
|
||||
# Plain list form and no-policy are unconfined (backward compatible).
|
||||
assert cfg.resolve_agents("tok_legacy") is None
|
||||
assert GatewayConfig().resolve_agents("anything") is None
|
||||
# Unknown token still holds no scopes.
|
||||
assert cfg.resolve_scopes("nope") == []
|
||||
|
||||
|
||||
def test_yaml_both_shapes_parse_agents():
|
||||
from praisonaiagents.gateway.config import MultiChannelGatewayConfig
|
||||
|
||||
flat = {"gateway": {"auth_scopes": {
|
||||
"tok_support": {"scopes": ["read", "write"], "agents": ["support"]},
|
||||
"tok_legacy": ["read"],
|
||||
}}}
|
||||
gw = MultiChannelGatewayConfig.from_dict(flat).gateway
|
||||
assert gw.resolve_agents("tok_support") == ["support"]
|
||||
assert gw.resolve_scopes("tok_support") == ["read", "write"]
|
||||
assert gw.resolve_agents("tok_legacy") is None
|
||||
|
||||
tokens = {"gateway": {"auth": {"tokens": [
|
||||
{"token": "tok_fin", "scopes": ["read"], "agents": ["finance"]},
|
||||
]}}}
|
||||
gw = MultiChannelGatewayConfig.from_dict(tokens).gateway
|
||||
assert gw.resolve_agents("tok_fin") == ["finance"]
|
||||
assert gw.resolve_scopes("tok_fin") == ["read"]
|
||||
|
||||
|
||||
def _gateway_with_client(agents):
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
gw._agents["support"] = object()
|
||||
gw._agents["finance"] = object()
|
||||
ws = _WS()
|
||||
gw._clients["c1"] = ws
|
||||
gw._client_scopes["c1"] = ["read", "write"]
|
||||
gw._client_agents["c1"] = agents
|
||||
return gw, ws
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", ["hello", "join"])
|
||||
def test_confined_operator_cannot_bind_other_agent(frame):
|
||||
gw, ws = _gateway_with_client(["support"])
|
||||
asyncio.run(gw._handle_client_message("c1", {"type": frame, "agent_id": "finance"}))
|
||||
assert "c1" not in gw._client_sessions
|
||||
assert "not found" in str(ws.sent[-1]).lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", ["hello", "join"])
|
||||
def test_confined_operator_binds_allowed_agent(frame):
|
||||
gw, _ = _gateway_with_client(["support"])
|
||||
asyncio.run(gw._handle_client_message("c1", {"type": frame, "agent_id": "support"}))
|
||||
assert gw._sessions[gw._client_sessions["c1"]].agent_id == "support"
|
||||
|
||||
|
||||
def test_join_cannot_resume_foreign_agent_session():
|
||||
gw, ws = _gateway_with_client(["support"])
|
||||
foreign = gw.create_session("finance", client_id="other")
|
||||
asyncio.run(gw._handle_client_message(
|
||||
"c1",
|
||||
{"type": "join", "agent_id": "support", "session_id": foreign.session_id},
|
||||
))
|
||||
assert "c1" not in gw._client_sessions
|
||||
assert ws.sent[-1]["type"] == "error"
|
||||
|
||||
|
||||
def test_join_denies_foreign_session_before_resume(monkeypatch):
|
||||
# The denial must happen before resume touches the foreign session.
|
||||
gw, ws = _gateway_with_client(["support"])
|
||||
foreign = gw.create_session("finance", client_id="other")
|
||||
calls = []
|
||||
monkeypatch.setattr(gw, "resume_or_create_session", lambda **kw: calls.append(kw))
|
||||
asyncio.run(gw._handle_client_message(
|
||||
"c1",
|
||||
{"type": "join", "agent_id": "support", "session_id": foreign.session_id, "since": 0},
|
||||
))
|
||||
assert calls == []
|
||||
assert ws.sent[-1]["type"] == "error"
|
||||
assert foreign.client_id == "other"
|
||||
|
||||
|
||||
def test_attach_refuses_confined_client_on_foreign_agent_session():
|
||||
gw, _ = _gateway_with_client(["support"])
|
||||
foreign = gw.create_session("finance", client_id="other", visibility="shared")
|
||||
with pytest.raises(PermissionError):
|
||||
gw.attach(foreign.session_id, "c1", role="member")
|
||||
assert "c1" not in gw._client_sessions
|
||||
assert "c1" not in gw._session_observers.get(foreign.session_id, {})
|
||||
own = gw.create_session("support", client_id="other", visibility="shared")
|
||||
gw.attach(own.session_id, "c1", role="member")
|
||||
assert gw._client_sessions["c1"] == own.session_id
|
||||
|
||||
|
||||
def test_unconfined_operator_reaches_every_agent():
|
||||
gw, _ = _gateway_with_client(None)
|
||||
asyncio.run(gw._handle_client_message("c1", {"type": "hello", "agent_id": "finance"}))
|
||||
assert gw._sessions[gw._client_sessions["c1"]].agent_id == "finance"
|
||||
|
||||
|
||||
def test_broadcast_withholds_other_agents_events():
|
||||
gw, ws = _gateway_with_client(["support"])
|
||||
|
||||
async def _run():
|
||||
await gw.broadcast(GatewayEvent(type=EventType.MESSAGE, data={}, source="finance"))
|
||||
await gw.broadcast(GatewayEvent(type=EventType.MESSAGE, data={}, source="support"))
|
||||
await gw.broadcast(GatewayEvent(type=EventType.MESSAGE, data={}, source="system"))
|
||||
|
||||
asyncio.run(_run())
|
||||
assert [f.get("source") for f in ws.sent] == ["support", "system"]
|
||||
|
||||
|
||||
def test_misspelled_grant_key_fails_closed():
|
||||
# A typo must not silently drop the confinement (it would reach every agent).
|
||||
from praisonaiagents.gateway.config import (
|
||||
ConfigValidationError, MultiChannelGatewayConfig,
|
||||
)
|
||||
from praisonai_bot.bots._config_schema import GatewayServerSchema
|
||||
|
||||
bad = {"tok": {"scopes": ["read"], "agnts": ["support"]}}
|
||||
with pytest.raises(ConfigValidationError):
|
||||
MultiChannelGatewayConfig.from_dict({"gateway": {"auth_scopes": bad}})
|
||||
with pytest.raises(Exception):
|
||||
GatewayServerSchema(auth_scopes=bad)
|
||||
GatewayServerSchema(auth_scopes=POLICY) # valid shapes still accepted
|
||||
|
||||
# Same for the ``auth.tokens[]`` form.
|
||||
bad_tokens = {"tokens": [{"token": "tok", "scopes": ["read"], "agnts": ["support"]}]}
|
||||
with pytest.raises(ConfigValidationError):
|
||||
MultiChannelGatewayConfig.from_dict({"gateway": {"auth": bad_tokens}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gw", [
|
||||
{"auth_scopes": {"tok": {"scopes": ["read"], "agents": "support"}}},
|
||||
{"auth": {"tokens": [{"token": "tok", "scopes": ["read"], "agents": "support"}]}},
|
||||
])
|
||||
def test_scalar_agents_value_rejected(gw):
|
||||
# A scalar must not be split into single-character agent ids.
|
||||
from praisonaiagents.gateway.config import MultiChannelGatewayConfig
|
||||
|
||||
with pytest.raises(ValueError, match="agents"):
|
||||
MultiChannelGatewayConfig.from_dict({"gateway": gw})
|
||||
|
||||
|
||||
class _StopStartup(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gw_yaml", [
|
||||
{"auth_scopes": {"tok_support": {"scopes": ["read"], "agents": ["support"]}}},
|
||||
{"auth": {"tokens": [{"token": "tok_support", "scopes": ["read"], "agents": ["support"]}]}},
|
||||
])
|
||||
def test_start_with_config_applies_yaml_policy(gw_yaml, monkeypatch):
|
||||
# The YAML startup path must stamp the policy onto self.config, otherwise
|
||||
# resolve_agents() returns None and confinement is silently off.
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
cfg = {"gateway": dict(gw_yaml, session={"persist": False})}
|
||||
monkeypatch.setattr(gw, "load_gateway_config", lambda path: cfg)
|
||||
monkeypatch.setattr(gw, "_session_store_explicit", False, raising=False)
|
||||
|
||||
def _stop(*a, **k):
|
||||
raise _StopStartup
|
||||
|
||||
monkeypatch.setattr(gw, "_build_session_store", _stop)
|
||||
with pytest.raises(_StopStartup):
|
||||
asyncio.run(gw.start_with_config("gateway.yaml"))
|
||||
assert gw.config.resolve_agents("tok_support") == ["support"]
|
||||
assert gw.config.resolve_scopes("tok_support") == ["read"]
|
||||
|
||||
|
||||
def test_start_with_config_keeps_python_policy_without_yaml_policy(monkeypatch):
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp", auth_scopes=POLICY))
|
||||
monkeypatch.setattr(gw, "load_gateway_config", lambda path: {"gateway": {"session": {}}})
|
||||
monkeypatch.setattr(gw, "_session_store_explicit", False, raising=False)
|
||||
monkeypatch.setattr(gw, "_build_session_store", lambda *a, **k: (_ for _ in ()).throw(_StopStartup()))
|
||||
with pytest.raises(_StopStartup):
|
||||
asyncio.run(gw.start_with_config("gateway.yaml"))
|
||||
assert gw.config.auth_scopes == POLICY
|
||||
|
||||
|
||||
def _start(gw, cfg, monkeypatch):
|
||||
cfg["gateway"].setdefault("session", {"persist": False})
|
||||
monkeypatch.setattr(gw, "load_gateway_config", lambda path: cfg)
|
||||
monkeypatch.setattr(gw, "_session_store_explicit", False, raising=False)
|
||||
monkeypatch.setattr(gw, "_build_session_store", lambda *a, **k: (_ for _ in ()).throw(_StopStartup()))
|
||||
asyncio.run(gw.start_with_config("gateway.yaml"))
|
||||
|
||||
|
||||
def test_start_with_config_expands_env_placeholder_keys(monkeypatch):
|
||||
# The loader resolves ${VAR} in values only; auth_scopes keys must be
|
||||
# expanded too, and the literal placeholder must not be a grant key.
|
||||
monkeypatch.setenv("SUPPORT_TOKEN", "real-support-token")
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
cfg = {"gateway": {"auth_scopes": {
|
||||
"${SUPPORT_TOKEN}": {"scopes": ["read"], "agents": ["support"]},
|
||||
"${UNSET_TOKEN_6253}": ["read", "write"],
|
||||
}}}
|
||||
with pytest.raises(_StopStartup):
|
||||
_start(gw, cfg, monkeypatch)
|
||||
assert gw.config.auth_scopes == {
|
||||
"real-support-token": {"scopes": ["read"], "agents": ["support"]},
|
||||
}
|
||||
assert gw.config.resolve_agents("real-support-token") == ["support"]
|
||||
assert gw.config.resolve_scopes("${SUPPORT_TOKEN}") == []
|
||||
assert gw.config.resolve_scopes("") == []
|
||||
|
||||
|
||||
def test_start_with_config_refuses_policy_with_only_unset_tokens(monkeypatch):
|
||||
# An all-empty policy would mean "no policy" (fail open), so startup stops.
|
||||
monkeypatch.delenv("UNSET_TOKEN_6253", raising=False)
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
cfg = {"gateway": {"auth_scopes": {
|
||||
"${UNSET_TOKEN_6253}": {"scopes": ["read"], "agents": ["support"]},
|
||||
}}}
|
||||
with pytest.raises(ValueError, match="resolved to empty"):
|
||||
_start(gw, cfg, monkeypatch)
|
||||
# Tokens form: the loader already turned the unset value into "".
|
||||
cfg = {"gateway": {"auth": {"tokens": [
|
||||
{"token": "", "scopes": ["read"], "agents": ["support"]},
|
||||
]}}}
|
||||
with pytest.raises(ValueError, match="resolved to empty"):
|
||||
_start(gw, cfg, monkeypatch)
|
||||
|
||||
|
||||
def test_start_with_config_tolerates_core_without_policy_parser(monkeypatch):
|
||||
# An older praisonaiagents lacks _parse_auth_scopes / resolve_agents; with
|
||||
# no YAML policy the gateway must still start instead of ImportError.
|
||||
import praisonaiagents.gateway.config as core_cfg
|
||||
|
||||
monkeypatch.delattr(core_cfg, "_parse_auth_scopes")
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp", auth_scopes=POLICY))
|
||||
with pytest.raises(_StopStartup):
|
||||
_start(gw, {"gateway": {}}, monkeypatch)
|
||||
assert gw.config.auth_scopes == POLICY
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gw_yaml", [
|
||||
{"auth_scopes": {"support-token": {"scopes": ["read"], "agents": ["support"]}}},
|
||||
{"auth": {"tokens": [{"token": "t", "scopes": ["read"], "agents": ["support"]}]}},
|
||||
])
|
||||
def test_start_with_config_refuses_unenforceable_policy_on_older_core(gw_yaml, monkeypatch):
|
||||
# A declared policy that the installed core cannot parse must fail closed,
|
||||
# not start with every operator unconfined.
|
||||
import praisonaiagents.gateway.config as core_cfg
|
||||
|
||||
monkeypatch.delattr(core_cfg, "_parse_auth_scopes")
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
with pytest.raises(ValueError, match="cannot enforce"):
|
||||
_start(gw, {"gateway": dict(gw_yaml)}, monkeypatch)
|
||||
assert not gw.config.auth_scopes
|
||||
Generated
+2
-2
@@ -1428,7 +1428,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-bot"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1510,7 +1510,7 @@ provides-extras = ["gateway", "bot", "bot-whatsapp-web", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-browser"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Browser automation for PraisonAI — Chrome extension bridge, CDP, and Playwright agents extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -932,7 +932,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-browser"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -976,7 +976,7 @@ provides-extras = ["server", "playwright", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -12,6 +12,6 @@ the remaining terminal-agent modules, with PEP 562 shims left behind at the
|
||||
old ``praisonai.*`` paths for backward compatibility.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -2393,6 +2393,8 @@ def _run_prompt(
|
||||
if allow_local_tools:
|
||||
_os.environ[_ALLOW_LOCAL_TOOLS_ENV] = "true"
|
||||
|
||||
_manifest_session = None
|
||||
|
||||
try:
|
||||
# Handle session continuity first (before any execution mode)
|
||||
from praisonai_code.cli.main import PraisonAI
|
||||
@@ -2442,6 +2444,13 @@ def _run_prompt(
|
||||
import uuid
|
||||
auto_save_name = session_id or "session-" + str(uuid.uuid4())[:8]
|
||||
|
||||
# Write-ahead "run in flight" marker on the session manifest, released
|
||||
# in ``finally`` below; a run that dies before then is reported as
|
||||
# interrupted on the next --continue/--session (Issue #6248).
|
||||
_manifest_session = _begin_manifest_run(
|
||||
session_id or auto_save_name, prompt, output, quiet=_json_stdout
|
||||
)
|
||||
|
||||
# Detect-and-attach: if a warm runtime is running, forward the plain
|
||||
# prompt to it (skipping per-invocation cold-start) and fall back to
|
||||
# in-process execution otherwise. Only the simple text path attaches;
|
||||
@@ -2774,6 +2783,7 @@ def _run_prompt(
|
||||
output.print_error(str(e))
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
_end_manifest_run(_manifest_session)
|
||||
# Restore the prior opt-in state so the grant is strictly per-invocation.
|
||||
if _prev_allow_local_tools is None:
|
||||
_os.environ.pop(_ALLOW_LOCAL_TOOLS_ENV, None)
|
||||
@@ -2785,6 +2795,57 @@ def _run_prompt(
|
||||
output.mode = _prev_mode
|
||||
|
||||
|
||||
def _begin_manifest_run(session_key, prompt, output, *, quiet: bool = False):
|
||||
"""Commit the session manifest's in-flight marker for this run.
|
||||
|
||||
If the previous run on this session never released its marker it was
|
||||
interrupted; say so, so the user can re-issue it. Returns the session key
|
||||
a ``(manifest, session_key, run_id)`` handle to release later, or ``None``
|
||||
when there is no session or the manifest is unavailable (manifest problems
|
||||
never block a run).
|
||||
"""
|
||||
if not session_key:
|
||||
return None
|
||||
try:
|
||||
import uuid
|
||||
from praisonaiagents.session.manifest import FileSessionManifest
|
||||
from ..utils.project import get_project_sessions_dir
|
||||
|
||||
# Scope manifests like the project session store, so equal session
|
||||
# names in different projects never share a run marker.
|
||||
manifest = FileSessionManifest(str(get_project_sessions_dir() / "manifests"))
|
||||
stale = manifest.interrupted(session_key)
|
||||
if stale is not None and not quiet:
|
||||
# The prompt itself is not stored (only a digest), so it is not echoed.
|
||||
output.print_warning(
|
||||
f"The previous run in this session was interrupted "
|
||||
f"(attempt {stale.attempt}); re-issue it to retry."
|
||||
)
|
||||
run_id = uuid.uuid4().hex[:12]
|
||||
manifest.begin_run(session_key, run_id, run_input=prompt)
|
||||
return manifest, session_key, run_id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _end_manifest_run(handle) -> None:
|
||||
"""Release this run's own in-flight marker unless it was interrupted (Ctrl+C)."""
|
||||
if not handle:
|
||||
return
|
||||
import sys
|
||||
|
||||
exc = sys.exc_info()[1]
|
||||
if isinstance(exc, KeyboardInterrupt):
|
||||
return
|
||||
manifest, session_key, run_id = handle
|
||||
try:
|
||||
manifest.end_run(
|
||||
session_key, "completed" if exc is None else "ended", run_id=run_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _record_session_usage(session_id, model, output, *, suppress_info: bool = False) -> None:
|
||||
"""Accumulate this run's token/cost usage into the active session and show
|
||||
a compact running total footer (Issue #2421).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-code"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Agentic terminal CLI for PraisonAI — the terminal-native agent (run, chat, code, runtime, cli backends) extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -1370,7 +1370,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-code"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1403,7 +1403,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-deploy"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Deployment for PraisonAI — API, Docker, and cloud (AWS, Azure, GCP) deployments extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -977,7 +977,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-deploy"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1014,7 +1014,7 @@ provides-extras = ["api", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.22"
|
||||
__version__ = "0.1.23"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-mcp"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
description = "MCP server hosting for PraisonAI — expose agents and tools to Cursor, Claude Desktop, and other MCP clients."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -1044,7 +1044,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-mcp"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1084,7 +1084,7 @@ provides-extras = ["server", "auth", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-sandbox"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Where PraisonAI agents run code — Docker, E2B, Modal, Daytona, Fly.io, Tenki, Novita, Sandlock, SSH and subprocess, as both single sandboxes and multi-instance compute providers."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -1679,7 +1679,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-sandbox"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1744,7 +1744,7 @@ provides-extras = ["docker", "e2b", "sandlock", "ssh", "tenki", "flyio", "modal"
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-train"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Training for PraisonAI — LLM fine-tuning (Unsloth) and iterative agent training extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
Generated
+2
-2
@@ -2606,7 +2606,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-train"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -2696,7 +2696,7 @@ provides-extras = ["llm", "vision", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -46,7 +46,7 @@ This complements `PARITY.md`, which only tracks whether an export exists.
|
||||
|
||||
### `Agent.__init__`
|
||||
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agent/agent.py:631`
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agent/agent.py:632`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:197` (ctor `src/praisonai-ts/src/agent/simple.ts:1019`)
|
||||
- Counts: 42 python params: 29 exact, 8 camelCase, 2 alias, 3 flattened, 0 missing; 8 mismatches; 8 waived; 14 TS-only of 60
|
||||
|
||||
@@ -219,7 +219,7 @@ TS-only members: `previousResult`?, `onToken`?, `signal`?, `onEvent`?, `options`
|
||||
|
||||
### `Agent.chat`
|
||||
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3714`
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3734`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:3592`
|
||||
- Counts: 17 python params: 7 exact, 9 camelCase, 1 alias, 0 flattened, 0 missing; 0 mismatches; 0 waived; 2 TS-only of 19
|
||||
|
||||
|
||||
@@ -1185,7 +1185,7 @@
|
||||
}
|
||||
],
|
||||
"python": {
|
||||
"location": "src/praisonai-agents/praisonaiagents/agent/agent.py:631",
|
||||
"location": "src/praisonai-agents/praisonaiagents/agent/agent.py:632",
|
||||
"resolved_class": "Agent"
|
||||
},
|
||||
"ts_only": [
|
||||
@@ -1797,7 +1797,7 @@
|
||||
}
|
||||
],
|
||||
"python": {
|
||||
"location": "src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3714",
|
||||
"location": "src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3734",
|
||||
"resolved_class": "ChatMixin"
|
||||
},
|
||||
"ts_only": [
|
||||
|
||||
@@ -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/v4.7.23.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.7.23.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.7.24.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.7.24.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -60,6 +60,20 @@ def _download_image(url: str, path: str) -> None:
|
||||
f.write(data)
|
||||
|
||||
|
||||
# dall-e-2/dall-e-3 were retired by OpenAI on 2026-05-12; gpt-image-1 on 2026-10-23.
|
||||
DEFAULT_IMAGE_MODEL = "gpt-image-2.5-flare"
|
||||
|
||||
|
||||
def _reject_markup(kwargs: Dict[str, Any]) -> None:
|
||||
"""Fail loudly: LiteLLM uploads only ``image``/``mask`` as files, so ``markup``
|
||||
would be dropped or sent as plain text and silently ignored by the edit."""
|
||||
if kwargs.get('markup') is not None:
|
||||
raise ValueError(
|
||||
"'markup' is not supported through LiteLLM's image_edit yet (it is not "
|
||||
"uploaded as a file). Use 'mask' or call the OpenAI SDK's images.edit directly."
|
||||
)
|
||||
|
||||
|
||||
def _model_rejects_response_format(model: str) -> bool:
|
||||
"""Return True when the target OpenAI image model rejects ``response_format``.
|
||||
|
||||
@@ -94,10 +108,10 @@ def _finalize_image_kwargs(call_kwargs: Dict[str, Any], model: str) -> None:
|
||||
|
||||
def image_generate(
|
||||
prompt: str,
|
||||
model: str = "dall-e-3",
|
||||
model: str = DEFAULT_IMAGE_MODEL,
|
||||
n: int = 1,
|
||||
size: str = "1024x1024",
|
||||
quality: str = "standard",
|
||||
quality: Optional[str] = None,
|
||||
style: Optional[str] = None,
|
||||
response_format: str = "url",
|
||||
timeout: float = 600.0,
|
||||
@@ -111,11 +125,12 @@ def image_generate(
|
||||
|
||||
Args:
|
||||
prompt: Text description of the image to generate
|
||||
model: Model name (e.g., "dall-e-3", "dall-e-2", "stability/...")
|
||||
model: Model name (e.g., "gpt-image-2.5-flare", "gpt-image-2.6-flare", "stability/...")
|
||||
n: Number of images to generate
|
||||
size: Image size (e.g., "1024x1024", "1792x1024", "1024x1792")
|
||||
quality: Image quality ("standard" or "hd")
|
||||
style: Image style ("vivid" or "natural")
|
||||
quality: Image quality, sent only when set (gpt-image: "low", "medium",
|
||||
"high", "xhigh", "max", "auto")
|
||||
style: Image style ("vivid" or "natural"; dall-e-3 only)
|
||||
response_format: "url" or "b64_json"
|
||||
timeout: Request timeout in seconds
|
||||
api_key: Optional API key override
|
||||
@@ -140,11 +155,11 @@ def image_generate(
|
||||
'timeout': timeout,
|
||||
}
|
||||
|
||||
# Only add quality/style for dall-e-3 (not supported by dall-e-2)
|
||||
if 'dall-e-3' in model.lower():
|
||||
if quality is not None:
|
||||
call_kwargs['quality'] = quality
|
||||
if style:
|
||||
call_kwargs['style'] = style
|
||||
# Only dall-e-3 accepts style
|
||||
if style and 'dall-e-3' in model.lower():
|
||||
call_kwargs['style'] = style
|
||||
if api_key:
|
||||
call_kwargs['api_key'] = api_key
|
||||
if api_base:
|
||||
@@ -174,10 +189,10 @@ def image_generate(
|
||||
|
||||
async def aimage_generate(
|
||||
prompt: str,
|
||||
model: str = "dall-e-3",
|
||||
model: str = DEFAULT_IMAGE_MODEL,
|
||||
n: int = 1,
|
||||
size: str = "1024x1024",
|
||||
quality: str = "standard",
|
||||
quality: Optional[str] = None,
|
||||
style: Optional[str] = None,
|
||||
response_format: str = "url",
|
||||
timeout: float = 600.0,
|
||||
@@ -202,11 +217,11 @@ async def aimage_generate(
|
||||
'timeout': timeout,
|
||||
}
|
||||
|
||||
# Only add quality/style for dall-e-3 (not supported by dall-e-2)
|
||||
if 'dall-e-3' in model.lower():
|
||||
if quality is not None:
|
||||
call_kwargs['quality'] = quality
|
||||
if style:
|
||||
call_kwargs['style'] = style
|
||||
# Only dall-e-3 accepts style
|
||||
if style and 'dall-e-3' in model.lower():
|
||||
call_kwargs['style'] = style
|
||||
if api_key:
|
||||
call_kwargs['api_key'] = api_key
|
||||
if api_base:
|
||||
@@ -237,7 +252,7 @@ async def aimage_generate(
|
||||
def image_edit(
|
||||
image: Union[str, bytes],
|
||||
prompt: str,
|
||||
model: str = "dall-e-2",
|
||||
model: str = DEFAULT_IMAGE_MODEL,
|
||||
mask: Optional[Union[str, bytes]] = None,
|
||||
n: int = 1,
|
||||
size: str = "1024x1024",
|
||||
@@ -267,6 +282,7 @@ def image_edit(
|
||||
Returns:
|
||||
List of ImageResult objects
|
||||
"""
|
||||
_reject_markup(kwargs)
|
||||
import litellm
|
||||
|
||||
# Handle file paths
|
||||
@@ -325,7 +341,7 @@ def image_edit(
|
||||
async def aimage_edit(
|
||||
image: Union[str, bytes],
|
||||
prompt: str,
|
||||
model: str = "dall-e-2",
|
||||
model: str = DEFAULT_IMAGE_MODEL,
|
||||
mask: Optional[Union[str, bytes]] = None,
|
||||
n: int = 1,
|
||||
size: str = "1024x1024",
|
||||
@@ -341,6 +357,7 @@ async def aimage_edit(
|
||||
|
||||
See image_edit() for full documentation.
|
||||
"""
|
||||
_reject_markup(kwargs)
|
||||
import litellm
|
||||
|
||||
image_file = image
|
||||
|
||||
@@ -18,11 +18,39 @@ class VideoResult:
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _build_call_kwargs(
|
||||
model: str,
|
||||
prompt: str,
|
||||
duration: Optional[int],
|
||||
aspect_ratio: Optional[str],
|
||||
timeout: float,
|
||||
kwargs: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build litellm.video_generation kwargs without unset or non-spec fields.
|
||||
|
||||
LiteLLM's video providers read ``seconds`` and ``size`` (OpenAI Videos API
|
||||
names). Unknown keys such as ``duration`` are forwarded verbatim, and
|
||||
strict OpenAI-compatible servers reject the request when they are
|
||||
present. Map ``duration`` to ``seconds`` and drop anything left unset.
|
||||
"""
|
||||
call_kwargs: Dict[str, Any] = {
|
||||
'model': model,
|
||||
'prompt': prompt,
|
||||
'timeout': timeout,
|
||||
}
|
||||
if duration is not None and 'seconds' not in kwargs:
|
||||
call_kwargs['seconds'] = str(duration)
|
||||
if aspect_ratio is not None:
|
||||
call_kwargs['aspect_ratio'] = aspect_ratio
|
||||
call_kwargs.update(kwargs)
|
||||
return call_kwargs
|
||||
|
||||
|
||||
def video_generate(
|
||||
prompt: str,
|
||||
model: str = "openai/sora-2",
|
||||
duration: int = 5,
|
||||
aspect_ratio: str = "16:9",
|
||||
duration: Optional[int] = None,
|
||||
aspect_ratio: Optional[str] = None,
|
||||
timeout: float = 600.0,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
@@ -35,8 +63,11 @@ def video_generate(
|
||||
Args:
|
||||
prompt: Text description of the video to generate
|
||||
model: Model name (e.g., "openai/sora-2")
|
||||
duration: Video duration in seconds
|
||||
aspect_ratio: Aspect ratio (e.g., "16:9", "9:16", "1:1")
|
||||
duration: Video duration in seconds. Sent as ``seconds`` (the OpenAI
|
||||
Videos API field) unless ``seconds`` is passed explicitly.
|
||||
Omitted when None, so the provider default applies.
|
||||
aspect_ratio: Aspect ratio (e.g., "16:9", "9:16", "1:1"). Only
|
||||
forwarded when set; OpenAI-style providers take ``size`` instead.
|
||||
timeout: Request timeout in seconds
|
||||
api_key: Optional API key override
|
||||
api_base: Optional API base URL override
|
||||
@@ -51,21 +82,13 @@ def video_generate(
|
||||
"""
|
||||
import litellm
|
||||
|
||||
call_kwargs = {
|
||||
'model': model,
|
||||
'prompt': prompt,
|
||||
'duration': duration,
|
||||
'aspect_ratio': aspect_ratio,
|
||||
'timeout': timeout,
|
||||
}
|
||||
call_kwargs = _build_call_kwargs(model, prompt, duration, aspect_ratio, timeout, kwargs)
|
||||
|
||||
if api_key:
|
||||
call_kwargs['api_key'] = api_key
|
||||
if api_base:
|
||||
call_kwargs['api_base'] = api_base
|
||||
|
||||
call_kwargs.update(kwargs)
|
||||
|
||||
if metadata:
|
||||
call_kwargs['metadata'] = metadata
|
||||
|
||||
@@ -91,8 +114,8 @@ def video_generate(
|
||||
async def avideo_generate(
|
||||
prompt: str,
|
||||
model: str = "openai/sora-2",
|
||||
duration: int = 5,
|
||||
aspect_ratio: str = "16:9",
|
||||
duration: Optional[int] = None,
|
||||
aspect_ratio: Optional[str] = None,
|
||||
timeout: float = 600.0,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
@@ -106,21 +129,13 @@ async def avideo_generate(
|
||||
"""
|
||||
import litellm
|
||||
|
||||
call_kwargs = {
|
||||
'model': model,
|
||||
'prompt': prompt,
|
||||
'duration': duration,
|
||||
'aspect_ratio': aspect_ratio,
|
||||
'timeout': timeout,
|
||||
}
|
||||
call_kwargs = _build_call_kwargs(model, prompt, duration, aspect_ratio, timeout, kwargs)
|
||||
|
||||
if api_key:
|
||||
call_kwargs['api_key'] = api_key
|
||||
if api_base:
|
||||
call_kwargs['api_base'] = api_base
|
||||
|
||||
call_kwargs.update(kwargs)
|
||||
|
||||
if metadata:
|
||||
call_kwargs['metadata'] = metadata
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.7.23"
|
||||
__version__ = "4.7.24"
|
||||
@@ -12,14 +12,14 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.7.21",
|
||||
"praisonai-code>=0.1.20",
|
||||
"praisonai-bot>=0.1.20",
|
||||
"praisonai-train>=0.1.20",
|
||||
"praisonai-browser>=0.1.20",
|
||||
"praisonai-mcp>=0.1.22",
|
||||
"praisonai-sandbox>=0.1.20",
|
||||
"praisonai-deploy>=0.1.20",
|
||||
"praisonaiagents>=1.7.22",
|
||||
"praisonai-code>=0.1.21",
|
||||
"praisonai-bot>=0.1.21",
|
||||
"praisonai-train>=0.1.21",
|
||||
"praisonai-browser>=0.1.21",
|
||||
"praisonai-mcp>=0.1.23",
|
||||
"praisonai-sandbox>=0.1.21",
|
||||
"praisonai-deploy>=0.1.21",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.83.14,<1.106", # 1.106 imports mcp.types.CacheableResult (mcp>=2.2); we pin mcp<2
|
||||
"PyYAML>=6.0",
|
||||
|
||||
@@ -178,6 +178,77 @@ class TestImagesCapabilities:
|
||||
captured = mock_image_generation.call_args.kwargs
|
||||
assert captured.get('drop_params') is False
|
||||
|
||||
@patch('litellm.image_generation')
|
||||
def test_image_generate_default_model_is_not_retired(self, mock_image_generation):
|
||||
"""dall-e-2/3 were retired 2026-05-12; the default must be a GPT image model (#6259)."""
|
||||
from praisonai.capabilities.images import image_generate
|
||||
|
||||
mock_image_generation.return_value = Mock(data=[])
|
||||
image_generate("x")
|
||||
|
||||
captured = mock_image_generation.call_args.kwargs
|
||||
assert captured['model'] == "gpt-image-2.5-flare"
|
||||
assert 'quality' not in captured # "standard" is not a valid gpt-image quality
|
||||
|
||||
@patch('litellm.image_generation')
|
||||
def test_image_generate_forwards_quality_and_images_26_params(self, mock_image_generation):
|
||||
"""quality must reach gpt-image models, not only dall-e-3 (#6259)."""
|
||||
from praisonai.capabilities.images import image_generate
|
||||
|
||||
mock_image_generation.return_value = Mock(data=[])
|
||||
params = {"frame_count": 4, "frame_duration_ms": 100, "complexity": "low",
|
||||
"output_format": "gif"}
|
||||
image_generate("x", model="gpt-image-2.6-flare", quality="low", **params)
|
||||
|
||||
captured = mock_image_generation.call_args.kwargs
|
||||
assert captured['quality'] == "low"
|
||||
assert params.items() <= captured.items()
|
||||
|
||||
def test_image_generate_images_26_params_reach_the_wire(self):
|
||||
"""Real LiteLLM, fake HTTP: params must be in the request body, not just in
|
||||
the kwargs handed to litellm (drop_params=True could strip them silently)."""
|
||||
import json
|
||||
httpx = pytest.importorskip("httpx")
|
||||
openai = pytest.importorskip("openai")
|
||||
pytest.importorskip("litellm")
|
||||
from praisonai.capabilities.images import image_generate
|
||||
|
||||
bodies = []
|
||||
|
||||
def handler(request):
|
||||
bodies.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"created": 1, "data": [{"b64_json": "AA=="}]})
|
||||
|
||||
client = openai.OpenAI(api_key="sk-test",
|
||||
http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
||||
params = {"quality": "low", "frame_count": 4, "frame_duration_ms": 100, "complexity": "low"}
|
||||
results = image_generate("x", model="gpt-image-2.6-flare", api_key="sk-test",
|
||||
client=client, **params)
|
||||
|
||||
assert results[0].b64_json == "AA=="
|
||||
assert params.items() <= bodies[0].items()
|
||||
assert "response_format" not in bodies[0]
|
||||
|
||||
@patch('litellm.aimage_generation')
|
||||
def test_aimage_generate_forwards_quality(self, mock_aimage_generation):
|
||||
import asyncio
|
||||
from praisonai.capabilities.images import aimage_generate
|
||||
|
||||
async def fake(**kw):
|
||||
return Mock(data=[])
|
||||
mock_aimage_generation.side_effect = fake
|
||||
asyncio.run(aimage_generate("x", model="gpt-image-2.5-flare", quality="low"))
|
||||
assert mock_aimage_generation.call_args.kwargs['quality'] == "low"
|
||||
|
||||
@patch('litellm.image_edit')
|
||||
def test_image_edit_rejects_markup(self, mock_image_edit):
|
||||
"""markup is never uploaded as a file by LiteLLM, so fail loudly (#6259)."""
|
||||
from praisonai.capabilities.images import image_edit
|
||||
|
||||
with pytest.raises(ValueError, match="markup"):
|
||||
image_edit(b"img", "make it blue", model="gpt-image-2.5-flare", markup=b"m")
|
||||
mock_image_edit.assert_not_called()
|
||||
|
||||
@patch('litellm.image_edit')
|
||||
def test_image_edit_drops_response_format_for_gpt_image(self, mock_image_edit):
|
||||
"""image_edit must also drop response_format for gpt-image-* and scope drop_params."""
|
||||
@@ -989,3 +1060,65 @@ class TestGuardrailFailClosed:
|
||||
|
||||
result = asyncio.run(_run())
|
||||
assert result.passed is False
|
||||
|
||||
|
||||
class TestVideoGenerateKwargs:
|
||||
"""video_generate must only send OpenAI Videos API field names upstream.
|
||||
|
||||
LiteLLM forwards unknown keys (e.g. ``duration``) verbatim to
|
||||
OpenAI-compatible ``/v1/videos`` servers, which reject them.
|
||||
"""
|
||||
|
||||
@patch('litellm.video_generation')
|
||||
def test_defaults_send_no_duration_or_aspect_ratio(self, mock_gen):
|
||||
from praisonai.capabilities.videos import video_generate
|
||||
|
||||
mock_gen.return_value = SimpleNamespace(id="video_1", status="queued")
|
||||
result = video_generate("a boat", model="openai/my-video-model")
|
||||
|
||||
sent = mock_gen.call_args.kwargs
|
||||
assert "duration" not in sent
|
||||
assert "aspect_ratio" not in sent
|
||||
assert "seconds" not in sent
|
||||
assert result.id == "video_1"
|
||||
assert result.status == "queued"
|
||||
|
||||
@patch('litellm.video_generation')
|
||||
def test_duration_maps_to_seconds(self, mock_gen):
|
||||
from praisonai.capabilities.videos import video_generate
|
||||
|
||||
mock_gen.return_value = SimpleNamespace(id="video_1", status="queued")
|
||||
video_generate("a boat", model="openai/my-video-model", duration=8)
|
||||
|
||||
sent = mock_gen.call_args.kwargs
|
||||
assert sent["seconds"] == "8"
|
||||
assert "duration" not in sent
|
||||
|
||||
@patch('litellm.video_generation')
|
||||
def test_explicit_seconds_and_size_win(self, mock_gen):
|
||||
from praisonai.capabilities.videos import video_generate
|
||||
|
||||
mock_gen.return_value = SimpleNamespace(id="video_1", status="queued")
|
||||
video_generate(
|
||||
"a boat", model="openai/my-video-model", duration=8, seconds="4", size="1344x768"
|
||||
)
|
||||
|
||||
sent = mock_gen.call_args.kwargs
|
||||
assert sent["seconds"] == "4"
|
||||
assert sent["size"] == "1344x768"
|
||||
|
||||
def test_async_uses_same_mapping(self):
|
||||
import asyncio
|
||||
from praisonai.capabilities.videos import avideo_generate
|
||||
|
||||
async def fake(**kwargs):
|
||||
fake.kwargs = kwargs
|
||||
return SimpleNamespace(id="video_2", status="queued")
|
||||
|
||||
with patch('litellm.avideo_generation', fake):
|
||||
asyncio.run(
|
||||
avideo_generate("a boat", model="openai/my-video-model", aspect_ratio="9:16")
|
||||
)
|
||||
|
||||
assert "duration" not in fake.kwargs
|
||||
assert fake.kwargs["aspect_ratio"] == "9:16"
|
||||
Reference in new issue
Block a user