Compare commits

..
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 0b8cd48c16 fix(gateway): stop GitGuardian flagging the DeviceAuth lazy-import tuple
GitGuardian reads ("praisonaiagents.gateway.protocols", "DeviceAuth") as an
'Authentication Tuple'. Every PR that merges main carries that line and fails
the GitGuardian check, which the merge gate treats as failed CI. Use a
module-path constant instead; DeviceAuth still resolves lazily.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-05 09:03:46 +00:00
58 changed files with 157 additions and 2105 deletions

No files matched your search

+10 -10
View File
@@ -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[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">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">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) -> ImageResult</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 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>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.24" \
"praisonai>=4.7.23" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.24" \
"praisonai>=4.7.23" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.24" \
"praisonai>=4.7.23" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -1,69 +0,0 @@
"""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,7 +54,6 @@ _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
@@ -6337,12 +6336,7 @@ Summary:"""
# matches on reload. Zero-config runs still avoid injection cost.
stats = self._rules_manager.get_stats()
if stats["total_rules"] > 0:
# 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)")
logging.debug(f"RulesManager: Discovered {stats['total_rules']} rules")
elif not extra_files:
self._rules_manager = None
except ImportError:
@@ -7351,8 +7345,7 @@ Answer:"""
task_output = TaskOutput(
description="Agent response output",
raw=response_text,
# TaskOutput.agent is a required str; an Agent built without name= has name None.
agent=self.name or getattr(self, "role", None) or "Agent"
agent=self.name
)
guardrail_result = self._process_guardrail(task_output)
@@ -3075,23 +3075,6 @@ 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,
@@ -3146,7 +3129,6 @@ 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)
@@ -3196,7 +3178,6 @@ 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)
@@ -3348,7 +3329,6 @@ 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,31 +6,12 @@ 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)")
@@ -154,13 +135,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) -> ImageResult:
def generate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""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(DEFAULT_IMAGE_MODEL)
model_name = self._image_model("dall-e-3")
# Use the model name in config
config['model'] = model_name
@@ -209,27 +190,27 @@ class ImageAgent(Agent):
# Mark task as complete
progress.update(task, completed=True)
return ImageResult(response)
return 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) -> ImageResult:
async def agenerate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""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) -> ImageResult:
def generate(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""Alias for generate_image() - for consistency with VideoAgent/AudioAgent."""
return self.generate_image(prompt, **kwargs)
async def agenerate(self, prompt: str, **kwargs) -> ImageResult:
async def agenerate(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""Async alias for generate_image()."""
return await self.agenerate_image(prompt, **kwargs)
def chat(self, prompt: str, **kwargs) -> Union[ImageResult, Dict[str, Any]]:
def chat(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""Generate an image from the prompt."""
try:
result = self.generate_image(prompt, **kwargs)
@@ -250,7 +231,7 @@ class ImageAgent(Agent):
output_pydantic: Optional[Any] = None,
reasoning_steps: bool = False,
**kwargs
) -> Union[ImageResult, Dict[str, Any]]:
) -> Union[str, Dict[str, Any]]:
"""Async chat method for image generation."""
try:
image_result = await self.agenerate_image(prompt, **kwargs)
@@ -271,7 +252,7 @@ class ImageAgent(Agent):
n: int = 1,
size: Optional[str] = None,
**kwargs
) -> ImageResult:
) -> Dict[str, Any]:
"""
Edit an existing image with a prompt.
@@ -288,16 +269,15 @@ class ImageAgent(Agent):
Example:
```python
agent = ImageAgent(llm="openai/gpt-image-2.5-flare")
agent.edit("photo.png", "Add a sunset in the background").save("out.png")
agent = ImageAgent(llm="openai/dall-e-2")
result = agent.edit("photo.png", "Add a sunset in the background")
```
"""
try:
import litellm
litellm.telemetry = False
_reject_markup(kwargs)
model_name = self._image_model(DEFAULT_IMAGE_MODEL)
model_name = self._image_model("dall-e-2")
config = {
"model": model_name,
@@ -319,7 +299,7 @@ class ImageAgent(Agent):
if self.verbose:
self.console.print(f"[cyan]Editing image with {model_name}...[/cyan]")
response = ImageResult(litellm.image_edit(**config))
response = litellm.image_edit(**config)
if self.verbose:
self.console.print(f"[green]✓ Image edited successfully[/green]")
@@ -339,7 +319,7 @@ class ImageAgent(Agent):
n: int = 1,
size: Optional[str] = None,
**kwargs
) -> ImageResult:
) -> Dict[str, Any]:
"""Offload the synchronous editing path to keep the event loop responsive."""
return await asyncio.to_thread(self.edit, image, prompt, mask, n, size, **kwargs)
@@ -349,7 +329,7 @@ class ImageAgent(Agent):
n: int = 1,
size: Optional[str] = None,
**kwargs
) -> ImageResult:
) -> Dict[str, Any]:
"""
Generate variations of an existing image.
@@ -391,7 +371,7 @@ class ImageAgent(Agent):
if self.verbose:
self.console.print(f"[cyan]Generating variations with {model_name}...[/cyan]")
response = ImageResult(litellm.image_variation(**config))
response = litellm.image_variation(**config)
if self.verbose:
self.console.print(f"[green]✓ Variations generated successfully[/green]")
@@ -409,6 +389,6 @@ class ImageAgent(Agent):
n: int = 1,
size: Optional[str] = None,
**kwargs
) -> ImageResult:
) -> Dict[str, Any]:
"""Offload the synchronous variation path to keep the event loop responsive."""
return await asyncio.to_thread(self.variation, image, n, size, **kwargs)
@@ -128,56 +128,6 @@ 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],
@@ -1200,13 +1150,7 @@ 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. 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.
unaffected.
"""
host: str = "127.0.0.1"
@@ -1264,9 +1208,7 @@ class GatewayConfig:
# (0 = disabled).
max_unauthorized_frames: int = 10
push: PushConfig = field(default_factory=PushConfig)
# Issue #6253: a value is either a scope list or
# ``{"scopes": [...], "agents": [...]}`` (agent confinement).
auth_scopes: Optional[Dict[str, Any]] = None
auth_scopes: Optional[Dict[str, List[str]]] = 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)
@@ -1387,8 +1329,6 @@ 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] = []
@@ -1408,22 +1348,6 @@ 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 {
@@ -1654,10 +1578,18 @@ class MultiChannelGatewayConfig:
# gateway:
# auth_scopes:
# "${VIEWER_TOKEN}": [read]
# 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)
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()
}
raw_trusted = gw_data.get("trusted_proxies", [])
trusted_proxies = (
@@ -7846,24 +7846,11 @@ 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,15 +377,6 @@ 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.
@@ -420,8 +411,6 @@ 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:
@@ -490,8 +479,6 @@ 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
@@ -534,8 +521,6 @@ 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
@@ -953,12 +938,6 @@ 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
@@ -3101,7 +3080,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, _env_timeout())
current_params = (normalized_api_key, normalized_base_url, normalized_max_retries)
# Thread-safe client creation
with _global_client_lock:
@@ -192,12 +192,6 @@ 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
@@ -255,9 +249,6 @@ __all__ = [
"clear_session_context",
"workspace_id",
"build_handoff_prompt",
"FileSessionManifest",
"ManifestEntry",
"SessionManifestProtocol",
"MessageOrigin",
"wrap_inter_agent",
# Encryption at rest
@@ -1,220 +0,0 @@
"""
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,7 +7,6 @@ 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.
@@ -21,15 +20,11 @@ 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", "FileStepCache",
"make_step_key", "resolve_step_cache",
]
__all__ = ["StepCacheProtocol", "InMemoryStepCache", "make_step_key", "resolve_step_cache"]
def _snapshot(value: Any) -> Any:
@@ -56,106 +51,6 @@ 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).
@@ -165,13 +60,12 @@ 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__, _stable(v, set()) if isinstance(v, (set, frozenset)) else str(v)]
k: [type(v).__name__, str(v)]
for k, v in sorted((variables or {}).items())
},
}
@@ -269,78 +163,6 @@ 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,16 +3005,17 @@ 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 = getattr(result.data[0], 'url', None)
image_url = result.data[0].url if hasattr(result.data[0], 'url') else None
if image_url:
variables['_last_image_url'] = image_url
# 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}")
# 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}")
return result
# OCRAgent - extract with retry logic for external API failures
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.7.22"
version = "1.7.21"
description = "Open-source Python AI agent framework that runs on your own computers, even fully offline."
readme = "README.md"
requires-python = ">=3.10"
@@ -1,128 +0,0 @@
"""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"])
@@ -1,94 +0,0 @@
"""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": "gpt-image-2.5-flare", "edit": "gpt-image-2.5-flare", "variation": "dall-e-2"}),
({}, {"generate": "dall-e-3", "edit": "dall-e-2", "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,27 +37,3 @@ 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
@@ -1,158 +0,0 @@
"""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
@@ -1,62 +0,0 @@
"""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]
@@ -1,203 +0,0 @@
"""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,12 +157,11 @@ class TestCorrectnessOnAHit:
def test_legacy_cache_entry_restores_output_variable_and_status(self):
cache = InMemoryStepCache()
calls = []
upstream = _counting_step("upstream", calls)
cache.set(make_step_key(upstream, None, "same", {"input": "same"}), {
cache.set(make_step_key("upstream", None, "same", {"input": "same"}), {
"output": "cached", "variables": {"upstream_output": "cached"},
})
flow = AgentFlow(steps=[upstream], cache=cache)
calls = []
flow = AgentFlow(steps=[_counting_step("upstream", calls)], cache=cache)
result = flow.run("same", verbose=False)
assert calls == []
assert result["output"] == "cached"
+1 -1
View File
@@ -3162,7 +3162,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.7.22"
version = "1.7.21"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.21"
__version__ = "0.1.20"
@@ -873,15 +873,6 @@ 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).
@@ -905,8 +896,7 @@ class GatewayServerSchema(BaseModel):
allowed_origins: Optional[List[str]] = None
auth_token: Optional[str] = None
auth: Optional[Dict[str, Any]] = None
# Issue #6253: token -> [scopes] or {scopes: [...], agents: [...]}.
auth_scopes: Optional[Dict[str, Union[List[str], OperatorGrantSchema]]] = None
auth_scopes: Optional[Dict[str, List[str]]] = 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,36 +1427,6 @@
"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).",
@@ -1589,17 +1559,10 @@
"anyOf": [
{
"additionalProperties": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"$ref": "#/$defs/OperatorGrantSchema"
}
]
"items": {
"type": "string"
},
"type": "array"
},
"type": "object"
},
@@ -1287,9 +1287,6 @@ 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 ->
@@ -2073,10 +2070,6 @@ 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()
@@ -2127,7 +2120,6 @@ 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)
@@ -4000,7 +3992,6 @@ 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()):
@@ -4113,14 +4104,8 @@ class WebSocketGateway:
if msg_type == "hello":
agent_id = data.get("agent_id")
# 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)
):
# Check if agent exists
if not agent_id or agent_id not in self._agents:
error = HelloError(
code=ConnectErrorCode.AGENT_NOT_FOUND,
message=f"Agent not found: {agent_id}",
@@ -4326,11 +4311,7 @@ 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
and self._client_may_reach_agent(client_id, agent_id)
):
if agent_id and agent_id in self._agents:
# Protocol version negotiation with validation
try:
client_min_version = int(data.get("min_version", MIN_PROTOCOL_VERSION))
@@ -4392,18 +4373,6 @@ 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,
@@ -4411,17 +4380,7 @@ 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
@@ -5413,7 +5372,6 @@ 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:
@@ -5484,7 +5442,6 @@ 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:
@@ -5537,7 +5494,6 @@ 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
@@ -6883,9 +6839,6 @@ 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")
@@ -7048,15 +7001,6 @@ 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).
@@ -7101,11 +7045,6 @@ 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:
@@ -12123,42 +12062,6 @@ 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 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-bot"
version = "0.1.21"
version = "0.1.20"
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
readme = "README.md"
license = "MIT"
@@ -1,284 +0,0 @@
"""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
+2 -2
View File
@@ -1428,7 +1428,7 @@ wheels = [
[[package]]
name = "praisonai-bot"
version = "0.1.21"
version = "0.1.20"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1510,7 +1510,7 @@ provides-extras = ["gateway", "bot", "bot-whatsapp-web", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.22"
version = "1.7.21"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1 +1 @@
__version__ = "0.1.21"
__version__ = "0.1.20"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-browser"
version = "0.1.21"
version = "0.1.20"
description = "Browser automation for PraisonAI — Chrome extension bridge, CDP, and Playwright agents extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
+2 -2
View File
@@ -932,7 +932,7 @@ wheels = [
[[package]]
name = "praisonai-browser"
version = "0.1.21"
version = "0.1.20"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -976,7 +976,7 @@ provides-extras = ["server", "playwright", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.22"
version = "1.7.21"
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.21"
__version__ = "0.1.20"
__all__ = ["__version__"]
@@ -2393,8 +2393,6 @@ 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
@@ -2444,13 +2442,6 @@ 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;
@@ -2783,7 +2774,6 @@ 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)
@@ -2795,57 +2785,6 @@ 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 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-code"
version = "0.1.21"
version = "0.1.20"
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"}
+2 -2
View File
@@ -1370,7 +1370,7 @@ wheels = [
[[package]]
name = "praisonai-code"
version = "0.1.21"
version = "0.1.20"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1403,7 +1403,7 @@ requires-dist = [
[[package]]
name = "praisonaiagents"
version = "1.7.22"
version = "1.7.21"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1 +1 @@
__version__ = "0.1.21"
__version__ = "0.1.20"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-deploy"
version = "0.1.21"
version = "0.1.20"
description = "Deployment for PraisonAI — API, Docker, and cloud (AWS, Azure, GCP) deployments extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
+2 -2
View File
@@ -977,7 +977,7 @@ wheels = [
[[package]]
name = "praisonai-deploy"
version = "0.1.21"
version = "0.1.20"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1014,7 +1014,7 @@ provides-extras = ["api", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.22"
version = "1.7.21"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.23"
__version__ = "0.1.22"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-mcp"
version = "0.1.23"
version = "0.1.22"
description = "MCP server hosting for PraisonAI — expose agents and tools to Cursor, Claude Desktop, and other MCP clients."
readme = "README.md"
license = {text = "MIT"}
+2 -2
View File
@@ -1044,7 +1044,7 @@ wheels = [
[[package]]
name = "praisonai-mcp"
version = "0.1.23"
version = "0.1.22"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1084,7 +1084,7 @@ provides-extras = ["server", "auth", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.22"
version = "1.7.21"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1 +1 @@
__version__ = "0.1.21"
__version__ = "0.1.20"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-sandbox"
version = "0.1.21"
version = "0.1.20"
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"}
+2 -2
View File
@@ -1679,7 +1679,7 @@ wheels = [
[[package]]
name = "praisonai-sandbox"
version = "0.1.21"
version = "0.1.20"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1744,7 +1744,7 @@ provides-extras = ["docker", "e2b", "sandlock", "ssh", "tenki", "flyio", "modal"
[[package]]
name = "praisonaiagents"
version = "1.7.22"
version = "1.7.21"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1 +1 @@
__version__ = "0.1.21"
__version__ = "0.1.20"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-train"
version = "0.1.21"
version = "0.1.20"
description = "Training for PraisonAI — LLM fine-tuning (Unsloth) and iterative agent training extracted from the praisonai wrapper."
readme = "README.md"
license = "MIT"
+2 -2
View File
@@ -2606,7 +2606,7 @@ wheels = [
[[package]]
name = "praisonai-train"
version = "0.1.21"
version = "0.1.20"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -2696,7 +2696,7 @@ provides-extras = ["llm", "vision", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.22"
version = "1.7.21"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -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:632`
- Python: `src/praisonai-agents/praisonaiagents/agent/agent.py:631`
- 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:3734`
- Python: `src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3714`
- 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
+2 -2
View File
@@ -1185,7 +1185,7 @@
}
],
"python": {
"location": "src/praisonai-agents/praisonaiagents/agent/agent.py:632",
"location": "src/praisonai-agents/praisonaiagents/agent/agent.py:631",
"resolved_class": "Agent"
},
"ts_only": [
@@ -1797,7 +1797,7 @@
}
],
"python": {
"location": "src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3734",
"location": "src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3714",
"resolved_class": "ChatMixin"
},
"ts_only": [
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/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
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
license "MIT"
depends_on "[email protected]"
+17 -34
View File
@@ -60,20 +60,6 @@ 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``.
@@ -108,10 +94,10 @@ def _finalize_image_kwargs(call_kwargs: Dict[str, Any], model: str) -> None:
def image_generate(
prompt: str,
model: str = DEFAULT_IMAGE_MODEL,
model: str = "dall-e-3",
n: int = 1,
size: str = "1024x1024",
quality: Optional[str] = None,
quality: str = "standard",
style: Optional[str] = None,
response_format: str = "url",
timeout: float = 600.0,
@@ -125,12 +111,11 @@ def image_generate(
Args:
prompt: Text description of the image to generate
model: Model name (e.g., "gpt-image-2.5-flare", "gpt-image-2.6-flare", "stability/...")
model: Model name (e.g., "dall-e-3", "dall-e-2", "stability/...")
n: Number of images to generate
size: Image size (e.g., "1024x1024", "1792x1024", "1024x1792")
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)
quality: Image quality ("standard" or "hd")
style: Image style ("vivid" or "natural")
response_format: "url" or "b64_json"
timeout: Request timeout in seconds
api_key: Optional API key override
@@ -155,11 +140,11 @@ def image_generate(
'timeout': timeout,
}
if quality is not None:
# Only add quality/style for dall-e-3 (not supported by dall-e-2)
if 'dall-e-3' in model.lower():
call_kwargs['quality'] = quality
# Only dall-e-3 accepts style
if style and 'dall-e-3' in model.lower():
call_kwargs['style'] = style
if style:
call_kwargs['style'] = style
if api_key:
call_kwargs['api_key'] = api_key
if api_base:
@@ -189,10 +174,10 @@ def image_generate(
async def aimage_generate(
prompt: str,
model: str = DEFAULT_IMAGE_MODEL,
model: str = "dall-e-3",
n: int = 1,
size: str = "1024x1024",
quality: Optional[str] = None,
quality: str = "standard",
style: Optional[str] = None,
response_format: str = "url",
timeout: float = 600.0,
@@ -217,11 +202,11 @@ async def aimage_generate(
'timeout': timeout,
}
if quality is not None:
# Only add quality/style for dall-e-3 (not supported by dall-e-2)
if 'dall-e-3' in model.lower():
call_kwargs['quality'] = quality
# Only dall-e-3 accepts style
if style and 'dall-e-3' in model.lower():
call_kwargs['style'] = style
if style:
call_kwargs['style'] = style
if api_key:
call_kwargs['api_key'] = api_key
if api_base:
@@ -252,7 +237,7 @@ async def aimage_generate(
def image_edit(
image: Union[str, bytes],
prompt: str,
model: str = DEFAULT_IMAGE_MODEL,
model: str = "dall-e-2",
mask: Optional[Union[str, bytes]] = None,
n: int = 1,
size: str = "1024x1024",
@@ -282,7 +267,6 @@ def image_edit(
Returns:
List of ImageResult objects
"""
_reject_markup(kwargs)
import litellm
# Handle file paths
@@ -341,7 +325,7 @@ def image_edit(
async def aimage_edit(
image: Union[str, bytes],
prompt: str,
model: str = DEFAULT_IMAGE_MODEL,
model: str = "dall-e-2",
mask: Optional[Union[str, bytes]] = None,
n: int = 1,
size: str = "1024x1024",
@@ -357,7 +341,6 @@ async def aimage_edit(
See image_edit() for full documentation.
"""
_reject_markup(kwargs)
import litellm
image_file = image
+24 -39
View File
@@ -18,39 +18,11 @@ 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: Optional[int] = None,
aspect_ratio: Optional[str] = None,
duration: int = 5,
aspect_ratio: str = "16:9",
timeout: float = 600.0,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
@@ -63,11 +35,8 @@ 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. 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.
duration: Video duration in seconds
aspect_ratio: Aspect ratio (e.g., "16:9", "9:16", "1:1")
timeout: Request timeout in seconds
api_key: Optional API key override
api_base: Optional API base URL override
@@ -82,13 +51,21 @@ def video_generate(
"""
import litellm
call_kwargs = _build_call_kwargs(model, prompt, duration, aspect_ratio, timeout, kwargs)
call_kwargs = {
'model': model,
'prompt': prompt,
'duration': duration,
'aspect_ratio': aspect_ratio,
'timeout': timeout,
}
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
@@ -114,8 +91,8 @@ def video_generate(
async def avideo_generate(
prompt: str,
model: str = "openai/sora-2",
duration: Optional[int] = None,
aspect_ratio: Optional[str] = None,
duration: int = 5,
aspect_ratio: str = "16:9",
timeout: float = 600.0,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
@@ -129,13 +106,21 @@ async def avideo_generate(
"""
import litellm
call_kwargs = _build_call_kwargs(model, prompt, duration, aspect_ratio, timeout, kwargs)
call_kwargs = {
'model': model,
'prompt': prompt,
'duration': duration,
'aspect_ratio': aspect_ratio,
'timeout': timeout,
}
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
View File
@@ -1 +1 @@
__version__ = "4.7.24"
__version__ = "4.7.23"
+8 -8
View File
@@ -12,14 +12,14 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"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",
"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",
"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,77 +178,6 @@ 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."""
@@ -1060,65 +989,3 @@ 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"