mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 17:31:52 -10:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4e8a3a43 | ||
|
|
73c9ed3532 | ||
|
|
2660a5d6ad | ||
|
|
97e4285511 | ||
|
|
fcb1f249c2 | ||
|
|
6bc240be22 | ||
|
|
a72e156c4d | ||
|
|
24385d6487 | ||
|
|
2f8162207f | ||
|
|
516d8b6d88 | ||
|
|
7eb98e895b | ||
|
|
ef79b7a056 | ||
|
|
3ea8376610 | ||
|
|
6360b24c06 | ||
|
|
b0d8f77752 | ||
|
|
cce21bc8f1 | ||
|
|
179cab02db | ||
|
|
402d7ed9fc | ||
|
|
701e8e6a6a | ||
|
|
ef09341cc0 | ||
|
|
3a4d7e11c8 | ||
|
|
92644518c0 | ||
|
|
cf40b25e65 | ||
|
|
cd54574b20 | ||
|
|
9fd72a6455 | ||
|
|
437db5c437 | ||
|
|
68035af76d | ||
|
|
491e8950a3 | ||
|
|
7a25523c7d | ||
|
|
c989f2c31e | ||
|
|
a2c51a0760 | ||
|
|
7a601b5373 | ||
|
|
12f430e29c | ||
|
|
c3c78fbc7b | ||
|
|
bf120c8dca | ||
|
|
13d073b02a | ||
|
|
adcfa29941 | ||
|
|
7efd0075da | ||
|
|
9ec90361d6 | ||
|
|
2e5540a41b | ||
|
|
7fcd6af4c6 | ||
|
|
1fb71c46b4 | ||
|
|
ca46eb1297 | ||
|
|
422212105a | ||
|
|
b2611e5d61 | ||
|
|
78f0a71e71 | ||
|
|
e01f63dba2 | ||
|
|
99638a9129 | ||
|
|
a410bc88bd | ||
|
|
7bcdfafb4d | ||
|
|
a41a8a22d6 | ||
|
|
c18450bb35 | ||
|
|
9955d65f82 | ||
|
|
80c78071fc | ||
|
|
2e05f2c0f2 |
No files matched your search
@@ -0,0 +1,43 @@
|
||||
# PraisonAI Environment Variables
|
||||
# Copy this file to .env and update with your actual values
|
||||
|
||||
# === Required ===
|
||||
OPENAI_API_KEY=sk-... # OpenAI key for LLM calls. Get yours at https://platform.openai.com
|
||||
|
||||
# === Required for Claw web-search tool ===
|
||||
TAVILY_API_KEY=tvly-... # Get yours at https://app.tavily.com (free tier available)
|
||||
|
||||
# === Optional: Claw dashboard security ===
|
||||
PRAISONAI_ALLOW_LOCAL_TOOLS=0 # Set to 1 to enable local shell tools (security risk)
|
||||
|
||||
# === Optional: Alternative LLM Providers ===
|
||||
ANTHROPIC_API_KEY=sk-ant-... # Anthropic Claude models
|
||||
GOOGLE_API_KEY=... # Google Gemini models
|
||||
GROQ_API_KEY=gsk_... # Groq (fast inference)
|
||||
COHERE_API_KEY=... # Cohere models
|
||||
AZURE_OPENAI_API_KEY=... # Azure OpenAI
|
||||
AZURE_OPENAI_ENDPOINT=... # Azure OpenAI endpoint
|
||||
|
||||
# === Optional: Memory backends ===
|
||||
MEM0_API_KEY=... # Mem0 memory service
|
||||
REDIS_URL=redis://localhost:6379 # Redis for state management
|
||||
|
||||
# === Optional: Observability ===
|
||||
LANGFUSE_SECRET_KEY=sk-lf-... # Langfuse tracing
|
||||
LANGFUSE_PUBLIC_KEY=pk-lf-... # Langfuse public key
|
||||
|
||||
# === Optional: Bot platforms ===
|
||||
TELEGRAM_BOT_TOKEN=... # Telegram bot token
|
||||
DISCORD_BOT_TOKEN=... # Discord bot token
|
||||
SLACK_BOT_TOKEN=xoxb-... # Slack bot token
|
||||
SLACK_APP_TOKEN=xapp-... # Slack app token
|
||||
WHATSAPP_ACCESS_TOKEN=... # WhatsApp bot access token
|
||||
WHATSAPP_PHONE_NUMBER_ID=... # WhatsApp phone number ID
|
||||
|
||||
# === Optional: Database ===
|
||||
DATABASE_URL=sqlite:///~/.praison/database.sqlite # Database connection string
|
||||
|
||||
# === Optional: Development ===
|
||||
LOGLEVEL=INFO # Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
CHAINLIT_AUTH_SECRET=... # Chainlit authentication secret
|
||||
DEBUG=false # Enable debug mode
|
||||
@@ -181,12 +181,15 @@ jobs:
|
||||
|
||||
- name: Fetch PR branch and Setup Remote
|
||||
if: github.event.issue.pull_request
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
|
||||
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
|
||||
run: |
|
||||
# Fetch PR head from base repo and put it into local branch
|
||||
git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }}
|
||||
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
|
||||
|
||||
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
|
||||
if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then
|
||||
if [ "${IS_FORK}" = "true" ]; then
|
||||
git remote set-url origin file://$(pwd)
|
||||
fi
|
||||
|
||||
|
||||
@@ -29,9 +29,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
@@ -63,10 +60,12 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
PRAISONAI_AUTO_APPROVE: "true"
|
||||
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
|
||||
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
export ISSUE_NUMBER="${{ inputs.issue_number }}"
|
||||
export ISSUE_NUMBER="$ISSUE_NUMBER_INPUT"
|
||||
else
|
||||
export ISSUE_NUMBER="${{ github.event.issue.number }}"
|
||||
export ISSUE_NUMBER="$ISSUE_NUMBER_EVENT"
|
||||
fi
|
||||
praisonai github triage --issue $ISSUE_NUMBER --agent-file .github/praisonai-issue-triage.yaml
|
||||
@@ -30,22 +30,23 @@ jobs:
|
||||
steps:
|
||||
- name: Determine checkout ref
|
||||
id: dest
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
echo "ref=refs/pull/${{ github.event.issue.number }}/head" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=refs/pull/$ISSUE_NUMBER/head" >> "$GITHUB_OUTPUT"
|
||||
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "ref=refs/pull/${{ inputs.pr_number }}/head" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=refs/pull/$PR_NUMBER_INPUT/head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=$PR_HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
ref: ${{ steps.dest.outputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -294,8 +294,35 @@ def search(query: str) -> str:
|
||||
|
||||
@tool
|
||||
def calculate(expression: str) -> float:
|
||||
"""Evaluate a math expression."""
|
||||
return eval(expression)
|
||||
"""Safely evaluate a numeric arithmetic expression."""
|
||||
import ast
|
||||
import operator
|
||||
|
||||
# Define allowed operations
|
||||
_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
}
|
||||
|
||||
def _safe_eval(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return node.value
|
||||
elif isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
|
||||
elif isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.operand))
|
||||
else:
|
||||
raise ValueError("Unsupported expression")
|
||||
|
||||
try:
|
||||
return _safe_eval(ast.parse(expression, mode="eval").body)
|
||||
except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError):
|
||||
raise ValueError("Invalid arithmetic expression")
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
@@ -304,6 +331,7 @@ agent = Agent(
|
||||
agent.start("Search for AI news and calculate 15*4")
|
||||
```
|
||||
|
||||
> ⚠️ **Security Note:** Never use `eval()`, `exec()`, or `subprocess` in tool functions that process LLM-generated or user-supplied input. Always validate and sanitize inputs to prevent code injection attacks.
|
||||
> 📖 [Full tools docs](https://docs.praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools
|
||||
|
||||
### 5. Persistence (Databases)
|
||||
@@ -330,6 +358,15 @@ pip install "praisonai[claw]"
|
||||
praisonai claw
|
||||
```
|
||||
|
||||
#### Required Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and configure the following variables:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls |
|
||||
| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com |
|
||||
|
||||
Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI.
|
||||
|
||||
> 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
|
||||
|
||||
@@ -787,6 +787,9 @@ Methods:
|
||||
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot install-daemon <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot linear <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot list <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot purge <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot replay <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.37" \
|
||||
"praisonai>=4.6.41" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.37" \
|
||||
"praisonai>=4.6.41" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.37" \
|
||||
"praisonai>=4.6.41" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -23,12 +23,39 @@ print(f"[2] Updated to v{managed.agent_version}")
|
||||
result = agent.start("What is 123 + 456?", stream=True)
|
||||
|
||||
# ── 3. Custom tool ──
|
||||
def _safe_calc(expr: str) -> str:
|
||||
import ast
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expr):
|
||||
return "error"
|
||||
try:
|
||||
tree = ast.parse(expr, mode="eval")
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(
|
||||
node,
|
||||
(
|
||||
ast.Expression,
|
||||
ast.BinOp,
|
||||
ast.UnaryOp,
|
||||
ast.Constant,
|
||||
ast.Add,
|
||||
ast.Sub,
|
||||
ast.Mult,
|
||||
ast.Div,
|
||||
ast.USub,
|
||||
ast.UAdd,
|
||||
),
|
||||
):
|
||||
return "error"
|
||||
val = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
|
||||
return str(val)
|
||||
except Exception:
|
||||
return "error"
|
||||
|
||||
|
||||
def handle_calculator(tool_name, tool_input):
|
||||
expr = tool_input.get("expression", "0")
|
||||
try:
|
||||
val = eval(expr, {"__builtins__": {}})
|
||||
except Exception:
|
||||
val = "error"
|
||||
val = _safe_calc(str(expr))
|
||||
print(f" [Calculator: {expr} = {val}]")
|
||||
return str(val)
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Scientific paper workflow — YAML equivalent of multi_agent_paper.py
|
||||
# Run with:
|
||||
# praisonai agents run --file examples/scientific_writing/agents.yaml
|
||||
framework: praisonai
|
||||
topic: "Climate change effects on coral reef biodiversity"
|
||||
|
||||
roles:
|
||||
literature_reviewer:
|
||||
role: "Literature Reviewer"
|
||||
goal: "Survey recent academic work on {topic} and produce an APA review."
|
||||
backstory: "Expert in academic literature surveying and citation hygiene."
|
||||
llm: "gpt-4o-mini"
|
||||
tasks:
|
||||
review_literature:
|
||||
description: "Produce a concise literature review with 5-8 APA citations."
|
||||
expected_output: "APA-formatted literature review."
|
||||
|
||||
methodology_designer:
|
||||
role: "Methodology Designer"
|
||||
goal: "Design a reproducible research methodology for {topic}."
|
||||
backstory: "Specialises in rigorous experimental design."
|
||||
llm: "gpt-4o-mini"
|
||||
tasks:
|
||||
design_methodology:
|
||||
description: "Write a reproducible methods section in LaTeX."
|
||||
expected_output: "LaTeX methods section."
|
||||
|
||||
scientific_writer:
|
||||
role: "Scientific Writer"
|
||||
goal: "Assemble the final LaTeX paper on {topic}."
|
||||
backstory: >-
|
||||
Specialised in LaTeX-formatted academic papers, fine-tuned on
|
||||
scientific literature (CAJAL-4B checkpoint).
|
||||
llm: "huggingface/Agnuxo/CAJAL-4B-P2PCLAW"
|
||||
tasks:
|
||||
write_paper:
|
||||
description: >-
|
||||
Combine the literature review and methodology into a full
|
||||
scientific paper on {topic}. Use LaTeX formatting.
|
||||
expected_output: "Complete LaTeX paper."
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Multi-agent scientific paper workflow (Researcher -> Methodologist -> Writer).
|
||||
|
||||
Demonstrates the agent-centric pattern from AGENTS.md: compose specialised
|
||||
Agents with ``AgentTeam`` + sequential ``Task``s instead of creating custom
|
||||
Agent subclasses. The writer uses the CAJAL-4B HuggingFace model; the
|
||||
reviewer and methodologist use general-purpose LLMs.
|
||||
|
||||
Usage:
|
||||
python examples/scientific_writing/multi_agent_paper.py
|
||||
"""
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
|
||||
from scientific_writer import ( # noqa: E402 — example imports a sibling file
|
||||
build_scientific_writer,
|
||||
format_citation,
|
||||
format_latex_section,
|
||||
)
|
||||
|
||||
|
||||
literature_reviewer = Agent(
|
||||
name="Literature Reviewer",
|
||||
instructions=(
|
||||
"You are an expert at surveying academic literature. "
|
||||
"Produce a concise literature review with 5-8 key citations in APA."
|
||||
),
|
||||
tools=[format_citation],
|
||||
)
|
||||
|
||||
methodology_designer = Agent(
|
||||
name="Methodology Designer",
|
||||
instructions=(
|
||||
"You design rigorous research methodologies. "
|
||||
"Output a clear, reproducible methods section."
|
||||
),
|
||||
tools=[format_latex_section],
|
||||
)
|
||||
|
||||
scientific_writer = build_scientific_writer()
|
||||
|
||||
|
||||
def run(topic: str) -> str:
|
||||
"""Run the 3-agent workflow for a given research ``topic``."""
|
||||
t1 = Task(
|
||||
name="review_literature",
|
||||
description=f"Review literature on: {topic}",
|
||||
agent=literature_reviewer,
|
||||
expected_output="APA literature review.",
|
||||
)
|
||||
t2 = Task(
|
||||
name="design_methodology",
|
||||
description=f"Design research methodology for: {topic}",
|
||||
agent=methodology_designer,
|
||||
expected_output="LaTeX methods section.",
|
||||
)
|
||||
t3 = Task(
|
||||
name="write_paper",
|
||||
description=(
|
||||
f"Combine the literature review and methodology into a full "
|
||||
f"scientific paper on: {topic}. Use LaTeX formatting."
|
||||
),
|
||||
agent=scientific_writer,
|
||||
expected_output="Complete LaTeX paper.",
|
||||
)
|
||||
team = AgentTeam(
|
||||
agents=[literature_reviewer, methodology_designer, scientific_writer],
|
||||
tasks=[t1, t2, t3],
|
||||
)
|
||||
return team.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = run("Transformer architectures for protein folding prediction")
|
||||
print(result)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Scientific Paper Writer — single-agent example using the CAJAL model.
|
||||
|
||||
CAJAL (``Agnuxo/CAJAL-4B-P2PCLAW``) is a 4B-parameter HuggingFace model
|
||||
specialised for LaTeX-formatted academic writing. This example shows how
|
||||
to use it with a regular ``Agent`` and ``@tool`` functions — no new Agent
|
||||
subclass is needed. Run 3 ways: Python (this file), CLI, or YAML.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonaiagents[llm]" # for litellm / HuggingFace
|
||||
export HUGGINGFACE_API_KEY=... # or run a local inference server
|
||||
|
||||
Usage:
|
||||
python examples/scientific_writing/scientific_writer.py
|
||||
"""
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
|
||||
@tool
|
||||
def format_latex_section(title: str, content: str) -> str:
|
||||
"""Wrap prose content in a LaTeX ``\\section{}`` block.
|
||||
|
||||
Args:
|
||||
title: Section heading (e.g. ``"Introduction"``).
|
||||
content: The body text.
|
||||
|
||||
Returns:
|
||||
LaTeX-formatted section string.
|
||||
"""
|
||||
return f"\\section{{{title}}}\n{content}\n"
|
||||
|
||||
|
||||
@tool
|
||||
def format_citation(authors: str, year: int, title: str, venue: str) -> str:
|
||||
"""Render a single APA-style citation line.
|
||||
|
||||
Args:
|
||||
authors: ``"Smith, J. & Jones, A."``
|
||||
year: Publication year.
|
||||
title: Paper title.
|
||||
venue: Journal / conference name.
|
||||
|
||||
Returns:
|
||||
One APA-formatted citation string.
|
||||
"""
|
||||
return f"{authors} ({year}). {title}. *{venue}*."
|
||||
|
||||
|
||||
SCIENTIFIC_INSTRUCTIONS = """You are a specialised scientific paper writer.
|
||||
|
||||
Produce high-quality academic content with:
|
||||
- Clear structure (Abstract, Introduction, Methodology, Results, Discussion,
|
||||
Conclusion, References)
|
||||
- Rigorous methodology and precise language
|
||||
- Proper APA citations and LaTeX formatting
|
||||
- Honest limitations and future work sections
|
||||
|
||||
Use ``format_latex_section`` to wrap each section in LaTeX, and
|
||||
``format_citation`` to build the References list.
|
||||
"""
|
||||
|
||||
|
||||
def build_scientific_writer(
|
||||
model: str = "huggingface/Agnuxo/CAJAL-4B-P2PCLAW",
|
||||
) -> Agent:
|
||||
"""Construct a scientific-writing Agent.
|
||||
|
||||
Override ``model`` for any HuggingFace / local / API model — the default
|
||||
targets the CAJAL-4B checkpoint for LaTeX-style academic output.
|
||||
"""
|
||||
return Agent(
|
||||
name="Scientific Writer",
|
||||
instructions=SCIENTIFIC_INSTRUCTIONS,
|
||||
llm=model,
|
||||
tools=[format_latex_section, format_citation],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
agent = build_scientific_writer()
|
||||
paper = agent.start(
|
||||
"Write a short scientific paper (Abstract + Introduction + Conclusion) "
|
||||
"on 'Climate change effects on coral reef biodiversity'. "
|
||||
"Use LaTeX formatting and include at least 3 APA citations."
|
||||
)
|
||||
print(paper)
|
||||
@@ -44,12 +44,33 @@ def main():
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression safely."""
|
||||
try:
|
||||
# Safe eval for basic math
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if all(c in allowed for c in expression):
|
||||
return str(eval(expression))
|
||||
import ast
|
||||
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid expression"
|
||||
try:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(
|
||||
node,
|
||||
(
|
||||
ast.Expression,
|
||||
ast.BinOp,
|
||||
ast.UnaryOp,
|
||||
ast.Constant,
|
||||
ast.Add,
|
||||
ast.Sub,
|
||||
ast.Mult,
|
||||
ast.Div,
|
||||
ast.USub,
|
||||
ast.UAdd,
|
||||
),
|
||||
):
|
||||
return "Error: Invalid expression"
|
||||
return str(
|
||||
eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from .chat_handler import ChatHandlerMixin
|
||||
from .session_manager import SessionManagerMixin
|
||||
from .async_safety import AsyncSafeState
|
||||
from .unified_execution_mixin import UnifiedExecutionMixin
|
||||
from .sandbox_mixin import SandboxMixin
|
||||
|
||||
# Module-level logger for thread safety errors and debugging
|
||||
logger = get_logger(__name__)
|
||||
@@ -255,7 +256,7 @@ if TYPE_CHECKING:
|
||||
# Import structured error from central errors module
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
_agent_counter = 0
|
||||
_agent_counter_lock = threading.Lock()
|
||||
@@ -555,6 +556,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
|
||||
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
|
||||
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
|
||||
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -1946,6 +1948,9 @@ Your Goal: {self.goal}
|
||||
# Telemetry - lazy initialized via property for performance
|
||||
self.__telemetry = None
|
||||
self.__telemetry_initialized = False
|
||||
|
||||
# Sandbox configuration - initialize SandboxMixin
|
||||
super().__init__(sandbox=sandbox)
|
||||
|
||||
@property
|
||||
def _telemetry(self):
|
||||
|
||||
@@ -164,7 +164,8 @@ Follow best practices and coding standards."""
|
||||
messages=messages,
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
code = response.choices[0].message.content
|
||||
self._log("Code generated successfully", "green")
|
||||
return code
|
||||
@@ -204,7 +205,8 @@ Follow best practices and coding standards."""
|
||||
messages=messages,
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
code = response.choices[0].message.content
|
||||
self._log("Code generated successfully", "green")
|
||||
return code
|
||||
@@ -365,6 +367,8 @@ Provide clear, actionable feedback."""
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
review = response.choices[0].message.content
|
||||
self._log("Review completed", "green")
|
||||
return review
|
||||
@@ -405,6 +409,8 @@ Include:
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
explanation = response.choices[0].message.content
|
||||
self._log("Explanation completed", "green")
|
||||
return explanation
|
||||
@@ -451,6 +457,8 @@ Only output the refactored code."""
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
refactored = response.choices[0].message.content
|
||||
self._log("Refactoring completed", "green")
|
||||
return refactored
|
||||
@@ -495,6 +503,8 @@ Ensure the fix:
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
fixed = response.choices[0].message.content
|
||||
self._log("Fix completed", "green")
|
||||
return fixed
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Sandbox execution mixin for Agent.
|
||||
|
||||
Provides safe code execution capabilities to agents via the sandbox framework.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Union
|
||||
|
||||
from ..sandbox import (
|
||||
SandboxConfig,
|
||||
SandboxManager,
|
||||
SandboxResult,
|
||||
check_code_safety,
|
||||
format_warnings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxMixin:
|
||||
"""Mixin that adds sandbox execution capabilities to Agent.
|
||||
|
||||
Provides safe code execution in isolated environments with security pre-checks.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract sandbox config from kwargs
|
||||
self.sandbox_config: Optional[SandboxConfig] = kwargs.pop('sandbox', None)
|
||||
|
||||
# Convert boolean True to default config
|
||||
if self.sandbox_config is True:
|
||||
self.sandbox_config = SandboxConfig.subprocess()
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._sandbox_manager: Optional[SandboxManager] = None
|
||||
|
||||
@property
|
||||
def has_sandbox(self) -> bool:
|
||||
"""Check if sandbox is configured for this agent."""
|
||||
return self.sandbox_config is not None
|
||||
|
||||
def get_sandbox_manager(self) -> Optional[SandboxManager]:
|
||||
"""Get or create sandbox manager."""
|
||||
if not self.has_sandbox:
|
||||
return None
|
||||
|
||||
if self._sandbox_manager is None:
|
||||
self._sandbox_manager = SandboxManager(self.sandbox_config)
|
||||
|
||||
return self._sandbox_manager
|
||||
|
||||
async def execute_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
"""Execute code safely in configured sandbox.
|
||||
|
||||
Args:
|
||||
code: Code to execute
|
||||
language: Programming language (python, bash, etc.)
|
||||
check_security: Whether to run security pre-checks
|
||||
**kwargs: Additional arguments passed to sandbox.execute()
|
||||
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no sandbox is configured
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError(
|
||||
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
|
||||
)
|
||||
|
||||
# Security pre-check if enabled
|
||||
if check_security:
|
||||
warnings = check_code_safety(code, language)
|
||||
if warnings:
|
||||
warning_text = format_warnings(warnings)
|
||||
if self.verbose:
|
||||
logger.warning(f"Security warnings for code execution:\n{warning_text}")
|
||||
|
||||
# Store warnings in result metadata
|
||||
kwargs.setdefault('metadata', {})['security_warnings'] = warnings
|
||||
|
||||
manager = self.get_sandbox_manager()
|
||||
return await manager.run_code(code, language=language, **kwargs)
|
||||
|
||||
def execute_code_sync(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
"""Synchronous wrapper for execute_code.
|
||||
|
||||
Args:
|
||||
code: Code to execute
|
||||
language: Programming language
|
||||
check_security: Whether to run security pre-checks
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
from ..approval.utils import run_coroutine_safely
|
||||
return run_coroutine_safely(self.execute_code(code, language, check_security, **kwargs))
|
||||
|
||||
async def run_shell_command(
|
||||
self,
|
||||
command: Union[str, list],
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
"""Run a shell command in the sandbox.
|
||||
|
||||
Args:
|
||||
command: Shell command to run
|
||||
check_security: Whether to run security pre-checks
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError("No sandbox configured")
|
||||
|
||||
# Convert command to string for security checking
|
||||
if isinstance(command, list):
|
||||
command_str = " ".join(command)
|
||||
else:
|
||||
command_str = command
|
||||
|
||||
# Security pre-check
|
||||
if check_security:
|
||||
warnings = check_code_safety(command_str, "bash")
|
||||
if warnings:
|
||||
warning_text = format_warnings(warnings)
|
||||
if self.verbose:
|
||||
logger.warning(f"Security warnings for command:\n{warning_text}")
|
||||
|
||||
async with self.get_sandbox_manager() as sandbox:
|
||||
return await sandbox.run_command(command, **kwargs)
|
||||
|
||||
def get_sandbox_status(self) -> Dict[str, Any]:
|
||||
"""Get sandbox status information.
|
||||
|
||||
Returns:
|
||||
Dictionary with sandbox status details
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
return {"configured": False}
|
||||
|
||||
status = {
|
||||
"configured": True,
|
||||
"config": self.sandbox_config.to_dict() if self.sandbox_config else None,
|
||||
}
|
||||
|
||||
manager = self.get_sandbox_manager()
|
||||
if manager:
|
||||
# Get available sandbox types
|
||||
available_types = manager.get_available_types()
|
||||
status["available_types"] = available_types
|
||||
status["current_type"] = self.sandbox_config.sandbox_type
|
||||
|
||||
return status
|
||||
|
||||
async def sandbox_cleanup(self):
|
||||
"""Clean up sandbox resources."""
|
||||
if self._sandbox_manager:
|
||||
# The context manager handles cleanup, but we can force it here
|
||||
try:
|
||||
await self._sandbox_manager.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during sandbox cleanup: {e}")
|
||||
finally:
|
||||
self._sandbox_manager = None
|
||||
|
||||
def _get_code_execution_tools(self):
|
||||
"""Get sandbox-based code execution tools for this agent.
|
||||
|
||||
Returns tools that use the configured sandbox for safe execution.
|
||||
|
||||
Returns:
|
||||
List of tool functions
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
return []
|
||||
|
||||
from ..tools import tool
|
||||
|
||||
@tool
|
||||
def execute_python_code(code: str) -> str:
|
||||
"""Execute Python code safely in sandbox.
|
||||
|
||||
Args:
|
||||
code: Python code to execute
|
||||
|
||||
Returns:
|
||||
Execution output
|
||||
"""
|
||||
result = self.execute_code_sync(code, language="python")
|
||||
if result.success:
|
||||
return result.stdout or result.output
|
||||
else:
|
||||
return f"Error: {result.error or result.stderr}"
|
||||
|
||||
@tool
|
||||
def execute_shell_command(command: str) -> str:
|
||||
"""Execute shell command safely in sandbox.
|
||||
|
||||
Args:
|
||||
command: Shell command to execute
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
from ..approval.utils import run_coroutine_safely
|
||||
result = run_coroutine_safely(self.run_shell_command(command))
|
||||
if result.success:
|
||||
return result.stdout or result.output
|
||||
else:
|
||||
return f"Error: {result.error or result.stderr}"
|
||||
|
||||
return [execute_python_code, execute_shell_command]
|
||||
@@ -126,7 +126,24 @@ class _AgentServerRegistry:
|
||||
|
||||
thread = threading.Thread(target=run_server, daemon=True)
|
||||
thread.start()
|
||||
ready_event.wait(timeout=5.0) # Deterministic wait instead of sleep(0.5)
|
||||
|
||||
# Check for configurable timeout via environment variable
|
||||
try:
|
||||
timeout = float(os.environ.get("PRAISONAI_SERVER_READY_TIMEOUT", "5.0"))
|
||||
except ValueError:
|
||||
logger.warning("Invalid PRAISONAI_SERVER_READY_TIMEOUT value. Using default 5.0s.")
|
||||
timeout = 5.0
|
||||
became_ready = ready_event.wait(timeout=timeout)
|
||||
|
||||
if not became_ready:
|
||||
logger.warning(
|
||||
"Agent server on port %s did not become ready within %.1fs. "
|
||||
"Proceeding, but some features may not work correctly. "
|
||||
"Check server logs for startup errors.",
|
||||
port,
|
||||
timeout,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Inspired by Agno's SessionContextStore pattern.
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
@@ -97,8 +97,8 @@ class SessionContextTracker:
|
||||
|
||||
self._state = SessionState(
|
||||
session_id=self.session_id,
|
||||
created_at=datetime.utcnow().isoformat() + "Z",
|
||||
updated_at=datetime.utcnow().isoformat() + "Z",
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
def _generate_session_id(self) -> str:
|
||||
@@ -163,7 +163,7 @@ class SessionContextTracker:
|
||||
|
||||
def _mark_updated(self) -> None:
|
||||
"""Update the updated_at timestamp."""
|
||||
self._state.updated_at = datetime.utcnow().isoformat() + "Z"
|
||||
self._state.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
self._state.turn_count += 1
|
||||
|
||||
def to_context_string(self) -> str:
|
||||
@@ -257,7 +257,7 @@ Current messages take precedence if there's any conflict with this summary.
|
||||
self._state = SessionState(
|
||||
session_id=self.session_id,
|
||||
created_at=self._state.created_at,
|
||||
updated_at=datetime.utcnow().isoformat() + "Z",
|
||||
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -12,7 +12,7 @@ import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -39,7 +39,7 @@ class GradeResult:
|
||||
input_text: str = ""
|
||||
output: str = ""
|
||||
expected_output: Optional[str] = None
|
||||
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -55,7 +55,7 @@ class GradeResult:
|
||||
input_text=data.get("input_text", ""),
|
||||
output=data.get("output", ""),
|
||||
expected_output=data.get("expected_output"),
|
||||
timestamp=data.get("timestamp", datetime.utcnow().isoformat()),
|
||||
timestamp=data.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
|
||||
class BaseLLMGrader:
|
||||
|
||||
@@ -907,6 +907,8 @@ class OpenAIClient:
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
@@ -1167,6 +1169,8 @@ class OpenAIClient:
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
@@ -1638,8 +1642,10 @@ class OpenAIClient:
|
||||
)
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
@@ -1835,10 +1841,12 @@ class OpenAIClient:
|
||||
|
||||
if not final_response:
|
||||
return None
|
||||
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
@@ -2037,6 +2045,8 @@ class OpenAIClient:
|
||||
return
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
if tool_calls and execute_tool_fn:
|
||||
|
||||
@@ -7,7 +7,7 @@ import threading
|
||||
from typing import Any, Dict, List, Optional, Union, Literal
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Decomposed memory functionality - imported as mixins for backward compatibility
|
||||
from .storage import StorageMixin
|
||||
@@ -692,7 +692,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
"_id": ident,
|
||||
"content": text,
|
||||
"metadata": metadata,
|
||||
"created_at": datetime.utcnow(),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
self.mongo_short_term.insert_one(doc)
|
||||
@@ -918,7 +918,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
"_id": ident,
|
||||
"content": text,
|
||||
"metadata": metadata,
|
||||
"created_at": datetime.utcnow(),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "long_term"
|
||||
}
|
||||
|
||||
@@ -1441,14 +1441,14 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
self.mem0_client.add(text, user_id=user_id, metadata=meta)
|
||||
elif self.use_mongodb and hasattr(self, "mongo_users"):
|
||||
try:
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
ident = str(time.time_ns())
|
||||
doc = {
|
||||
"_id": ident,
|
||||
"user_id": user_id,
|
||||
"content": text,
|
||||
"metadata": meta,
|
||||
"created_at": datetime.utcnow()
|
||||
"created_at": datetime.now(timezone.utc)
|
||||
}
|
||||
self.mongo_users.insert_one(doc)
|
||||
self._log_verbose(f"Successfully stored user memory for {user_id}")
|
||||
|
||||
@@ -15,6 +15,8 @@ from .protocols import (
|
||||
ResourceLimits,
|
||||
)
|
||||
from .config import SandboxConfig, SecurityPolicy
|
||||
from .manager import SandboxManager
|
||||
from .security import SecurityWarning, check_code_safety, format_warnings, get_security_summary
|
||||
|
||||
__all__ = [
|
||||
"SandboxProtocol",
|
||||
@@ -22,5 +24,10 @@ __all__ = [
|
||||
"SandboxStatus",
|
||||
"ResourceLimits",
|
||||
"SandboxConfig",
|
||||
"SecurityPolicy",
|
||||
"SecurityPolicy",
|
||||
"SandboxManager",
|
||||
"SecurityWarning",
|
||||
"check_code_safety",
|
||||
"format_warnings",
|
||||
"get_security_summary",
|
||||
]
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
SandboxManager for PraisonAI Agents.
|
||||
|
||||
Factory and context manager for sandbox backends.
|
||||
Core SDK component that routes to appropriate sandbox implementations
|
||||
in the praisonai wrapper package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, TYPE_CHECKING
|
||||
|
||||
from .config import SandboxConfig
|
||||
from .protocols import SandboxProtocol, SandboxResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import AsyncContextManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxManager:
|
||||
"""Factory and context manager for all sandbox backends.
|
||||
|
||||
Routes to appropriate sandbox implementations based on configuration.
|
||||
Lightweight manager in core SDK - heavy implementations in wrapper.
|
||||
|
||||
Example:
|
||||
from praisonaiagents.sandbox import SandboxManager, SandboxConfig
|
||||
|
||||
# Simple usage
|
||||
config = SandboxConfig.docker("python:3.11-slim")
|
||||
manager = SandboxManager(config)
|
||||
result = await manager.run_code("print('Hello, World!')")
|
||||
|
||||
# Context manager usage
|
||||
async with SandboxManager(config) as sandbox:
|
||||
result = await sandbox.execute("print('Hello, World!')")
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[SandboxConfig] = None):
|
||||
"""Initialize the sandbox manager.
|
||||
|
||||
Args:
|
||||
config: Sandbox configuration. Defaults to subprocess sandbox.
|
||||
"""
|
||||
self.config = config or SandboxConfig.subprocess()
|
||||
self._sandbox: Optional[SandboxProtocol] = None
|
||||
|
||||
async def __aenter__(self) -> SandboxProtocol:
|
||||
"""Async context manager entry."""
|
||||
self._sandbox = await self._create_sandbox()
|
||||
return self._sandbox
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit with cleanup."""
|
||||
if self._sandbox:
|
||||
try:
|
||||
await self._sandbox.stop()
|
||||
await self._sandbox.cleanup()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during sandbox cleanup: {e}")
|
||||
finally:
|
||||
self._sandbox = None
|
||||
|
||||
async def run_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
"""Convenience method: create sandbox, run code, cleanup.
|
||||
|
||||
Args:
|
||||
code: Code to execute
|
||||
language: Programming language
|
||||
**kwargs: Additional arguments passed to execute()
|
||||
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
async with self as sandbox:
|
||||
return await sandbox.execute(code, language=language, **kwargs)
|
||||
|
||||
async def _create_sandbox(self) -> SandboxProtocol:
|
||||
"""Create appropriate sandbox backend based on config.
|
||||
|
||||
Routes to implementations in praisonai wrapper package.
|
||||
|
||||
Returns:
|
||||
Configured sandbox instance
|
||||
|
||||
Raises:
|
||||
ValueError: For unknown sandbox types
|
||||
ImportError: If required dependencies not available
|
||||
"""
|
||||
sandbox_type = self.config.sandbox_type.lower()
|
||||
|
||||
if sandbox_type == "docker":
|
||||
return await self._create_docker_sandbox()
|
||||
elif sandbox_type in ("subprocess", "local"):
|
||||
return await self._create_subprocess_sandbox()
|
||||
elif sandbox_type == "e2b":
|
||||
return await self._create_e2b_sandbox()
|
||||
elif sandbox_type == "sandlock":
|
||||
return await self._create_sandlock_sandbox()
|
||||
elif sandbox_type == "ssh":
|
||||
return await self._create_ssh_sandbox()
|
||||
elif sandbox_type == "modal":
|
||||
return await self._create_modal_sandbox()
|
||||
elif sandbox_type == "daytona":
|
||||
return await self._create_daytona_sandbox()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown sandbox type: {sandbox_type!r}. "
|
||||
f"Supported: 'docker', 'subprocess', 'e2b', 'sandlock', 'ssh', 'modal', 'daytona'"
|
||||
)
|
||||
|
||||
async def _create_docker_sandbox(self) -> SandboxProtocol:
|
||||
"""Create Docker sandbox."""
|
||||
try:
|
||||
# Lazy import from wrapper package
|
||||
from praisonai.sandbox import DockerSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Docker sandbox not available. Install with: "
|
||||
"pip install praisonaiagents[sandbox-docker]"
|
||||
) from e
|
||||
|
||||
sandbox = DockerSandbox(
|
||||
image=self.config.image,
|
||||
config=self.config,
|
||||
)
|
||||
|
||||
if not sandbox.is_available:
|
||||
raise RuntimeError(
|
||||
"Docker is not available. Please install Docker and ensure it's running."
|
||||
)
|
||||
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_subprocess_sandbox(self) -> SandboxProtocol:
|
||||
"""Create subprocess sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import SubprocessSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Subprocess sandbox not available. This should not happen - "
|
||||
"please check your installation."
|
||||
) from e
|
||||
|
||||
sandbox = SubprocessSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_e2b_sandbox(self) -> SandboxProtocol:
|
||||
"""Create E2B sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import E2BSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"E2B sandbox not available. Install with: "
|
||||
"pip install praisonaiagents[sandbox] e2b-code-interpreter"
|
||||
) from e
|
||||
|
||||
sandbox = E2BSandbox(config=self.config)
|
||||
|
||||
if not sandbox.is_available:
|
||||
raise RuntimeError(
|
||||
"E2B is not available. Please set E2B_API_KEY environment variable."
|
||||
)
|
||||
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_sandlock_sandbox(self) -> SandboxProtocol:
|
||||
"""Create Sandlock sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import SandlockSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Sandlock sandbox not available. Install sandlock."
|
||||
) from e
|
||||
|
||||
sandbox = SandlockSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_ssh_sandbox(self) -> SandboxProtocol:
|
||||
"""Create SSH sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import SSHSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"SSH sandbox not available. Install with: "
|
||||
"pip install paramiko"
|
||||
) from e
|
||||
|
||||
sandbox = SSHSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_modal_sandbox(self) -> SandboxProtocol:
|
||||
"""Create Modal sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import ModalSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Modal sandbox not available. Install with: "
|
||||
"pip install modal"
|
||||
) from e
|
||||
|
||||
sandbox = ModalSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_daytona_sandbox(self) -> SandboxProtocol:
|
||||
"""Create Daytona sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import DaytonaSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Daytona sandbox not available. Install daytona client."
|
||||
) from e
|
||||
|
||||
sandbox = DaytonaSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
def get_available_types(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get available sandbox types and their status.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping sandbox types to availability info
|
||||
"""
|
||||
types = {}
|
||||
|
||||
# Check Docker
|
||||
try:
|
||||
from praisonai.sandbox import DockerSandbox
|
||||
docker = DockerSandbox()
|
||||
types["docker"] = {
|
||||
"available": docker.is_available,
|
||||
"description": "Isolated Docker containers",
|
||||
"requires": ["docker"],
|
||||
}
|
||||
except ImportError:
|
||||
types["docker"] = {
|
||||
"available": False,
|
||||
"description": "Isolated Docker containers",
|
||||
"requires": ["docker", "praisonaiagents[sandbox-docker]"],
|
||||
}
|
||||
|
||||
# Check subprocess (always available)
|
||||
types["subprocess"] = {
|
||||
"available": True,
|
||||
"description": "Local subprocess (limited isolation)",
|
||||
"requires": [],
|
||||
}
|
||||
|
||||
# Check E2B
|
||||
try:
|
||||
from praisonai.sandbox import E2BSandbox
|
||||
e2b = E2BSandbox()
|
||||
types["e2b"] = {
|
||||
"available": e2b.is_available,
|
||||
"description": "E2B cloud sandboxes",
|
||||
"requires": ["e2b-code-interpreter", "E2B_API_KEY"],
|
||||
}
|
||||
except ImportError:
|
||||
types["e2b"] = {
|
||||
"available": False,
|
||||
"description": "E2B cloud sandboxes",
|
||||
"requires": ["e2b-code-interpreter", "E2B_API_KEY"],
|
||||
}
|
||||
|
||||
# Add other types with try/except blocks
|
||||
for sandbox_type, module_name, description, requirements in [
|
||||
("sandlock", "SandlockSandbox", "OS-native sandboxing", ["sandlock"]),
|
||||
("ssh", "SSHSandbox", "Remote SSH execution", ["paramiko"]),
|
||||
("modal", "ModalSandbox", "Modal cloud compute", ["modal"]),
|
||||
("daytona", "DaytonaSandbox", "Daytona workspaces", ["daytona"]),
|
||||
]:
|
||||
try:
|
||||
from praisonai import sandbox
|
||||
sandbox_class = getattr(sandbox, module_name)
|
||||
# Check if class exists without instantiating (some need required args)
|
||||
types[sandbox_type] = {
|
||||
"available": False, # Can't easily check without required args
|
||||
"description": description,
|
||||
"requires": requirements,
|
||||
}
|
||||
except (ImportError, AttributeError):
|
||||
types[sandbox_type] = {
|
||||
"available": False,
|
||||
"description": description,
|
||||
"requires": requirements,
|
||||
}
|
||||
|
||||
return types
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Security pre-checks for sandbox code execution.
|
||||
|
||||
Provides pre-execution analysis of code for potentially dangerous patterns.
|
||||
Note: These are warnings only - the sandbox provides the real isolation.
|
||||
"""
|
||||
|
||||
import re
|
||||
import ast
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityWarning:
|
||||
"""A security warning from code analysis."""
|
||||
|
||||
pattern: str
|
||||
message: str
|
||||
severity: str # "low", "medium", "high", "critical"
|
||||
line_number: Optional[int] = None
|
||||
context: Optional[str] = None
|
||||
|
||||
|
||||
# Dangerous patterns to detect in code
|
||||
DANGEROUS_PATTERNS = [
|
||||
# System/OS access
|
||||
(r"import\s+os\s*;?\s*os\.system", "Direct system command execution via os.system", "high"),
|
||||
(r"subprocess\.call.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
|
||||
(r"subprocess\.run.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
|
||||
(r"subprocess\.Popen.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
|
||||
(r"__import__\(['\"]os['\"]\)", "Dynamic import of os module", "medium"),
|
||||
(r"eval\s*\(", "Dynamic code execution with eval()", "critical"),
|
||||
(r"exec\s*\(", "Dynamic code execution with exec()", "critical"),
|
||||
(r"compile\s*\(.*exec", "Code compilation for execution", "high"),
|
||||
|
||||
# File system access
|
||||
(r"open\s*\(.*['\"]\/etc", "Access to system configuration files", "medium"),
|
||||
(r"open\s*\(.*['\"]\/root", "Access to root directory", "high"),
|
||||
(r"open\s*\(.*['\"]\/home", "Access to user directories", "medium"),
|
||||
(r"shutil\.rmtree", "Recursive directory deletion", "high"),
|
||||
(r"os\.remove\s*\(", "File deletion", "medium"),
|
||||
(r"os\.unlink\s*\(", "File deletion", "medium"),
|
||||
(r"pathlib\.Path.*unlink", "File deletion via pathlib", "medium"),
|
||||
|
||||
# Network access
|
||||
(r"socket\.socket", "Direct socket creation", "medium"),
|
||||
(r"urllib\.request", "HTTP requests via urllib", "low"),
|
||||
(r"requests\.", "HTTP requests via requests library", "low"),
|
||||
(r"http\.client", "HTTP client usage", "low"),
|
||||
|
||||
# Process manipulation
|
||||
(r"os\.fork\s*\(", "Process forking", "high"),
|
||||
(r"multiprocessing\.", "Multiprocessing usage", "medium"),
|
||||
(r"threading\.Thread", "Thread creation", "low"),
|
||||
|
||||
# Potentially dangerous builtins
|
||||
(r"getattr\s*\(.*__", "Dynamic attribute access to dunder methods", "medium"),
|
||||
(r"setattr\s*\(.*__", "Dynamic attribute setting of dunder methods", "medium"),
|
||||
(r"hasattr\s*\(.*__", "Dynamic attribute checking of dunder methods", "low"),
|
||||
(r"globals\s*\(\)", "Access to global namespace", "medium"),
|
||||
(r"locals\s*\(\)", "Access to local namespace", "low"),
|
||||
(r"vars\s*\(", "Variable inspection", "low"),
|
||||
(r"dir\s*\(", "Object inspection", "low"),
|
||||
|
||||
# Code modification
|
||||
(r"sys\.modules", "Module manipulation", "high"),
|
||||
(r"importlib\.", "Dynamic import manipulation", "medium"),
|
||||
(r"__builtins__", "Access to builtin functions", "high"),
|
||||
|
||||
# Memory/performance
|
||||
(r"while\s+True:", "Infinite loop (potential DoS)", "medium"),
|
||||
(r"for.*in.*range\s*\(\s*[0-9]{6,}", "Large range iteration", "low"),
|
||||
(r"list\s*\(\s*range\s*\(\s*[0-9]{6,}", "Large list creation", "low"),
|
||||
(r"\*\s*[0-9]{6,}", "Large data multiplication", "low"),
|
||||
]
|
||||
|
||||
|
||||
def check_code_safety(code: str, language: str = "python") -> List[SecurityWarning]:
|
||||
"""Analyze code for potentially dangerous patterns.
|
||||
|
||||
Args:
|
||||
code: Code to analyze
|
||||
language: Programming language (currently only 'python' supported)
|
||||
|
||||
Returns:
|
||||
List of security warnings found
|
||||
|
||||
Note:
|
||||
These are warnings only. The sandbox provides real isolation.
|
||||
This is a best-effort static analysis for user awareness.
|
||||
"""
|
||||
warnings = []
|
||||
|
||||
if language == "python":
|
||||
warnings.extend(_check_python_patterns(code))
|
||||
warnings.extend(_check_python_ast(code))
|
||||
elif language == "bash":
|
||||
warnings.extend(_check_bash_patterns(code))
|
||||
else:
|
||||
# Fall back to basic pattern matching
|
||||
warnings.extend(_check_generic_patterns(code))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_python_patterns(code: str) -> List[SecurityWarning]:
|
||||
"""Check Python code using regex patterns."""
|
||||
warnings = []
|
||||
lines = code.split('\n')
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
for pattern, message, severity in DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, line, re.IGNORECASE):
|
||||
warnings.append(SecurityWarning(
|
||||
pattern=pattern,
|
||||
message=message,
|
||||
severity=severity,
|
||||
line_number=i,
|
||||
context=line.strip(),
|
||||
))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_python_ast(code: str) -> List[SecurityWarning]:
|
||||
"""Check Python code using AST analysis."""
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
visitor = DangerousASTVisitor()
|
||||
visitor.visit(tree)
|
||||
warnings.extend(visitor.warnings)
|
||||
except SyntaxError:
|
||||
# Invalid Python syntax - let the sandbox handle it
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"AST analysis failed: {e}")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_bash_patterns(code: str) -> List[SecurityWarning]:
|
||||
"""Check bash/shell code for dangerous patterns."""
|
||||
warnings = []
|
||||
lines = code.split('\n')
|
||||
|
||||
bash_patterns = [
|
||||
(r"rm\s+-rf\s+\/", "Recursive deletion of root directory", "critical"),
|
||||
(r"rm\s+-rf\s+\*", "Recursive deletion with wildcard", "high"),
|
||||
(r">\s*\/dev\/sd[a-z]", "Direct disk device access", "critical"),
|
||||
(r"dd\s+if=.*of=", "Low-level disk operations", "high"),
|
||||
(r"mkfs\.", "Filesystem creation", "critical"),
|
||||
(r"fdisk", "Disk partitioning", "critical"),
|
||||
(r"shutdown", "System shutdown", "high"),
|
||||
(r"reboot", "System reboot", "high"),
|
||||
(r"halt", "System halt", "high"),
|
||||
(r"init\s+[0-6]", "System runlevel change", "high"),
|
||||
(r"chmod\s+777", "Overly permissive file permissions", "medium"),
|
||||
(r"curl.*\|\s*bash", "Piped execution from network", "high"),
|
||||
(r"wget.*\|\s*bash", "Piped execution from network", "high"),
|
||||
(r"\$\(.*\)", "Command substitution", "medium"),
|
||||
(r"`.*`", "Command substitution (backticks)", "medium"),
|
||||
]
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
for pattern, message, severity in bash_patterns:
|
||||
if re.search(pattern, line, re.IGNORECASE):
|
||||
warnings.append(SecurityWarning(
|
||||
pattern=pattern,
|
||||
message=message,
|
||||
severity=severity,
|
||||
line_number=i,
|
||||
context=line.strip(),
|
||||
))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_generic_patterns(code: str) -> List[SecurityWarning]:
|
||||
"""Check code using generic patterns."""
|
||||
warnings = []
|
||||
lines = code.split('\n')
|
||||
|
||||
generic_patterns = [
|
||||
(r"eval\s*\(", "Dynamic code execution", "critical"),
|
||||
(r"exec\s*\(", "Dynamic code execution", "critical"),
|
||||
(r"system\s*\(", "System command execution", "high"),
|
||||
(r"shell\s*\(", "Shell command execution", "high"),
|
||||
]
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
for pattern, message, severity in generic_patterns:
|
||||
if re.search(pattern, line, re.IGNORECASE):
|
||||
warnings.append(SecurityWarning(
|
||||
pattern=pattern,
|
||||
message=message,
|
||||
severity=severity,
|
||||
line_number=i,
|
||||
context=line.strip(),
|
||||
))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
class DangerousASTVisitor(ast.NodeVisitor):
|
||||
"""AST visitor to detect dangerous Python constructs."""
|
||||
|
||||
def __init__(self):
|
||||
self.warnings = []
|
||||
|
||||
def visit_Call(self, node):
|
||||
"""Visit function calls."""
|
||||
# Check for dangerous function calls
|
||||
if isinstance(node.func, ast.Name):
|
||||
func_name = node.func.id
|
||||
if func_name in ("eval", "exec", "compile"):
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern=f"{func_name}()",
|
||||
message=f"Dynamic code execution with {func_name}()",
|
||||
severity="critical",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
|
||||
# Check for attribute calls like os.system
|
||||
elif isinstance(node.func, ast.Attribute):
|
||||
if isinstance(node.func.value, ast.Name):
|
||||
module = node.func.value.id
|
||||
func = node.func.attr
|
||||
|
||||
if module == "os" and func == "system":
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern="os.system()",
|
||||
message="Direct system command execution",
|
||||
severity="high",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
elif module == "subprocess" and func in ("call", "run", "Popen"):
|
||||
# Check if shell=True is used
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg == "shell" and isinstance(keyword.value, ast.Constant):
|
||||
if keyword.value.value is True:
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern=f"subprocess.{func}(..., shell=True)",
|
||||
message="Shell injection risk with subprocess",
|
||||
severity="high",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Import(self, node):
|
||||
"""Visit import statements."""
|
||||
for alias in node.names:
|
||||
if alias.name in ("os", "subprocess", "sys"):
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern=f"import {alias.name}",
|
||||
message=f"Import of potentially dangerous module: {alias.name}",
|
||||
severity="low",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node):
|
||||
"""Visit from ... import statements."""
|
||||
if node.module in ("os", "subprocess", "sys"):
|
||||
for alias in node.names:
|
||||
if alias.name in ("system", "call", "run", "Popen", "modules"):
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern=f"from {node.module} import {alias.name}",
|
||||
message=f"Import of dangerous function: {alias.name}",
|
||||
severity="medium",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def format_warnings(warnings: List[SecurityWarning]) -> str:
|
||||
"""Format security warnings for display.
|
||||
|
||||
Args:
|
||||
warnings: List of security warnings
|
||||
|
||||
Returns:
|
||||
Formatted string of warnings
|
||||
"""
|
||||
if not warnings:
|
||||
return "No security issues detected."
|
||||
|
||||
# Group by severity
|
||||
by_severity = {"critical": [], "high": [], "medium": [], "low": []}
|
||||
for warning in warnings:
|
||||
by_severity[warning.severity].append(warning)
|
||||
|
||||
lines = []
|
||||
lines.append(f"Security analysis found {len(warnings)} potential issue(s):")
|
||||
lines.append("")
|
||||
|
||||
for severity in ["critical", "high", "medium", "low"]:
|
||||
if by_severity[severity]:
|
||||
lines.append(f"{severity.upper()} RISK:")
|
||||
for warning in by_severity[severity]:
|
||||
line_info = f" (line {warning.line_number})" if warning.line_number else ""
|
||||
context_info = f"\n Context: {warning.context}" if warning.context else ""
|
||||
lines.append(f" - {warning.message}{line_info}{context_info}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Note: These are warnings only. The sandbox provides real isolation.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_security_summary(warnings: List[SecurityWarning]) -> Dict[str, Any]:
|
||||
"""Get security analysis summary.
|
||||
|
||||
Args:
|
||||
warnings: List of security warnings
|
||||
|
||||
Returns:
|
||||
Dictionary with summary statistics
|
||||
"""
|
||||
by_severity = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
for warning in warnings:
|
||||
by_severity[warning.severity] += 1
|
||||
|
||||
max_severity = "low"
|
||||
if by_severity["critical"] > 0:
|
||||
max_severity = "critical"
|
||||
elif by_severity["high"] > 0:
|
||||
max_severity = "high"
|
||||
elif by_severity["medium"] > 0:
|
||||
max_severity = "medium"
|
||||
|
||||
return {
|
||||
"total_warnings": len(warnings),
|
||||
"by_severity": by_severity,
|
||||
"max_severity": max_severity,
|
||||
"is_safe": len(warnings) == 0,
|
||||
"has_critical": by_severity["critical"] > 0,
|
||||
}
|
||||
@@ -6,7 +6,7 @@ from typing import List, Optional, Dict
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import SkillMetadata
|
||||
from .discovery import discover_skills
|
||||
from .discovery import discover_skills, get_default_skill_dirs
|
||||
from .loader import SkillLoader, LoadedSkill
|
||||
from .prompt import generate_skills_xml
|
||||
from .substitution import render_skill_body
|
||||
@@ -271,8 +271,7 @@ class SkillManager:
|
||||
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
|
||||
|
||||
# Create skill directory
|
||||
from .discovery import get_default_skill_directories
|
||||
skill_dirs = get_default_skill_directories()
|
||||
skill_dirs = get_default_skill_dirs()
|
||||
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
|
||||
|
||||
import os
|
||||
|
||||
@@ -75,12 +75,12 @@ TOOL_MAPPINGS = {
|
||||
|
||||
|
||||
# Python Tools
|
||||
'execute_code': ('.python_tools', 'PythonTools'),
|
||||
'analyze_code': ('.python_tools', 'PythonTools'),
|
||||
'format_code': ('.python_tools', 'PythonTools'),
|
||||
'lint_code': ('.python_tools', 'PythonTools'),
|
||||
'disassemble_code': ('.python_tools', 'PythonTools'),
|
||||
'python_tools': ('.python_tools', 'PythonTools'),
|
||||
'execute_code': ('.python_tools', None),
|
||||
'analyze_code': ('.python_tools', None),
|
||||
'format_code': ('.python_tools', None),
|
||||
'lint_code': ('.python_tools', None),
|
||||
'disassemble_code': ('.python_tools', None),
|
||||
'python_tools': ('.python_tools', None),
|
||||
|
||||
|
||||
# Chain of Thought Training Tools
|
||||
@@ -326,7 +326,7 @@ def __getattr__(name: str) -> Any:
|
||||
if name in [
|
||||
'duckduckgo', 'internet_search', 'searxng_search', 'searxng',
|
||||
'scrape_page', 'extract_links', 'crawl', 'extract_text',
|
||||
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
|
||||
'tavily_search_async', 'tavily_extract_async',
|
||||
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
|
||||
|
||||
@@ -268,6 +268,11 @@ class MentionsParser:
|
||||
def _process_url_mention(self, url: str) -> Optional[str]:
|
||||
"""Process @url:https://... mention."""
|
||||
try:
|
||||
from praisonaiagents.tools.spider_tools import SpiderTools
|
||||
|
||||
if not SpiderTools()._validate_url(url):
|
||||
return f"# URL: {url}\n[Blocked: URL is not allowed]"
|
||||
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
|
||||
@@ -36,6 +36,30 @@ def _safe_getattr(obj, name, *default):
|
||||
return getattr(obj, name, *default) if default else getattr(obj, name)
|
||||
|
||||
|
||||
_SANDBOX_BLOCKED_ATTRS = frozenset({
|
||||
'__subclasses__', '__bases__', '__mro__', '__globals__',
|
||||
'__code__', '__class__', '__dict__', '__builtins__',
|
||||
'__import__', '__loader__', '__spec__', '__init_subclass__',
|
||||
'__set_name__', '__reduce__', '__reduce_ex__',
|
||||
'__traceback__', '__qualname__', '__module__',
|
||||
'__wrapped__', '__closure__', '__annotations__',
|
||||
'__self__', # C builtins leak real builtins module (GHSA-4mr5-g6f9-cfrh)
|
||||
# Frame/code object introspection
|
||||
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
|
||||
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
|
||||
'f_globals', 'f_locals', 'f_builtins', 'f_code',
|
||||
'co_consts', 'co_names',
|
||||
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
|
||||
'__dir__', '__get__', '__set__', '__delete__',
|
||||
})
|
||||
|
||||
_SANDBOX_BLOCKED_CALLS = frozenset({
|
||||
'exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir', 'vars',
|
||||
})
|
||||
|
||||
|
||||
def _validate_code_ast(code: str):
|
||||
"""Validate code using AST — catches attacks that bypass text checks.
|
||||
|
||||
@@ -48,23 +72,6 @@ def _validate_code_ast(code: str):
|
||||
except SyntaxError:
|
||||
return None # let compile() handle syntax errors later
|
||||
|
||||
# Dangerous dunder attributes attackers use for sandbox escape
|
||||
_blocked_attrs = frozenset({
|
||||
'__subclasses__', '__bases__', '__mro__', '__globals__',
|
||||
'__code__', '__class__', '__dict__', '__builtins__',
|
||||
'__import__', '__loader__', '__spec__', '__init_subclass__',
|
||||
'__set_name__', '__reduce__', '__reduce_ex__',
|
||||
'__traceback__', '__qualname__', '__module__',
|
||||
'__wrapped__', '__closure__', '__annotations__',
|
||||
# Frame/code object introspection
|
||||
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
|
||||
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
|
||||
'f_globals', 'f_locals', 'f_builtins', 'f_code',
|
||||
'co_consts', 'co_names',
|
||||
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
|
||||
'__dir__', '__get__', '__set__', '__delete__',
|
||||
})
|
||||
|
||||
for node in ast.walk(tree):
|
||||
# Block import statements
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
@@ -72,28 +79,26 @@ def _validate_code_ast(code: str):
|
||||
|
||||
# Block attribute access to dangerous dunders
|
||||
if isinstance(node, ast.Attribute):
|
||||
if node.attr in _blocked_attrs:
|
||||
if node.attr in _SANDBOX_BLOCKED_ATTRS:
|
||||
return (
|
||||
f"Access to attribute '{node.attr}' is restricted"
|
||||
)
|
||||
|
||||
# Block calls to dangerous builtins by name
|
||||
# Block calls to dangerous builtins (bare name or attribute access)
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in (
|
||||
'exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir',
|
||||
):
|
||||
if isinstance(func, ast.Name) and func.id in _SANDBOX_BLOCKED_CALLS:
|
||||
return f"Call to '{func.id}' is not allowed"
|
||||
if isinstance(func, ast.Attribute) and func.attr in _SANDBOX_BLOCKED_CALLS:
|
||||
return f"Call to '{func.attr}' is not allowed"
|
||||
|
||||
# Block dangerous constants (strings containing dunders)
|
||||
# Fallback for Python 3.7 ast.Str
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
if any(attr in node.value for attr in _blocked_attrs):
|
||||
if any(attr in node.value for attr in _SANDBOX_BLOCKED_ATTRS):
|
||||
return f"String constant contains restricted attribute name"
|
||||
elif type(node).__name__ == 'Str':
|
||||
if any(attr in getattr(node, 's', '') for attr in _blocked_attrs):
|
||||
if any(attr in getattr(node, 's', '') for attr in _SANDBOX_BLOCKED_ATTRS):
|
||||
return f"String constant contains restricted attribute name"
|
||||
|
||||
return None
|
||||
@@ -112,7 +117,16 @@ def _execute_code_sandboxed(
|
||||
"""
|
||||
if limits is None:
|
||||
limits = ResourceLimits.minimal()
|
||||
|
||||
|
||||
ast_error = _validate_code_ast(code)
|
||||
if ast_error:
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': '',
|
||||
'stderr': f'Security Error: {ast_error}',
|
||||
'success': False,
|
||||
}
|
||||
|
||||
try:
|
||||
# Create temporary file for the code
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
||||
@@ -151,21 +165,8 @@ def safe_execute():
|
||||
}}
|
||||
|
||||
# Block dangerous patterns
|
||||
blocked_attrs = {{
|
||||
'__subclasses__', '__bases__', '__mro__', '__globals__',
|
||||
'__code__', '__class__', '__dict__', '__builtins__',
|
||||
'__import__', '__loader__', '__spec__', '__init_subclass__',
|
||||
'__set_name__', '__reduce__', '__reduce_ex__',
|
||||
'__traceback__', '__qualname__', '__module__',
|
||||
'__wrapped__', '__closure__', '__annotations__',
|
||||
# Frame/code object introspection
|
||||
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
|
||||
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
|
||||
'f_globals', 'f_locals', 'f_builtins', 'f_code',
|
||||
'co_consts', 'co_names',
|
||||
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
|
||||
'__dir__', '__get__', '__set__', '__delete__',
|
||||
}}
|
||||
blocked_attrs = {set(_SANDBOX_BLOCKED_ATTRS)!r}
|
||||
blocked_calls = {set(_SANDBOX_BLOCKED_CALLS)!r}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
@@ -182,14 +183,20 @@ def safe_execute():
|
||||
"stderr": f"Access to attribute '{{node.attr}}' is restricted",
|
||||
"success": False
|
||||
}}
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
if node.func.id in ('exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir'):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in blocked_calls:
|
||||
return {{
|
||||
"result": None,
|
||||
"stdout": "",
|
||||
"stderr": f"Call to '{{node.func.id}}' is not allowed",
|
||||
"stderr": f"Call to '{{func.id}}' is not allowed",
|
||||
"success": False
|
||||
}}
|
||||
if isinstance(func, ast.Attribute) and func.attr in blocked_calls:
|
||||
return {{
|
||||
"result": None,
|
||||
"stdout": "",
|
||||
"stderr": f"Call to '{{func.attr}}' is not allowed",
|
||||
"success": False
|
||||
}}
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
@@ -481,7 +488,7 @@ def _execute_code_direct(
|
||||
'__import__', 'import ', 'from ', 'exec', 'eval',
|
||||
'compile', 'open(', 'file(', 'input(', 'raw_input',
|
||||
'__subclasses__', '__bases__', '__globals__', '__code__',
|
||||
'__class__', 'globals(', 'locals(', 'vars('
|
||||
'__class__', '__self__', 'globals(', 'locals(', 'vars('
|
||||
]
|
||||
|
||||
code_lower = code.lower()
|
||||
|
||||
@@ -11,6 +11,8 @@ content = scrape_page("https://example.com")
|
||||
"""
|
||||
|
||||
import logging
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import List, Dict, Union, Optional, Any
|
||||
from importlib import util
|
||||
import json
|
||||
@@ -20,6 +22,48 @@ import os
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
|
||||
def _host_is_blocked(hostname: str) -> bool:
|
||||
"""Return True when hostname resolves to loopback/private/internal targets."""
|
||||
if not hostname:
|
||||
return True
|
||||
host = hostname.lower().rstrip(".")
|
||||
if host in ("localhost", "0.0.0.0", "::1") or host.endswith(".localhost"):
|
||||
return True
|
||||
if host in ("169.254.169.254", "metadata.google.internal"):
|
||||
return True
|
||||
if any(host.endswith(suffix) for suffix in (".local", ".internal", ".localdomain")):
|
||||
return True
|
||||
|
||||
def _ip_blocked(ip: ipaddress._BaseAddress) -> bool:
|
||||
return bool(
|
||||
ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local
|
||||
)
|
||||
|
||||
if host.isdigit():
|
||||
try:
|
||||
return _ip_blocked(ipaddress.ip_address(int(host)))
|
||||
except (ValueError, OverflowError):
|
||||
return True
|
||||
|
||||
if host.startswith("0x"):
|
||||
try:
|
||||
return _ip_blocked(ipaddress.ip_address(int(host, 16)))
|
||||
except (ValueError, OverflowError):
|
||||
return True
|
||||
|
||||
try:
|
||||
return _ip_blocked(ipaddress.ip_address(host))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
class SpiderTools:
|
||||
"""Tools for web scraping and crawling."""
|
||||
|
||||
@@ -59,31 +103,9 @@ class SpiderTools:
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
|
||||
# Reject local/internal addresses
|
||||
hostname = parsed.hostname.lower()
|
||||
|
||||
# Block localhost and loopback
|
||||
if hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']:
|
||||
if _host_is_blocked(parsed.hostname):
|
||||
return False
|
||||
|
||||
# Block private IP ranges
|
||||
import ipaddress
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:
|
||||
return False
|
||||
except ValueError:
|
||||
# Not an IP address, continue with domain validation
|
||||
pass
|
||||
|
||||
# Block common internal domains
|
||||
if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']):
|
||||
return False
|
||||
|
||||
# Block metadata service endpoints
|
||||
if hostname in ['169.254.169.254', 'metadata.google.internal']:
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.37"
|
||||
version = "1.6.41"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -101,6 +101,16 @@ crawl = [
|
||||
"playwright>=1.47.0",
|
||||
]
|
||||
|
||||
# Sandbox dependencies
|
||||
sandbox = [
|
||||
"e2b-code-interpreter>=1.0.0",
|
||||
]
|
||||
|
||||
# Docker sandbox dependencies
|
||||
sandbox-docker = [
|
||||
"docker>=7.0.0",
|
||||
]
|
||||
|
||||
# Combined features
|
||||
all = [
|
||||
"praisonaiagents[memory]",
|
||||
@@ -112,6 +122,7 @@ all = [
|
||||
"praisonaiagents[telemetry]",
|
||||
"praisonaiagents[mongodb]",
|
||||
"praisonaiagents[auth]",
|
||||
"praisonaiagents[sandbox]",
|
||||
"praisonaiagents[search]",
|
||||
"praisonaiagents[crawl]",
|
||||
"praisonaiagents[autonomy]",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Test for agent server readiness timeout warning functionality."""
|
||||
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from praisonaiagents.agents.agents import _AgentServerRegistry
|
||||
|
||||
|
||||
def test_server_readiness_timeout_emits_warning(caplog):
|
||||
"""A server that does not start in time must produce a WARNING log entry."""
|
||||
registry = _AgentServerRegistry()
|
||||
|
||||
# Create a mock FastAPI app that won't signal readiness
|
||||
mock_app = MagicMock()
|
||||
|
||||
# Register the app but don't set the ready event
|
||||
with patch.dict(registry._apps, {8000: mock_app}):
|
||||
with patch.dict(registry._ready_events, {8000: threading.Event()}):
|
||||
with patch.dict(registry._started, {8000: False}):
|
||||
with patch("threading.Thread.start"): # Prevent real server spawning
|
||||
with caplog.at_level(logging.WARNING):
|
||||
# Use a very short timeout to ensure the test doesn't hang
|
||||
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "0.1"}):
|
||||
result = registry.start_server_if_needed(8000, host="127.0.0.1")
|
||||
|
||||
# The method should still return True but log a warning
|
||||
assert result is True
|
||||
assert "8000" in caplog.text
|
||||
assert "did not become ready" in caplog.text.lower()
|
||||
|
||||
|
||||
def test_server_readiness_timeout_configurable_via_env():
|
||||
"""The timeout should be configurable via PRAISONAI_SERVER_READY_TIMEOUT."""
|
||||
registry = _AgentServerRegistry()
|
||||
|
||||
# Create a mock FastAPI app
|
||||
mock_app = MagicMock()
|
||||
|
||||
# Register the app but don't set the ready event
|
||||
with patch.dict(registry._apps, {8001: mock_app}):
|
||||
with patch.dict(registry._ready_events, {8001: threading.Event()}):
|
||||
with patch.dict(registry._started, {8001: False}):
|
||||
with patch("threading.Thread.start"): # Prevent real server spawning
|
||||
# Test with custom timeout
|
||||
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "0.05"}):
|
||||
start_time = time.time()
|
||||
result = registry.start_server_if_needed(8001, host="127.0.0.1")
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Should respect the custom timeout and complete quickly
|
||||
assert result is True
|
||||
assert duration < 1.0 # Should be much less than default 5s
|
||||
|
||||
|
||||
def test_server_readiness_success_no_warning(caplog):
|
||||
"""A server that starts in time should not produce any warning."""
|
||||
registry = _AgentServerRegistry()
|
||||
|
||||
# Create a mock FastAPI app
|
||||
mock_app = MagicMock()
|
||||
ready_event = threading.Event()
|
||||
|
||||
# Register the app and signal readiness immediately
|
||||
with patch.dict(registry._apps, {8002: mock_app}):
|
||||
with patch.dict(registry._ready_events, {8002: ready_event}):
|
||||
with patch.dict(registry._started, {8002: False}):
|
||||
with patch("threading.Thread.start"): # Prevent real server spawning
|
||||
# Set the event immediately to simulate quick startup
|
||||
ready_event.set()
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = registry.start_server_if_needed(8002, host="127.0.0.1")
|
||||
|
||||
# Should succeed without warnings
|
||||
assert result is True
|
||||
assert "did not become ready" not in caplog.text.lower()
|
||||
|
||||
|
||||
def test_default_timeout_value(monkeypatch):
|
||||
"""Test that the default timeout is 5.0 seconds when env var is not set."""
|
||||
registry = _AgentServerRegistry()
|
||||
|
||||
# Create a mock FastAPI app
|
||||
mock_app = MagicMock()
|
||||
ready_event = threading.Event()
|
||||
|
||||
# Register the app but don't set the ready event
|
||||
with patch.dict(registry._apps, {8003: mock_app}):
|
||||
with patch.dict(registry._ready_events, {8003: ready_event}):
|
||||
with patch.dict(registry._started, {8003: False}):
|
||||
with patch("threading.Thread.start"): # Prevent real server spawning
|
||||
# Remove only the specific env var so other env vars (PATH etc.) remain intact
|
||||
monkeypatch.delenv("PRAISONAI_SERVER_READY_TIMEOUT", raising=False)
|
||||
with patch("threading.Event.wait") as mock_wait:
|
||||
mock_wait.return_value = False
|
||||
result = registry.start_server_if_needed(8003, host="127.0.0.1")
|
||||
|
||||
# Should have called wait with default timeout of 5.0
|
||||
mock_wait.assert_called_once_with(timeout=5.0)
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_invalid_timeout_env_var_fallback(caplog):
|
||||
"""Test that invalid PRAISONAI_SERVER_READY_TIMEOUT falls back to default with warning."""
|
||||
registry = _AgentServerRegistry()
|
||||
|
||||
# Create a mock FastAPI app
|
||||
mock_app = MagicMock()
|
||||
ready_event = threading.Event()
|
||||
|
||||
# Register the app but don't set the ready event
|
||||
with patch.dict(registry._apps, {8004: mock_app}):
|
||||
with patch.dict(registry._ready_events, {8004: ready_event}):
|
||||
with patch.dict(registry._started, {8004: False}):
|
||||
with patch("threading.Thread.start"): # Prevent real server spawning
|
||||
# Test with invalid timeout value
|
||||
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "invalid"}):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with patch("threading.Event.wait") as mock_wait:
|
||||
mock_wait.return_value = False
|
||||
result = registry.start_server_if_needed(8004, host="127.0.0.1")
|
||||
|
||||
# Should have fallen back to 5.0 and logged warning
|
||||
mock_wait.assert_called_once_with(timeout=5.0)
|
||||
assert result is True
|
||||
assert "Invalid PRAISONAI_SERVER_READY_TIMEOUT" in caplog.text
|
||||
@@ -0,0 +1 @@
|
||||
# Sandbox tests package
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Unit tests for SandboxManager.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from praisonaiagents.sandbox.manager import SandboxManager
|
||||
from praisonaiagents.sandbox.config import SandboxConfig
|
||||
from praisonaiagents.sandbox.protocols import SandboxResult, SandboxStatus
|
||||
|
||||
|
||||
class TestSandboxManager:
|
||||
"""Test SandboxManager functionality."""
|
||||
|
||||
def test_init_default_config(self):
|
||||
"""Test initialization with default config."""
|
||||
manager = SandboxManager()
|
||||
assert manager.config is not None
|
||||
assert manager.config.sandbox_type == "subprocess"
|
||||
|
||||
def test_init_with_config(self):
|
||||
"""Test initialization with custom config."""
|
||||
config = SandboxConfig.docker("python:3.11")
|
||||
manager = SandboxManager(config)
|
||||
assert manager.config == config
|
||||
|
||||
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
|
||||
async def test_context_manager(self, mock_create_sandbox):
|
||||
"""Test SandboxManager as async context manager."""
|
||||
mock_sandbox = AsyncMock()
|
||||
mock_create_sandbox.return_value = mock_sandbox
|
||||
|
||||
manager = SandboxManager()
|
||||
|
||||
async with manager as sandbox:
|
||||
assert sandbox == mock_sandbox
|
||||
mock_create_sandbox.assert_called_once()
|
||||
|
||||
# Cleanup should be called
|
||||
mock_sandbox.stop.assert_called_once()
|
||||
mock_sandbox.cleanup.assert_called_once()
|
||||
|
||||
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
|
||||
async def test_context_manager_cleanup_error(self, mock_create_sandbox):
|
||||
"""Test context manager cleanup with error."""
|
||||
mock_sandbox = AsyncMock()
|
||||
mock_sandbox.stop.side_effect = Exception("Stop failed")
|
||||
mock_create_sandbox.return_value = mock_sandbox
|
||||
|
||||
manager = SandboxManager()
|
||||
|
||||
# Should not raise exception, just log warning
|
||||
async with manager as sandbox:
|
||||
assert sandbox == mock_sandbox
|
||||
|
||||
mock_sandbox.stop.assert_called_once()
|
||||
mock_sandbox.cleanup.assert_called_once()
|
||||
|
||||
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
|
||||
async def test_run_code_convenience_method(self, mock_create_sandbox):
|
||||
"""Test run_code convenience method."""
|
||||
mock_sandbox = AsyncMock()
|
||||
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="output")
|
||||
mock_sandbox.execute.return_value = mock_result
|
||||
mock_create_sandbox.return_value = mock_sandbox
|
||||
|
||||
manager = SandboxManager()
|
||||
|
||||
result = await manager.run_code("print('hello')", language="python")
|
||||
|
||||
assert result == mock_result
|
||||
mock_sandbox.execute.assert_called_once_with("print('hello')", language="python")
|
||||
|
||||
async def test_create_sandbox_subprocess(self):
|
||||
"""Test creating subprocess sandbox."""
|
||||
config = SandboxConfig.subprocess()
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('praisonai.sandbox.subprocess.SubprocessSandbox') as mock_class:
|
||||
mock_instance = AsyncMock()
|
||||
mock_class.return_value = mock_instance
|
||||
mock_instance.is_available = True
|
||||
|
||||
sandbox = await manager._create_sandbox()
|
||||
|
||||
mock_class.assert_called_once_with(config)
|
||||
assert sandbox == mock_instance
|
||||
|
||||
async def test_create_sandbox_docker(self):
|
||||
"""Test creating Docker sandbox."""
|
||||
config = SandboxConfig.docker("python:3.11")
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('praisonai.sandbox.docker.DockerSandbox') as mock_class:
|
||||
mock_instance = AsyncMock()
|
||||
mock_class.return_value = mock_instance
|
||||
mock_instance.is_available = True
|
||||
|
||||
sandbox = await manager._create_sandbox()
|
||||
|
||||
mock_class.assert_called_once_with(config)
|
||||
assert sandbox == mock_instance
|
||||
|
||||
async def test_create_sandbox_e2b(self):
|
||||
"""Test creating E2B sandbox."""
|
||||
config = SandboxConfig.e2b()
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('praisonai.sandbox.e2b.E2BSandbox') as mock_class:
|
||||
mock_instance = AsyncMock()
|
||||
mock_class.return_value = mock_instance
|
||||
mock_instance.is_available = True
|
||||
|
||||
sandbox = await manager._create_sandbox()
|
||||
|
||||
mock_class.assert_called_once_with(config)
|
||||
assert sandbox == mock_instance
|
||||
|
||||
async def test_create_sandbox_unknown_type(self):
|
||||
"""Test creating sandbox with unknown type."""
|
||||
config = SandboxConfig(sandbox_type="unknown")
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown sandbox type"):
|
||||
await manager._create_sandbox()
|
||||
|
||||
async def test_create_sandbox_unavailable(self):
|
||||
"""Test creating sandbox when not available."""
|
||||
config = SandboxConfig.docker("python:3.11")
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('praisonai.sandbox.docker.DockerSandbox') as mock_class:
|
||||
mock_instance = Mock()
|
||||
mock_instance.is_available = False
|
||||
mock_class.return_value = mock_instance
|
||||
|
||||
with pytest.raises(RuntimeError, match="not available"):
|
||||
await manager._create_sandbox()
|
||||
|
||||
async def test_create_sandbox_import_error(self):
|
||||
"""Test creating sandbox with import error."""
|
||||
config = SandboxConfig.e2b()
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('importlib.import_module', side_effect=ImportError("Module not found")):
|
||||
with pytest.raises(ImportError, match="Module not found"):
|
||||
await manager._create_sandbox()
|
||||
|
||||
def test_get_available_types(self):
|
||||
"""Test getting available sandbox types."""
|
||||
manager = SandboxManager()
|
||||
|
||||
with patch('praisonaiagents.sandbox.manager.SandboxManager._check_availability') as mock_check:
|
||||
mock_check.side_effect = lambda t: t in ["subprocess", "docker"]
|
||||
|
||||
available = manager.get_available_types()
|
||||
|
||||
assert "subprocess" in available
|
||||
assert available["subprocess"] is True
|
||||
assert "docker" in available
|
||||
assert available["docker"] is True
|
||||
assert "e2b" in available
|
||||
assert available["e2b"] is False
|
||||
|
||||
def test_check_availability_subprocess(self):
|
||||
"""Test checking subprocess availability."""
|
||||
manager = SandboxManager()
|
||||
|
||||
# Subprocess should always be available
|
||||
available = manager._check_availability("subprocess")
|
||||
assert available is True
|
||||
|
||||
def test_check_availability_docker(self):
|
||||
"""Test checking Docker availability."""
|
||||
manager = SandboxManager()
|
||||
|
||||
with patch('importlib.import_module') as mock_import:
|
||||
with patch.object(mock_import.return_value, 'DockerSandbox') as mock_class:
|
||||
mock_instance = Mock()
|
||||
mock_instance.is_available = True
|
||||
mock_class.return_value = mock_instance
|
||||
|
||||
available = manager._check_availability("docker")
|
||||
assert available is True
|
||||
|
||||
def test_check_availability_not_available(self):
|
||||
"""Test checking availability when not available."""
|
||||
manager = SandboxManager()
|
||||
|
||||
with patch('importlib.import_module', side_effect=ImportError()):
|
||||
available = manager._check_availability("docker")
|
||||
assert available is False
|
||||
|
||||
def test_check_availability_unknown(self):
|
||||
"""Test checking availability for unknown type."""
|
||||
manager = SandboxManager()
|
||||
|
||||
available = manager._check_availability("unknown")
|
||||
assert available is False
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Unit tests for SandboxMixin functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from praisonaiagents.agent.sandbox_mixin import SandboxMixin
|
||||
from praisonaiagents.sandbox import SandboxConfig, SandboxResult, SandboxStatus
|
||||
|
||||
|
||||
class MockAgent(SandboxMixin):
|
||||
"""Mock agent class for testing SandboxMixin."""
|
||||
|
||||
def __init__(self, sandbox=None, verbose=False):
|
||||
super().__init__(sandbox=sandbox)
|
||||
self.verbose = verbose
|
||||
|
||||
|
||||
class TestSandboxMixin:
|
||||
"""Test SandboxMixin functionality."""
|
||||
|
||||
def test_init_no_sandbox(self):
|
||||
"""Test initialization without sandbox."""
|
||||
agent = MockAgent()
|
||||
assert not agent.has_sandbox
|
||||
assert agent.sandbox_config is None
|
||||
|
||||
def test_init_with_boolean_true(self):
|
||||
"""Test initialization with sandbox=True."""
|
||||
agent = MockAgent(sandbox=True)
|
||||
assert agent.has_sandbox
|
||||
assert agent.sandbox_config is not None
|
||||
assert agent.sandbox_config.sandbox_type == "subprocess"
|
||||
|
||||
def test_init_with_config(self):
|
||||
"""Test initialization with explicit config."""
|
||||
config = SandboxConfig.docker("python:3.11")
|
||||
agent = MockAgent(sandbox=config)
|
||||
assert agent.has_sandbox
|
||||
assert agent.sandbox_config == config
|
||||
|
||||
def test_get_sandbox_manager_no_config(self):
|
||||
"""Test get_sandbox_manager without config."""
|
||||
agent = MockAgent()
|
||||
manager = agent.get_sandbox_manager()
|
||||
assert manager is None
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
def test_get_sandbox_manager_with_config(self, mock_manager_class):
|
||||
"""Test get_sandbox_manager with config."""
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config)
|
||||
|
||||
manager = agent.get_sandbox_manager()
|
||||
|
||||
mock_manager_class.assert_called_once_with(config)
|
||||
assert agent._sandbox_manager is not None
|
||||
|
||||
def test_execute_code_without_sandbox_raises(self):
|
||||
"""Test that execute_code raises without sandbox."""
|
||||
agent = MockAgent()
|
||||
|
||||
with pytest.raises(RuntimeError, match="No sandbox configured"):
|
||||
agent.execute_code_sync("print('hello')")
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.check_code_safety')
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
async def test_execute_code_with_warnings(self, mock_manager_class, mock_check_safety):
|
||||
"""Test execute_code with security warnings."""
|
||||
# Setup
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config, verbose=True)
|
||||
|
||||
mock_warnings = ["Potential security issue"]
|
||||
mock_check_safety.return_value = mock_warnings
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="output")
|
||||
mock_manager.run_code.return_value = mock_result
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
# Execute
|
||||
result = await agent.execute_code("print('test')", check_security=True)
|
||||
|
||||
# Verify
|
||||
mock_check_safety.assert_called_once_with("print('test')", "python")
|
||||
mock_manager.run_code.assert_called_once()
|
||||
assert result == mock_result
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
async def test_run_shell_command(self, mock_manager_class):
|
||||
"""Test run_shell_command functionality."""
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config)
|
||||
|
||||
# Mock manager as async context manager
|
||||
mock_manager = AsyncMock()
|
||||
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="result")
|
||||
mock_manager.__aenter__.return_value = mock_manager
|
||||
mock_manager.__aexit__.return_value = None
|
||||
mock_manager.run_command.return_value = mock_result
|
||||
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
result = await agent.run_shell_command("echo hello")
|
||||
|
||||
mock_manager.run_command.assert_called_once_with("echo hello")
|
||||
assert result == mock_result
|
||||
|
||||
def test_get_sandbox_status_no_config(self):
|
||||
"""Test get_sandbox_status without config."""
|
||||
agent = MockAgent()
|
||||
status = agent.get_sandbox_status()
|
||||
assert status == {"configured": False}
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
def test_get_sandbox_status_with_config(self, mock_manager_class):
|
||||
"""Test get_sandbox_status with config."""
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config)
|
||||
|
||||
mock_manager = Mock()
|
||||
mock_manager.get_available_types.return_value = {"subprocess": True}
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
status = agent.get_sandbox_status()
|
||||
|
||||
assert status["configured"] is True
|
||||
assert status["config"] is not None
|
||||
assert status["available_types"] == {"subprocess": True}
|
||||
assert status["current_type"] == "subprocess"
|
||||
|
||||
def test_get_code_execution_tools_no_sandbox(self):
|
||||
"""Test _get_code_execution_tools without sandbox."""
|
||||
agent = MockAgent()
|
||||
tools = agent._get_code_execution_tools()
|
||||
assert tools == []
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
def test_get_code_execution_tools_with_sandbox(self, mock_manager_class):
|
||||
"""Test _get_code_execution_tools with sandbox."""
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config)
|
||||
|
||||
tools = agent._get_code_execution_tools()
|
||||
|
||||
assert len(tools) == 2
|
||||
# Check that tools are functions
|
||||
assert callable(tools[0])
|
||||
assert callable(tools[1])
|
||||
|
||||
async def test_sandbox_cleanup(self):
|
||||
"""Test sandbox cleanup functionality."""
|
||||
agent = MockAgent()
|
||||
|
||||
# Mock manager
|
||||
mock_manager = AsyncMock()
|
||||
agent._sandbox_manager = mock_manager
|
||||
|
||||
await agent.sandbox_cleanup()
|
||||
|
||||
mock_manager.__aexit__.assert_called_once_with(None, None, None)
|
||||
assert agent._sandbox_manager is None
|
||||
|
||||
async def test_sandbox_cleanup_with_error(self):
|
||||
"""Test sandbox cleanup with error handling."""
|
||||
agent = MockAgent()
|
||||
|
||||
# Mock manager that raises exception
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.__aexit__.side_effect = Exception("Cleanup failed")
|
||||
agent._sandbox_manager = mock_manager
|
||||
|
||||
# Should not raise, just log warning
|
||||
await agent.sandbox_cleanup()
|
||||
|
||||
assert agent._sandbox_manager is None
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Unit tests for sandbox security module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from praisonaiagents.sandbox.security import (
|
||||
check_code_safety,
|
||||
format_warnings,
|
||||
SecurityWarning,
|
||||
SecurityLevel,
|
||||
SecurityAnalyzer
|
||||
)
|
||||
|
||||
|
||||
class TestCodeSafety:
|
||||
"""Test code safety checking functionality."""
|
||||
|
||||
def test_empty_code(self):
|
||||
"""Test empty code returns no warnings."""
|
||||
warnings = check_code_safety("", "python")
|
||||
assert warnings == []
|
||||
|
||||
def test_safe_code(self):
|
||||
"""Test safe code returns no warnings."""
|
||||
safe_code = """
|
||||
x = 1 + 2
|
||||
print(f"Result: {x}")
|
||||
"""
|
||||
warnings = check_code_safety(safe_code, "python")
|
||||
assert warnings == []
|
||||
|
||||
def test_dangerous_imports(self):
|
||||
"""Test detection of dangerous imports."""
|
||||
dangerous_code = """
|
||||
import os
|
||||
import subprocess
|
||||
os.system('rm -rf /')
|
||||
"""
|
||||
warnings = check_code_safety(dangerous_code, "python")
|
||||
assert len(warnings) > 0
|
||||
assert any("os.system" in str(w) for w in warnings)
|
||||
|
||||
def test_eval_exec_usage(self):
|
||||
"""Test detection of eval/exec usage."""
|
||||
dangerous_code = """
|
||||
user_input = input("Enter code: ")
|
||||
eval(user_input)
|
||||
exec(user_input)
|
||||
"""
|
||||
warnings = check_code_safety(dangerous_code, "python")
|
||||
assert len(warnings) > 0
|
||||
assert any("eval" in str(w) or "exec" in str(w) for w in warnings)
|
||||
|
||||
def test_network_operations(self):
|
||||
"""Test detection of network operations."""
|
||||
network_code = """
|
||||
import urllib.request
|
||||
import socket
|
||||
urllib.request.urlopen('http://example.com')
|
||||
"""
|
||||
warnings = check_code_safety(network_code, "python")
|
||||
assert len(warnings) > 0
|
||||
|
||||
def test_file_operations(self):
|
||||
"""Test detection of file operations."""
|
||||
file_code = """
|
||||
with open('/etc/passwd', 'r') as f:
|
||||
data = f.read()
|
||||
"""
|
||||
warnings = check_code_safety(file_code, "python")
|
||||
assert len(warnings) > 0
|
||||
|
||||
def test_bash_commands(self):
|
||||
"""Test bash command safety checking."""
|
||||
dangerous_bash = "rm -rf / --no-preserve-root"
|
||||
warnings = check_code_safety(dangerous_bash, "bash")
|
||||
assert len(warnings) > 0
|
||||
|
||||
def test_sql_injection_patterns(self):
|
||||
"""Test SQL injection pattern detection."""
|
||||
sql_code = """
|
||||
query = f"SELECT * FROM users WHERE id = {user_id}"
|
||||
cursor.execute(query)
|
||||
"""
|
||||
warnings = check_code_safety(sql_code, "python")
|
||||
assert len(warnings) > 0
|
||||
|
||||
def test_format_warnings(self):
|
||||
"""Test warning formatting."""
|
||||
warnings = [
|
||||
SecurityWarning("Test warning", SecurityLevel.HIGH, "eval", 1),
|
||||
SecurityWarning("Another warning", SecurityLevel.MEDIUM, "import os", 2)
|
||||
]
|
||||
|
||||
formatted = format_warnings(warnings)
|
||||
assert "Test warning" in formatted
|
||||
assert "Another warning" in formatted
|
||||
assert "HIGH" in formatted
|
||||
assert "MEDIUM" in formatted
|
||||
|
||||
def test_format_warnings_empty(self):
|
||||
"""Test formatting empty warnings list."""
|
||||
formatted = format_warnings([])
|
||||
assert formatted == ""
|
||||
|
||||
|
||||
class TestSecurityAnalyzer:
|
||||
"""Test SecurityAnalyzer class."""
|
||||
|
||||
def test_analyzer_initialization(self):
|
||||
"""Test SecurityAnalyzer initialization."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
assert analyzer is not None
|
||||
|
||||
def test_analyze_safe_code(self):
|
||||
"""Test analyzer with safe code."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
code = "print('Hello, World!')"
|
||||
|
||||
warnings = analyzer.analyze(code, "python")
|
||||
assert warnings == []
|
||||
|
||||
def test_analyze_unsafe_code(self):
|
||||
"""Test analyzer with unsafe code."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
code = "import os; os.system('rm -rf /')"
|
||||
|
||||
warnings = analyzer.analyze(code, "python")
|
||||
assert len(warnings) > 0
|
||||
assert all(isinstance(w, SecurityWarning) for w in warnings)
|
||||
|
||||
def test_different_languages(self):
|
||||
"""Test analyzer with different languages."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
|
||||
# Python
|
||||
python_warnings = analyzer.analyze("import os", "python")
|
||||
assert len(python_warnings) > 0
|
||||
|
||||
# Bash
|
||||
bash_warnings = analyzer.analyze("rm -rf /", "bash")
|
||||
assert len(bash_warnings) > 0
|
||||
|
||||
# JavaScript (should fall back to basic checks)
|
||||
js_warnings = analyzer.analyze("eval(userInput)", "javascript")
|
||||
assert len(js_warnings) > 0
|
||||
|
||||
def test_security_levels(self):
|
||||
"""Test different security levels are detected."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
|
||||
# High risk
|
||||
high_risk_code = "import os; os.system('rm -rf /')"
|
||||
warnings = analyzer.analyze(high_risk_code, "python")
|
||||
high_warnings = [w for w in warnings if w.level == SecurityLevel.HIGH]
|
||||
assert len(high_warnings) > 0
|
||||
|
||||
# Medium risk
|
||||
medium_risk_code = "import subprocess"
|
||||
warnings = analyzer.analyze(medium_risk_code, "python")
|
||||
medium_warnings = [w for w in warnings if w.level == SecurityLevel.MEDIUM]
|
||||
# May or may not have medium warnings depending on implementation
|
||||
|
||||
def test_line_numbers(self):
|
||||
"""Test that line numbers are correctly reported."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
code = """
|
||||
print("Safe line")
|
||||
import os
|
||||
os.system('dangerous')
|
||||
"""
|
||||
|
||||
warnings = analyzer.analyze(code, "python")
|
||||
assert len(warnings) > 0
|
||||
|
||||
# Check that line numbers are reasonable
|
||||
line_numbers = [w.line_number for w in warnings if w.line_number is not None]
|
||||
assert all(ln > 0 for ln in line_numbers)
|
||||
assert all(ln <= 4 for ln in line_numbers) # Within the code block
|
||||
|
||||
def test_pattern_matching(self):
|
||||
"""Test that specific patterns are matched correctly."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
|
||||
test_cases = [
|
||||
("eval(x)", ["eval"]),
|
||||
("exec('code')", ["exec"]),
|
||||
("__import__('os')", ["__import__"]),
|
||||
("open('/etc/passwd')", ["file access"]),
|
||||
("subprocess.call(['rm', 'file'])", ["subprocess"]),
|
||||
]
|
||||
|
||||
for code, expected_patterns in test_cases:
|
||||
warnings = analyzer.analyze(code, "python")
|
||||
assert len(warnings) > 0, f"No warnings for: {code}"
|
||||
|
||||
# Check that at least one expected pattern is found
|
||||
warning_texts = [str(w) for w in warnings]
|
||||
found = any(
|
||||
any(pattern.lower() in text.lower() for text in warning_texts)
|
||||
for pattern in expected_patterns
|
||||
)
|
||||
assert found, f"Expected patterns {expected_patterns} not found in warnings for: {code}"
|
||||
@@ -21,6 +21,16 @@ import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _unregister_bot_platform(name: str) -> None:
|
||||
"""Remove a runtime-registered bot platform (tests only)."""
|
||||
from praisonai.bots._registry import get_default_bot_registry
|
||||
|
||||
try:
|
||||
get_default_bot_registry().unregister(name.lower())
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Helper: Mock adapter that simulates a platform bot
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -59,9 +69,10 @@ class TestBotWithMockAdapter:
|
||||
"""Integration tests for Bot class with a mock platform adapter."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -148,10 +159,14 @@ class TestBotOSLifecycle:
|
||||
"""Integration tests for BotOS multi-bot orchestration."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mock1", MockPlatformAdapter)
|
||||
register_platform("mock2", MockPlatformAdapter)
|
||||
self._cleanup = lambda: (_custom_platforms.pop("mock1", None), _custom_platforms.pop("mock2", None))
|
||||
self._cleanup = lambda: (
|
||||
_unregister_bot_platform("mock1"),
|
||||
_unregister_bot_platform("mock2"),
|
||||
)
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -212,9 +227,10 @@ class TestRealAgentInBot:
|
||||
"""Tests with a real praisonaiagents.Agent inside Bot."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -246,9 +262,10 @@ class TestAgentTeamInBotOS:
|
||||
"""AgentTeam (multi-agent) inside Bot and BotOS."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -300,9 +317,10 @@ class TestAgentFlowInBotOS:
|
||||
"""AgentFlow (deterministic workflow) inside Bot and BotOS."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -355,9 +373,8 @@ class TestPlatformExtensibility:
|
||||
"""Third-party platform registration and usage."""
|
||||
|
||||
def teardown_method(self):
|
||||
from praisonai.bots._registry import _custom_platforms
|
||||
_custom_platforms.pop("custom_chat", None)
|
||||
_custom_platforms.pop("my_platform", None)
|
||||
_unregister_bot_platform("custom_chat")
|
||||
_unregister_bot_platform("my_platform")
|
||||
|
||||
def test_register_and_use_custom_platform(self):
|
||||
from praisonai.bots._registry import register_platform, resolve_adapter
|
||||
@@ -389,7 +406,7 @@ class TestPlatformExtensibility:
|
||||
|
||||
def test_resolve_unknown_platform_raises(self):
|
||||
from praisonai.bots._registry import resolve_adapter
|
||||
with pytest.raises(ValueError, match="Unknown platform"):
|
||||
with pytest.raises(ValueError, match=r"Unknown .*plugin"):
|
||||
resolve_adapter("nonexistent_xyz")
|
||||
|
||||
|
||||
@@ -401,9 +418,10 @@ class TestBotOSFromConfig:
|
||||
"""BotOS.from_config() YAML config loading."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -459,7 +477,8 @@ platforms:
|
||||
|
||||
def test_from_config_multi_platform(self):
|
||||
from praisonai.bots import BotOS
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mock2", MockPlatformAdapter)
|
||||
|
||||
yaml_content = """\
|
||||
@@ -484,7 +503,7 @@ platforms:
|
||||
assert "mock2" in botos.list_bots()
|
||||
finally:
|
||||
os.unlink(path)
|
||||
_custom_platforms.pop("mock2", None)
|
||||
_unregister_bot_platform("mock2")
|
||||
|
||||
def test_from_config_invalid_raises(self):
|
||||
from praisonai.bots import BotOS
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""@url mentions must not fetch loopback targets."""
|
||||
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
|
||||
def test_url_mention_blocks_loopback():
|
||||
parser = MentionsParser()
|
||||
result = parser._process_url_mention("http://127.0.0.1:8765/")
|
||||
assert result is not None
|
||||
assert "Blocked" in result
|
||||
@@ -361,3 +361,11 @@ class TestProfileExports:
|
||||
from praisonaiagents.tools import AUTONOMY_PROFILE
|
||||
|
||||
assert AUTONOMY_PROFILE is not None
|
||||
|
||||
def test_import_execute_code_uses_module_function(self):
|
||||
"""execute_code should resolve to python_tools.execute_code function."""
|
||||
from praisonaiagents.tools import execute_code
|
||||
from praisonaiagents.tools.python_tools import execute_code as execute_code_impl
|
||||
|
||||
assert callable(execute_code)
|
||||
assert execute_code is execute_code_impl
|
||||
@@ -112,6 +112,22 @@ class TestSandboxEscapePrevention:
|
||||
result = sandbox.run("__import__('os')")
|
||||
assert result["success"] is False
|
||||
|
||||
def test_print_self_blocked(self, sandbox):
|
||||
"""print.__self__ leaks real builtins module (GHSA-4mr5-g6f9-cfrh)."""
|
||||
result = sandbox.run("b = print.__self__")
|
||||
assert result["success"] is False
|
||||
assert "restricted" in result["stderr"].lower()
|
||||
|
||||
def test_vars_call_blocked(self, sandbox):
|
||||
"""vars() can expose builtins.__dict__ after __self__ leak."""
|
||||
result = sandbox.run("vars({})")
|
||||
assert result["success"] is False
|
||||
|
||||
def test_attribute_dunder_call_blocked(self, sandbox):
|
||||
"""Attribute calls bypass bare-name Call checks."""
|
||||
result = sandbox.run("(1).__class__.__mro__")
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
# ── Legitimate Code (all must PASS) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -38,6 +38,16 @@ def test_still_blocks_loopback():
|
||||
assert spider._validate_url("http://localhost/") is False
|
||||
|
||||
|
||||
def test_blocks_alternate_loopback_encodings():
|
||||
"""GHSA-5c6w-wwfq-7qqm: non-canonical loopback host forms."""
|
||||
spider = SpiderTools()
|
||||
assert spider._validate_url("http://localhost.:8765/") is False
|
||||
assert spider._validate_url("http://127.1:8765/") is False
|
||||
assert spider._validate_url("http://0177.0.0.1:8765/") is False
|
||||
assert spider._validate_url("http://0x7f000001:8765/") is False
|
||||
assert spider._validate_url("http://2130706433:8765/") is False
|
||||
|
||||
|
||||
def test_rejects_non_string_input():
|
||||
spider = SpiderTools()
|
||||
assert spider._validate_url(None) is False # type: ignore[arg-type]
|
||||
|
||||
Generated
+271
-172
@@ -21,16 +21,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
version = "2.4.4"
|
||||
version = "2.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/55/e4373e888fdacb15563ef6fa9fa8c8252476ea071e96fb46defac9f18bf2/aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745", size = 21977, upload-time = "2024-11-30T18:44:00.701Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/74/fbb6559de3607b3300b9be3cc64e97548d55678e44623db17820dbd20002/aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8", size = 14756, upload-time = "2024-11-30T18:43:39.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.11.11"
|
||||
version = "3.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -42,80 +42,123 @@ dependencies = [
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/ed/f26db39d29cd3cb2f5a3374304c713fe5ab5a0e4c8ee25a0c45cc6adf844/aiohttp-3.11.11.tar.gz", hash = "sha256:bb49c7f1e6ebf3821a42d81d494f538107610c3a705987f53068546b0e90303e", size = 7669618, upload-time = "2024-12-18T21:20:50.191Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7d/ff2e314b8f9e0b1df833e2d4778eaf23eae6b8cc8f922495d110ddcbf9e1/aiohttp-3.11.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a60804bff28662cbcf340a4d61598891f12eea3a66af48ecfdc975ceec21e3c8", size = 708550, upload-time = "2024-12-18T21:17:26.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/b8/aeb4975d5bba233d6f246941f5957a5ad4e3def8b0855a72742e391925f2/aiohttp-3.11.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b4fa1cb5f270fb3eab079536b764ad740bb749ce69a94d4ec30ceee1b5940d5", size = 468430, upload-time = "2024-12-18T21:17:30.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/5b/5b620279b3df46e597008b09fa1e10027a39467387c2332657288e25811a/aiohttp-3.11.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:731468f555656767cda219ab42e033355fe48c85fbe3ba83a349631541715ba2", size = 455593, upload-time = "2024-12-18T21:17:34.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/75/0cdf014b816867d86c0bc26f3d3e3f194198dbf33037890beed629cd4f8f/aiohttp-3.11.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb23d8bb86282b342481cad4370ea0853a39e4a32a0042bb52ca6bdde132df43", size = 1584635, upload-time = "2024-12-18T21:17:37.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/2f/95b8f4e4dfeb57c1d9ad9fa911ede35a0249d75aa339edd2c2270dc539da/aiohttp-3.11.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f047569d655f81cb70ea5be942ee5d4421b6219c3f05d131f64088c73bb0917f", size = 1632363, upload-time = "2024-12-18T21:17:40.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/cb/70cf69ea7c50f5b0021a84f4c59c3622b2b3b81695f48a2f0e42ef7eba6e/aiohttp-3.11.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd7659baae9ccf94ae5fe8bfaa2c7bc2e94d24611528395ce88d009107e00c6d", size = 1668315, upload-time = "2024-12-18T21:17:42.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/cc/3a3fc7a290eabc59839a7e15289cd48f33dd9337d06e301064e1e7fb26c5/aiohttp-3.11.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af01e42ad87ae24932138f154105e88da13ce7d202a6de93fafdafb2883a00ef", size = 1589546, upload-time = "2024-12-18T21:17:45.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b4/0f7b0ed41ac6000e283e7332f0f608d734b675a8509763ca78e93714cfb0/aiohttp-3.11.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5854be2f3e5a729800bac57a8d76af464e160f19676ab6aea74bde18ad19d438", size = 1544581, upload-time = "2024-12-18T21:17:48.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/b9/4d06470fd85c687b6b0e31935ef73dde6e31767c9576d617309a2206556f/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6526e5fb4e14f4bbf30411216780c9967c20c5a55f2f51d3abd6de68320cc2f3", size = 1529256, upload-time = "2024-12-18T21:17:51.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/a2/6958b1b880fc017fd35f5dfb2c26a9a50c755b75fd9ae001dc2236a4fb79/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:85992ee30a31835fc482468637b3e5bd085fa8fe9392ba0bdcbdc1ef5e9e3c55", size = 1536592, upload-time = "2024-12-18T21:17:54.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/dd/b974012a9551fd654f5bb95a6dd3f03d6e6472a17e1a8216dd42e9638d6c/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88a12ad8ccf325a8a5ed80e6d7c3bdc247d66175afedbe104ee2aaca72960d8e", size = 1607446, upload-time = "2024-12-18T21:17:57.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/d3/6c98fd87e638e51f074a3f2061e81fcb92123bcaf1439ac1b4a896446e40/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0a6d3fbf2232e3a08c41eca81ae4f1dff3d8f1a30bae415ebe0af2d2458b8a33", size = 1628809, upload-time = "2024-12-18T21:17:59.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/2e/86e6f85cbca02be042c268c3d93e7f35977a0e127de56e319bdd1569eaa8/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84a585799c58b795573c7fa9b84c455adf3e1d72f19a2bf498b54a95ae0d194c", size = 1564291, upload-time = "2024-12-18T21:18:02.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/8d/1f4ef3503b767717f65e1f5178b0173ab03cba1a19997ebf7b052161189f/aiohttp-3.11.11-cp310-cp310-win32.whl", hash = "sha256:bfde76a8f430cf5c5584553adf9926534352251d379dcb266ad2b93c54a29745", size = 416601, upload-time = "2024-12-18T21:18:05.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/86/81cb83691b5ace3d9aa148dc42bacc3450d749fc88c5ec1973573c1c1779/aiohttp-3.11.11-cp310-cp310-win_amd64.whl", hash = "sha256:0fd82b8e9c383af11d2b26f27a478640b6b83d669440c0a71481f7c865a51da9", size = 442007, upload-time = "2024-12-18T21:18:07.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ae/e8806a9f054e15f1d18b04db75c23ec38ec954a10c0a68d3bd275d7e8be3/aiohttp-3.11.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ba74ec819177af1ef7f59063c6d35a214a8fde6f987f7661f4f0eecc468a8f76", size = 708624, upload-time = "2024-12-18T21:18:10.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/e0/313ef1a333fb4d58d0c55a6acb3cd772f5d7756604b455181049e222c020/aiohttp-3.11.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4af57160800b7a815f3fe0eba9b46bf28aafc195555f1824555fa2cfab6c1538", size = 468507, upload-time = "2024-12-18T21:18:12.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/60/03455476bf1f467e5b4a32a465c450548b2ce724eec39d69f737191f936a/aiohttp-3.11.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffa336210cf9cd8ed117011085817d00abe4c08f99968deef0013ea283547204", size = 455571, upload-time = "2024-12-18T21:18:15.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/f9/469588603bd75bf02c8ffb8c8a0d4b217eed446b49d4a767684685aa33fd/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b8fe282183e4a3c7a1b72f5ade1094ed1c6345a8f153506d114af5bf8accd9", size = 1685694, upload-time = "2024-12-18T21:18:17.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b9/1b7fa43faf6c8616fa94c568dc1309ffee2b6b68b04ac268e5d64b738688/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af41686ccec6a0f2bdc66686dc0f403c41ac2089f80e2214a0f82d001052c03", size = 1743660, upload-time = "2024-12-18T21:18:20.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/8b/0248d19dbb16b67222e75f6aecedd014656225733157e5afaf6a6a07e2e8/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70d1f9dde0e5dd9e292a6d4d00058737052b01f3532f69c0c65818dac26dc287", size = 1785421, upload-time = "2024-12-18T21:18:22.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/11/f478e071815a46ca0a5ae974651ff0c7a35898c55063305a896e58aa1247/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249cc6912405917344192b9f9ea5cd5b139d49e0d2f5c7f70bdfaf6b4dbf3a2e", size = 1675145, upload-time = "2024-12-18T21:18:24.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5d/284d182fecbb5075ae10153ff7374f57314c93a8681666600e3a9e09c505/aiohttp-3.11.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0eb98d90b6690827dcc84c246811feeb4e1eea683c0eac6caed7549be9c84665", size = 1619804, upload-time = "2024-12-18T21:18:26.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/78/980064c2ad685c64ce0e8aeeb7ef1e53f43c5b005edcd7d32e60809c4992/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec82bf1fda6cecce7f7b915f9196601a1bd1a3079796b76d16ae4cce6d0ef89b", size = 1654007, upload-time = "2024-12-18T21:18:29.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/8d/9e658d63b1438ad42b96f94da227f2e2c1d5c6001c9e8ffcc0bfb22e9105/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9fd46ce0845cfe28f108888b3ab17abff84ff695e01e73657eec3f96d72eef34", size = 1650022, upload-time = "2024-12-18T21:18:33.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fd/a032bf7f2755c2df4f87f9effa34ccc1ef5cea465377dbaeef93bb56bbd6/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd176afcf8f5d2aed50c3647d4925d0db0579d96f75a31e77cbaf67d8a87742d", size = 1732899, upload-time = "2024-12-18T21:18:35.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/0c/c2b85fde167dd440c7ba50af2aac20b5a5666392b174df54c00f888c5a75/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ec2aa89305006fba9ffb98970db6c8221541be7bee4c1d027421d6f6df7d1ce2", size = 1755142, upload-time = "2024-12-18T21:18:37.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/78/91ae1a3b3b3bed8b893c5d69c07023e151b1c95d79544ad04cf68f596c2f/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:92cde43018a2e17d48bb09c79e4d4cb0e236de5063ce897a5e40ac7cb4878773", size = 1692736, upload-time = "2024-12-18T21:18:40.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/89/a7ef9c4b4cdb546fcc650ca7f7395aaffbd267f0e1f648a436bec33c9b95/aiohttp-3.11.11-cp311-cp311-win32.whl", hash = "sha256:aba807f9569455cba566882c8938f1a549f205ee43c27b126e5450dc9f83cc62", size = 416418, upload-time = "2024-12-18T21:18:44.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/db/2192489a8a51b52e06627506f8ac8df69ee221de88ab9bdea77aa793aa6a/aiohttp-3.11.11-cp311-cp311-win_amd64.whl", hash = "sha256:ae545f31489548c87b0cced5755cfe5a5308d00407000e72c4fa30b19c3220ac", size = 442509, upload-time = "2024-12-18T21:18:47.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/cf/4bda538c502f9738d6b95ada11603c05ec260807246e15e869fc3ec5de97/aiohttp-3.11.11-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e595c591a48bbc295ebf47cb91aebf9bd32f3ff76749ecf282ea7f9f6bb73886", size = 704666, upload-time = "2024-12-18T21:18:49.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/7b/87fcef2cad2fad420ca77bef981e815df6904047d0a1bd6aeded1b0d1d66/aiohttp-3.11.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ea1b59dc06396b0b424740a10a0a63974c725b1c64736ff788a3689d36c02d2", size = 464057, upload-time = "2024-12-18T21:18:51.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a6/789e1f17a1b6f4a38939fbc39d29e1d960d5f89f73d0629a939410171bc0/aiohttp-3.11.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8811f3f098a78ffa16e0ea36dffd577eb031aea797cbdba81be039a4169e242c", size = 455996, upload-time = "2024-12-18T21:18:53.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/dd/485061fbfef33165ce7320db36e530cd7116ee1098e9c3774d15a732b3fd/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7227b87a355ce1f4bf83bfae4399b1f5bb42e0259cb9405824bd03d2f4336a", size = 1682367, upload-time = "2024-12-18T21:18:55.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d7/9ec5b3ea9ae215c311d88b2093e8da17e67b8856673e4166c994e117ee3e/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d40f9da8cabbf295d3a9dae1295c69975b86d941bc20f0a087f0477fa0a66231", size = 1736989, upload-time = "2024-12-18T21:18:56.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fb/ea94927f7bfe1d86178c9d3e0a8c54f651a0a655214cce930b3c679b8f64/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffb3dc385f6bb1568aa974fe65da84723210e5d9707e360e9ecb51f59406cd2e", size = 1793265, upload-time = "2024-12-18T21:19:00.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/7f/6de218084f9b653026bd7063cd8045123a7ba90c25176465f266976d8c82/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8f5f7515f3552d899c61202d99dcb17d6e3b0de777900405611cd747cecd1b8", size = 1691841, upload-time = "2024-12-18T21:19:02.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/e2/992f43d87831cbddb6b09c57ab55499332f60ad6fdbf438ff4419c2925fc/aiohttp-3.11.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3499c7ffbfd9c6a3d8d6a2b01c26639da7e43d47c7b4f788016226b1e711caa8", size = 1619317, upload-time = "2024-12-18T21:19:04.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/74/879b23cdd816db4133325a201287c95bef4ce669acde37f8f1b8669e1755/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e2bf8029dbf0810c7bfbc3e594b51c4cc9101fbffb583a3923aea184724203c", size = 1641416, upload-time = "2024-12-18T21:19:09.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/98/b123f6b15d87c54e58fd7ae3558ff594f898d7f30a90899718f3215ad328/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6212a60e5c482ef90f2d788835387070a88d52cf6241d3916733c9176d39eab", size = 1646514, upload-time = "2024-12-18T21:19:12.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/38/257fda3dc99d6978ab943141d5165ec74fd4b4164baa15e9c66fa21da86b/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d119fafe7b634dbfa25a8c597718e69a930e4847f0b88e172744be24515140da", size = 1702095, upload-time = "2024-12-18T21:19:15.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f4/ddab089053f9fb96654df5505c0a69bde093214b3c3454f6bfdb1845f558/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6fba278063559acc730abf49845d0e9a9e1ba74f85f0ee6efd5803f08b285853", size = 1734611, upload-time = "2024-12-18T21:19:18.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d6/f30b2bc520c38c8aa4657ed953186e535ae84abe55c08d0f70acd72ff577/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92fc484e34b733704ad77210c7957679c5c3877bd1e6b6d74b185e9320cc716e", size = 1694576, upload-time = "2024-12-18T21:19:21.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/97/b0a88c3f4c6d0020b34045ee6d954058abc870814f6e310c4c9b74254116/aiohttp-3.11.11-cp312-cp312-win32.whl", hash = "sha256:9f5b3c1ed63c8fa937a920b6c1bec78b74ee09593b3f5b979ab2ae5ef60d7600", size = 411363, upload-time = "2024-12-18T21:19:23.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/23/cc36d9c398980acaeeb443100f0216f50a7cfe20c67a9fd0a2f1a5a846de/aiohttp-3.11.11-cp312-cp312-win_amd64.whl", hash = "sha256:1e69966ea6ef0c14ee53ef7a3d68b564cc408121ea56c0caa2dc918c1b2f553d", size = 437666, upload-time = "2024-12-18T21:19:26.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d1/d8af164f400bad432b63e1ac857d74a09311a8334b0481f2f64b158b50eb/aiohttp-3.11.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:541d823548ab69d13d23730a06f97460f4238ad2e5ed966aaf850d7c369782d9", size = 697982, upload-time = "2024-12-18T21:19:28.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/d1/faad3bf9fa4bfd26b95c69fc2e98937d52b1ff44f7e28131855a98d23a17/aiohttp-3.11.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:929f3ed33743a49ab127c58c3e0a827de0664bfcda566108989a14068f820194", size = 460662, upload-time = "2024-12-18T21:19:31.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/61/0d71cc66d63909dabc4590f74eba71f91873a77ea52424401c2498d47536/aiohttp-3.11.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0882c2820fd0132240edbb4a51eb8ceb6eef8181db9ad5291ab3332e0d71df5f", size = 452950, upload-time = "2024-12-18T21:19:33.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/db/6d04bc7fd92784900704e16b745484ef45b77bd04e25f58f6febaadf7983/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63de12e44935d5aca7ed7ed98a255a11e5cb47f83a9fded7a5e41c40277d104", size = 1665178, upload-time = "2024-12-18T21:19:36.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/5c/e95ade9ae29f375411884d9fd98e50535bf9fe316c9feb0f30cd2ac8f508/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa54f8ef31d23c506910c21163f22b124facb573bff73930735cf9fe38bf7dff", size = 1717939, upload-time = "2024-12-18T21:19:40.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/1c/1e7d5c5daea9e409ed70f7986001b8c9e3a49a50b28404498d30860edab6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a344d5dc18074e3872777b62f5f7d584ae4344cd6006c17ba12103759d407af3", size = 1775125, upload-time = "2024-12-18T21:19:43.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/66/890987e44f7d2f33a130e37e01a164168e6aff06fce15217b6eaf14df4f6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7fb429ab1aafa1f48578eb315ca45bd46e9c37de11fe45c7f5f4138091e2f1", size = 1677176, upload-time = "2024-12-18T21:19:46.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/dc/e2ba57d7a52df6cdf1072fd5fa9c6301a68e1cd67415f189805d3eeb031d/aiohttp-3.11.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c341c7d868750e31961d6d8e60ff040fb9d3d3a46d77fd85e1ab8e76c3e9a5c4", size = 1603192, upload-time = "2024-12-18T21:19:48.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9e/8d08a57de79ca3a358da449405555e668f2c8871a7777ecd2f0e3912c272/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed9ee95614a71e87f1a70bc81603f6c6760128b140bc4030abe6abaa988f1c3d", size = 1618296, upload-time = "2024-12-18T21:19:50.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/51/89822e3ec72db352c32e7fc1c690370e24e231837d9abd056490f3a49886/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de8d38f1c2810fa2a4f1d995a2e9c70bb8737b18da04ac2afbf3971f65781d87", size = 1616524, upload-time = "2024-12-18T21:19:52.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/fa/e2e6d9398f462ffaa095e84717c1732916a57f1814502929ed67dd7568ef/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a9b7371665d4f00deb8f32208c7c5e652059b0fda41cf6dbcac6114a041f1cc2", size = 1685471, upload-time = "2024-12-18T21:19:54.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/5f/6bb976e619ca28a052e2c0ca7b0251ccd893f93d7c24a96abea38e332bf6/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:620598717fce1b3bd14dd09947ea53e1ad510317c85dda2c9c65b622edc96b12", size = 1715312, upload-time = "2024-12-18T21:19:56.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/c1/756a7e65aa087c7fac724d6c4c038f2faaa2a42fe56dbc1dd62a33ca7213/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf8d9bfee991d8acc72d060d53860f356e07a50f0e0d09a8dfedea1c554dd0d5", size = 1672783, upload-time = "2024-12-18T21:19:59.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ba/a6190ebb02176c7f75e6308da31f5d49f6477b651a3dcfaaaca865a298e2/aiohttp-3.11.11-cp313-cp313-win32.whl", hash = "sha256:9d73ee3725b7a737ad86c2eac5c57a4a97793d9f442599bea5ec67ac9f4bdc3d", size = 410229, upload-time = "2024-12-18T21:20:02.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/62/c9fa5bafe03186a0e4699150a7fed9b1e73240996d0d2f0e5f70f3fdf471/aiohttp-3.11.11-cp313-cp313-win_amd64.whl", hash = "sha256:c7a06301c2fb096bdb0bd25fe2011531c1453b9f2c163c8031600ec73af1cc99", size = 436081, upload-time = "2024-12-18T21:20:04.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/05/6817e0390eb47b0867cf8efdb535298191662192281bc3ca62a0cb7973eb/aiohttp-3.13.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6290fe12fe8cefa6ea3c1c5b969d32c010dfe191d4392ff9b599a3f473cbe722", size = 753094, upload-time = "2026-03-28T17:14:59.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c1/e5b7f25f6dd1ab57da92aa9d226b2c8b56f223dd20475d3ddfddaba86ab8/aiohttp-3.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7520d92c0e8fbbe63f36f20a5762db349ff574ad38ad7bc7732558a650439845", size = 505213, upload-time = "2026-03-28T17:15:01.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/e5/8f42033c7ce98b54dfd3791f03e60231cfe4a2db4471b5fc188df2b8a6ad/aiohttp-3.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2710ae1e1b81d0f187883b6e9d66cecf8794b50e91aa1e73fc78bfb5503b5d9", size = 498580, upload-time = "2026-03-28T17:15:03.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a4/bbc989f5362066b81930da1a66084a859a971d03faab799dc59a3ce3a220/aiohttp-3.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:717d17347567ded1e273aa09918650dfd6fd06f461549204570c7973537d4123", size = 1692718, upload-time = "2026-03-28T17:15:05.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/72/3775116969931f151be116689d2ae6ddafff2ec2887d8f9b4e7043f32e74/aiohttp-3.13.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:383880f7b8de5ac208fa829c7038d08e66377283b2de9e791b71e06e803153c2", size = 1660714, upload-time = "2026-03-28T17:15:08.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e8/d2f1a2da2743e32fe348ebf8a4c59caad14a92f5f18af616fd33381275e1/aiohttp-3.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1867087e2c1963db1216aedf001efe3b129835ed2b05d97d058176a6d08b5726", size = 1744152, upload-time = "2026-03-28T17:15:10.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a6/575886f417ac3c08e462f2ca237cc49f436bd992ca3f7ff95b7dd9c44205/aiohttp-3.13.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6234bf416a38d687c3ab7f79934d7fb2a42117a5b9813aca07de0a5398489023", size = 1836278, upload-time = "2026-03-28T17:15:12.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/4c/0051d4550fb9e8b5ca4e0fe1ccd58652340915180c5164999e6741bf2083/aiohttp-3.13.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdd3393130bf6588962441ffd5bde1d3ea2d63a64afa7119b3f3ba349cebbe7", size = 1687953, upload-time = "2026-03-28T17:15:14.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/54/841e87b8c51c2adc01a3ceb9919dc45c7899fe4c21deb70aada734ea5a38/aiohttp-3.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d0dbc6c76befa76865373d6aa303e480bb8c3486e7763530f7f6e527b471118", size = 1572484, upload-time = "2026-03-28T17:15:15.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/f1/21cbf5f7fa1e267af6301f886cab9b314f085e4d0097668d189d165cd7da/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10fb7b53262cf4144a083c9db0d2b4d22823d6708270a9970c4627b248c6064c", size = 1662851, upload-time = "2026-03-28T17:15:17.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/15/bcad6b68d7bef27ae7443288215767263c7753ede164267cf6cf63c94a87/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:eb10ce8c03850e77f4d9518961c227be569e12f71525a7e90d17bca04299921d", size = 1671984, upload-time = "2026-03-28T17:15:19.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/ab316931afc7a73c7f493bb1b30fbd61e28ec2d3ea50353336e76293e8ec/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7c65738ac5ae32b8feef699a4ed0dc91a0c8618b347781b7461458bbcaaac7eb", size = 1713880, upload-time = "2026-03-28T17:15:21.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/45/314e8e64c7f328174964b6db511dd5e9e60c9121ab5457bc2c908b7d03a4/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6b335919ffbaf98df8ff3c74f7a6decb8775882632952fd1810a017e38f15aee", size = 1560315, upload-time = "2026-03-28T17:15:23.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e7/93d5fa06fe00219a81466577dacae9e3732f3b4f767b12b2e2cc8c35c970/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ec75fc18cb9f4aca51c2cbace20cf6716e36850f44189644d2d69a875d5e0532", size = 1735115, upload-time = "2026-03-28T17:15:25.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/9f/f64b95392ddd4e204fd9ab7cd33dd18d14ac9e4b86866f1f6a69b7cda83d/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:463fa18a95c5a635d2b8c09babe240f9d7dbf2a2010a6c0b35d8c4dff2a0e819", size = 1673916, upload-time = "2026-03-28T17:15:27.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/c1/bb33be79fd285c69f32e5b074b299cae8847f748950149c3965c1b3b3adf/aiohttp-3.13.4-cp310-cp310-win32.whl", hash = "sha256:13168f5645d9045522c6cef818f54295376257ed8d02513a37c2ef3046fc7a97", size = 440277, upload-time = "2026-03-28T17:15:29.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f9/7cf1688da4dd0885f914ee40bc8e1dce776df98fe6518766de975a570538/aiohttp-3.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:a7058af1f53209fdf07745579ced525d38d481650a989b7aa4a3b484b901cdab", size = 463015, upload-time = "2026-03-28T17:15:30.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "frozenlist" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -899,6 +942,29 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docker"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dockerfile-parse"
|
||||
version = "2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "durationpy"
|
||||
version = "0.9"
|
||||
@@ -908,6 +974,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a3/ac312faeceffd2d8f86bc6dcb5c401188ba5a01bc88e69bed97578a0dfcd/durationpy-0.9-py3-none-any.whl", hash = "sha256:e65359a7af5cedad07fb77a2dd3f390f8eb0b74cb845589fa6c057086834dd38", size = 3461, upload-time = "2024-10-02T17:58:59.349Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "e2b"
|
||||
version = "2.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "dockerfile-parse" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "httpx" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/1b/d11119f70ada7d8f0d3a79e6cd60c9180f614ddcd99afa436d89b81739bc/e2b-2.2.3.tar.gz", hash = "sha256:6e28655b1dc3753005e48e7268c02b29314d4b7907cf8617ba777a7096b471d9", size = 89414, upload-time = "2025-10-09T08:44:36.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d1/ab9dec3e4abc9f932a9ef4a9a57900332a3c6df31db454d2d8ef0b6d1944/e2b-2.2.3-py3-none-any.whl", hash = "sha256:c6949b7c17ab66a5c56c187b473a7f5d5f367e22cf2bd8e3c5e376117003dc56", size = 165490, upload-time = "2025-10-09T08:44:35.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "e2b-code-interpreter"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "e2b" },
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/d0/116900ce07bef1cf2068a0c6da6df4eebcb78b22c037ccb3360613ac1d47/e2b_code_interpreter-2.1.1.tar.gz", hash = "sha256:a51af42d30cbeca158168cee11e362254467b508a0ab578760248f0e97779dd8", size = 10114, upload-time = "2025-10-08T00:18:19.086Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/c0/774e3e37ba818e27496d2a483a2d679a56b6427562f6e94af862eefc9a9f/e2b_code_interpreter-2.1.1-py3-none-any.whl", hash = "sha256:f7d561ae0bc6f0ae755d09afaa636d3ba935af1b11e6e31d5dbd5988da5a4101", size = 12983, upload-time = "2025-10-08T00:18:17.424Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.1"
|
||||
@@ -1392,18 +1491,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.27.2"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189, upload-time = "2024-08-27T12:54:01.334Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395, upload-time = "2024-08-27T12:53:59.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1509,14 +1607,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.5"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674, upload-time = "2024-12-21T18:30:22.828Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596, upload-time = "2024-12-21T18:30:19.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1797,13 +1895,12 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.81.1"
|
||||
version = "1.83.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "click" },
|
||||
{ name = "fastuuid" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "importlib-metadata" },
|
||||
{ name = "jinja2" },
|
||||
@@ -1814,9 +1911,9 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/2b/299d54f95e02e9ed551c186e881a4ac0eaa5a948a6be93ecaa26a748f1be/litellm-1.81.1.tar.gz", hash = "sha256:9c758db8abff04a2f1f43582d042080e36f245fe34cfbafe2f8b7ca8f1de29b6", size = 13487469, upload-time = "2026-01-21T12:55:58.271Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/73/8d100c4e48935f6a381df60f894ca9c063ea412ce354fbe7a17770ad4092/litellm-1.81.1-py3-none-any.whl", hash = "sha256:503512a8a7f3cddf9d8fed6182c14f1e77c5655635fe67b09efb09c75234bb87", size = 11795146, upload-time = "2026-01-21T12:55:55.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2488,7 +2585,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.15.0"
|
||||
version = "2.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -2500,9 +2597,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2951,7 +3048,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.37"
|
||||
version = "1.6.41"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -2964,10 +3061,12 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
all = [
|
||||
{ name = "ast-grep-cli" },
|
||||
{ name = "chonkie" },
|
||||
{ name = "chromadb" },
|
||||
{ name = "crawl4ai" },
|
||||
{ name = "ddgs" },
|
||||
{ name = "e2b-code-interpreter" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "litellm" },
|
||||
{ name = "markitdown", extra = ["all"] },
|
||||
@@ -3035,6 +3134,12 @@ os = [
|
||||
{ name = "pyjwt" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sandbox = [
|
||||
{ name = "e2b-code-interpreter" },
|
||||
]
|
||||
sandbox-docker = [
|
||||
{ name = "docker" },
|
||||
]
|
||||
search = [
|
||||
{ name = "ddgs" },
|
||||
]
|
||||
@@ -3052,11 +3157,13 @@ requires-dist = [
|
||||
{ name = "chromadb", marker = "extra == 'memory'", specifier = ">=1.0.0" },
|
||||
{ name = "crawl4ai", marker = "extra == 'crawl'", specifier = ">=0.4.0" },
|
||||
{ name = "ddgs", marker = "extra == 'search'", specifier = ">=9.0.0" },
|
||||
{ name = "docker", marker = "extra == 'sandbox-docker'", specifier = ">=7.0.0" },
|
||||
{ name = "e2b-code-interpreter", marker = "extra == 'sandbox'", specifier = ">=1.0.0" },
|
||||
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
|
||||
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
|
||||
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
|
||||
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.81.0,<=1.82.6" },
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.81.0,<=1.82.6" },
|
||||
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.83.14" },
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
|
||||
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
|
||||
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
@@ -3069,6 +3176,7 @@ requires-dist = [
|
||||
{ name = "posthog", marker = "extra == 'telemetry'", specifier = ">=3.0.0" },
|
||||
{ name = "praisonaiagents", extras = ["api"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["auth"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["autonomy"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["crawl"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["graph"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["knowledge"], marker = "extra == 'all'" },
|
||||
@@ -3076,6 +3184,8 @@ requires-dist = [
|
||||
{ name = "praisonaiagents", extras = ["mcp"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["memory"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["mongodb"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["os"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["sandbox"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["search"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["telemetry"], marker = "extra == 'all'" },
|
||||
{ name = "pydantic", specifier = ">=2.10.0" },
|
||||
@@ -3092,7 +3202,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "search", "crawl", "all"]
|
||||
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "search", "crawl", "sandbox", "sandbox-docker", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "primp"
|
||||
@@ -3586,11 +3696,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.0.1"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4378,104 +4488,93 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/02/576ff3a6639e755c4f70997b2d315f56d6d71e0d046f4fb64cb81a3fb099/tiktoken-0.8.0.tar.gz", hash = "sha256:9ccbb2740f24542534369c5635cfd9b2b3c2490754a78ac8831d99f89f94eeb2", size = 35107, upload-time = "2024-10-03T22:44:04.196Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ba/a35fad753bbca8ba0cc1b0f3402a70256a110ced7ac332cf84ba89fc87ab/tiktoken-0.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b07e33283463089c81ef1467180e3e00ab00d46c2c4bbcef0acab5f771d6695e", size = 1039905, upload-time = "2024-10-03T22:43:17.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/05/13dab8fd7460391c387b3e69e14bf1e51ff71fe0a202cd2933cc3ea93fb6/tiktoken-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9269348cb650726f44dd3bbb3f9110ac19a8dcc8f54949ad3ef652ca22a38e21", size = 982417, upload-time = "2024-10-03T22:43:19.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/98/18ec4a8351a6cf4537e40cd6e19a422c10cce1ef00a2fcb716e0a96af58b/tiktoken-0.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e13f37bc4ef2d012731e93e0fef21dc3b7aea5bb9009618de9a4026844e560", size = 1144915, upload-time = "2024-10-03T22:43:21.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/28/cf3633018cbcc6deb7805b700ccd6085c9a5a7f72b38974ee0bffd56d311/tiktoken-0.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f13d13c981511331eac0d01a59b5df7c0d4060a8be1e378672822213da51e0a2", size = 1177221, upload-time = "2024-10-03T22:43:23.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/81/8a5be305cbd39d4e83a794f9e80c7f2c84b524587b7feb27c797b2046d51/tiktoken-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6b2ddbc79a22621ce8b1166afa9f9a888a664a579350dc7c09346a3b5de837d9", size = 1237398, upload-time = "2024-10-03T22:43:24.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/da/8d1cc3089a83f5cf11c2e489332752981435280285231924557350523a59/tiktoken-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d8c2d0e5ba6453a290b86cd65fc51fedf247e1ba170191715b049dac1f628005", size = 884215, upload-time = "2024-10-03T22:43:26.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1e/ca48e7bfeeccaf76f3a501bd84db1fa28b3c22c9d1a1f41af9fb7579c5f6/tiktoken-0.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d622d8011e6d6f239297efa42a2657043aaed06c4f68833550cac9e9bc723ef1", size = 1039700, upload-time = "2024-10-03T22:43:28.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/f8/f0101d98d661b34534769c3818f5af631e59c36ac6d07268fbfc89e539ce/tiktoken-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2efaf6199717b4485031b4d6edb94075e4d79177a172f38dd934d911b588d54a", size = 982413, upload-time = "2024-10-03T22:43:29.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/3c/2b95391d9bd520a73830469f80a96e3790e6c0a5ac2444f80f20b4b31051/tiktoken-0.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5637e425ce1fc49cf716d88df3092048359a4b3bbb7da762840426e937ada06d", size = 1144242, upload-time = "2024-10-04T04:42:53.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/c4/c4a4360de845217b6aa9709c15773484b50479f36bb50419c443204e5de9/tiktoken-0.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fb0e352d1dbe15aba082883058b3cce9e48d33101bdaac1eccf66424feb5b47", size = 1176588, upload-time = "2024-10-03T22:43:31.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/a3/ef984e976822cd6c2227c854f74d2e60cf4cd6fbfca46251199914746f78/tiktoken-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56edfefe896c8f10aba372ab5706b9e3558e78db39dd497c940b47bf228bc419", size = 1237261, upload-time = "2024-10-03T22:43:32.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/86/eea2309dc258fb86c7d9b10db536434fc16420feaa3b6113df18b23db7c2/tiktoken-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:326624128590def898775b722ccc327e90b073714227175ea8febbc920ac0a99", size = 884537, upload-time = "2024-10-03T22:43:34.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/22/34b2e136a6f4af186b6640cbfd6f93400783c9ef6cd550d9eab80628d9de/tiktoken-0.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:881839cfeae051b3628d9823b2e56b5cc93a9e2efb435f4cf15f17dc45f21586", size = 1039357, upload-time = "2024-10-03T22:43:36.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d2/c793cf49c20f5855fd6ce05d080c0537d7418f22c58e71f392d5e8c8dbf7/tiktoken-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe9399bdc3f29d428f16a2f86c3c8ec20be3eac5f53693ce4980371c3245729b", size = 982616, upload-time = "2024-10-03T22:43:37.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a1/79846e5ef911cd5d75c844de3fa496a10c91b4b5f550aad695c5df153d72/tiktoken-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a58deb7075d5b69237a3ff4bb51a726670419db6ea62bdcd8bd80c78497d7ab", size = 1144011, upload-time = "2024-10-03T22:43:39.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/32/e0e3a859136e95c85a572e4806dc58bf1ddf651108ae8b97d5f3ebe1a244/tiktoken-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2908c0d043a7d03ebd80347266b0e58440bdef5564f84f4d29fb235b5df3b04", size = 1175432, upload-time = "2024-10-03T22:43:40.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/89/926b66e9025b97e9fbabeaa59048a736fe3c3e4530a204109571104f921c/tiktoken-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:294440d21a2a51e12d4238e68a5972095534fe9878be57d905c476017bff99fc", size = 1236576, upload-time = "2024-10-03T22:43:41.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e2/39d4aa02a52bba73b2cd21ba4533c84425ff8786cc63c511d68c8897376e/tiktoken-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:d8f3192733ac4d77977432947d563d7e1b310b96497acd3c196c9bddb36ed9db", size = 883824, upload-time = "2024-10-03T22:43:43.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/802e79ba0ee5fcbf240cd624143f57744e5d411d2e9d9ad2db70d8395986/tiktoken-0.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:02be1666096aff7da6cbd7cdaa8e7917bfed3467cd64b38b1f112e96d3b06a24", size = 1039648, upload-time = "2024-10-03T22:43:45.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/da/24cdbfc302c98663fbea66f5866f7fa1048405c7564ab88483aea97c3b1a/tiktoken-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94ff53c5c74b535b2cbf431d907fc13c678bbd009ee633a2aca269a04389f9a", size = 982763, upload-time = "2024-10-03T22:43:46.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f0/0ecf79a279dfa41fc97d00adccf976ecc2556d3c08ef3e25e45eb31f665b/tiktoken-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b231f5e8982c245ee3065cd84a4712d64692348bc609d84467c57b4b72dcbc5", size = 1144417, upload-time = "2024-10-03T22:43:48.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/d3/155d2d4514f3471a25dc1d6d20549ef254e2aa9bb5b1060809b1d3b03d3a/tiktoken-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4177faa809bd55f699e88c96d9bb4635d22e3f59d635ba6fd9ffedf7150b9953", size = 1175108, upload-time = "2024-10-03T22:43:50.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/eb/5989e16821ee8300ef8ee13c16effc20dfc26c777d05fbb6825e3c037b81/tiktoken-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5376b6f8dc4753cd81ead935c5f518fa0fbe7e133d9e25f648d8c4dabdd4bad7", size = 1236520, upload-time = "2024-10-03T22:43:51.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/59/14b20465f1d1cb89cfbc96ec27e5617b2d41c79da12b5e04e96d689be2a7/tiktoken-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:18228d624807d66c87acd8f25fc135665617cab220671eb65b50f5d70fa51f69", size = 883849, upload-time = "2024-10-03T22:43:53.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/b3/2cb7c17b6c4cf8ca983204255d3f1d95eda7213e247e6947a0ee2c747a2c/tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970", size = 1051991, upload-time = "2025-10-06T20:21:34.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0f/df139f1df5f6167194ee5ab24634582ba9a1b62c6b996472b0277ec80f66/tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16", size = 995798, upload-time = "2025-10-06T20:21:35.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/5d/26a691f28ab220d5edc09b9b787399b130f24327ef824de15e5d85ef21aa/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030", size = 1129865, upload-time = "2025-10-06T20:21:36.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/94/443fab3d4e5ebecac895712abd3849b8da93b7b7dec61c7db5c9c7ebe40c/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134", size = 1152856, upload-time = "2025-10-06T20:21:37.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/35/388f941251b2521c70dd4c5958e598ea6d2c88e28445d2fb8189eecc1dfc/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a", size = 1195308, upload-time = "2025-10-06T20:21:39.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/00/c6681c7f833dd410576183715a530437a9873fa910265817081f65f9105f/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892", size = 1255697, upload-time = "2025-10-06T20:21:41.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d2/82e795a6a9bafa034bf26a58e68fe9a89eeaaa610d51dbeb22106ba04f0a/tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1", size = 879375, upload-time = "2025-10-06T20:21:43.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokenizers"
|
||||
version = "0.20.3"
|
||||
version = "0.22.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/25/b1681c1c30ea3ea6e584ae3fffd552430b12faa599b558c4c4783f56d7ff/tokenizers-0.20.3.tar.gz", hash = "sha256:2278b34c5d0dd78e087e1ca7f9b1dcbf129d80211afa645f214bd6e051037539", size = 340513, upload-time = "2024-11-05T17:34:10.403Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/51/421bb0052fc4333f7c1e3231d8c6607552933d919b628c8fabd06f60ba1e/tokenizers-0.20.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:31ccab28dbb1a9fe539787210b0026e22debeab1662970f61c2d921f7557f7e4", size = 2674308, upload-time = "2024-11-05T17:30:25.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/e9/f651f8d27614fd59af387f4dfa568b55207e5fac8d06eec106dc00b921c4/tokenizers-0.20.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6361191f762bda98c773da418cf511cbaa0cb8d0a1196f16f8c0119bde68ff8", size = 2559363, upload-time = "2024-11-05T17:30:28.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e8/0e9f81a09ab79f409eabfd99391ca519e315496694671bebca24c3e90448/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f128d5da1202b78fa0a10d8d938610472487da01b57098d48f7e944384362514", size = 2892896, upload-time = "2024-11-05T17:30:30.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/72/15fdbc149e05005e99431ecd471807db2241983deafe1e704020f608f40e/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79c4121a2e9433ad7ef0769b9ca1f7dd7fa4c0cd501763d0a030afcbc6384481", size = 2802785, upload-time = "2024-11-05T17:30:32.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/44/1f8aea48f9bb117d966b7272484671b33a509f6217a8e8544d79442c90db/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7850fde24197fe5cd6556e2fdba53a6d3bae67c531ea33a3d7c420b90904141", size = 3086060, upload-time = "2024-11-05T17:30:34.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/83/82ba40da99870b3a0b801cffaf4f099f088a84c7e07d32cc6ca751ce08e6/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b357970c095dc134978a68c67d845a1e3803ab7c4fbb39195bde914e7e13cf8b", size = 3096760, upload-time = "2024-11-05T17:30:36.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/46/7a025404201d937f86548928616c0a164308aa3998e546efdf798bf5ee9c/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a333d878c4970b72d6c07848b90c05f6b045cf9273fc2bc04a27211721ad6118", size = 3380165, upload-time = "2024-11-05T17:30:37.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/49/15fae66ac62e49255eeedbb7f4127564b2c3f3aef2009913f525732d1a08/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd9fee817f655a8f50049f685e224828abfadd436b8ff67979fc1d054b435f1", size = 2994038, upload-time = "2024-11-05T17:30:40.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/64/693afc9ba2393c2eed85c02bacb44762f06a29f0d1a5591fa5b40b39c0a2/tokenizers-0.20.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9e7816808b402129393a435ea2a509679b41246175d6e5e9f25b8692bfaa272b", size = 8977285, upload-time = "2024-11-05T17:30:42.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/7e/6126c18694310fe07970717929e889898767c41fbdd95b9078e8aec0f9ef/tokenizers-0.20.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba96367db9d8a730d3a1d5996b4b7babb846c3994b8ef14008cd8660f55db59d", size = 9294890, upload-time = "2024-11-05T17:30:44.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/7d/5e3307a1091c8608a1e58043dff49521bc19553c6e9548c7fac6840cc2c4/tokenizers-0.20.3-cp310-none-win32.whl", hash = "sha256:ee31ba9d7df6a98619426283e80c6359f167e2e9882d9ce1b0254937dbd32f3f", size = 2196883, upload-time = "2024-11-05T17:30:46.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/62/aaf5b2a526b3b10c20985d9568ff8c8f27159345eaef3347831e78cd5894/tokenizers-0.20.3-cp310-none-win_amd64.whl", hash = "sha256:a845c08fdad554fe0871d1255df85772f91236e5fd6b9287ef8b64f5807dbd0c", size = 2381637, upload-time = "2024-11-05T17:30:48.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/93/6742ef9206409d5ce1fdf44d5ca1687cdc3847ba0485424e2c731e6bcf67/tokenizers-0.20.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:585b51e06ca1f4839ce7759941e66766d7b060dccfdc57c4ca1e5b9a33013a90", size = 2674224, upload-time = "2024-11-05T17:30:49.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/14/e75ece72e99f6ef9ae07777ca9fdd78608f69466a5cecf636e9bd2f25d5c/tokenizers-0.20.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61cbf11954f3b481d08723ebd048ba4b11e582986f9be74d2c3bdd9293a4538d", size = 2558991, upload-time = "2024-11-05T17:30:51.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/54/033b5b2ba0c3ae01e026c6f7ced147d41a2fa1c573d00a66cb97f6d7f9b3/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef820880d5e4e8484e2fa54ff8d297bb32519eaa7815694dc835ace9130a3eea", size = 2892476, upload-time = "2024-11-05T17:30:53.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b0/cc369fb3297d61f3311cab523d16d48c869dc2f0ba32985dbf03ff811041/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:67ef4dcb8841a4988cd00dd288fb95dfc8e22ed021f01f37348fd51c2b055ba9", size = 2802775, upload-time = "2024-11-05T17:30:55.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/74/62ad983e8ea6a63e04ed9c5be0b605056bf8aac2f0125f9b5e0b3e2b89fa/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff1ef8bd47a02b0dc191688ccb4da53600df5d4c9a05a4b68e1e3de4823e78eb", size = 3086138, upload-time = "2024-11-05T17:30:57.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ac/4637ba619db25094998523f9e6f5b456e1db1f8faa770a3d925d436db0c3/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:444d188186eab3148baf0615b522461b41b1f0cd58cd57b862ec94b6ac9780f1", size = 3098076, upload-time = "2024-11-05T17:30:59.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ce/9793f2dc2ce529369807c9c74e42722b05034af411d60f5730b720388c7d/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37c04c032c1442740b2c2d925f1857885c07619224a533123ac7ea71ca5713da", size = 3379650, upload-time = "2024-11-05T17:31:01.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/f6/2841de926bc4118af996eaf0bdf0ea5b012245044766ffc0347e6c968e63/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:453c7769d22231960ee0e883d1005c93c68015025a5e4ae56275406d94a3c907", size = 2994005, upload-time = "2024-11-05T17:31:02.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b2/00915c4fed08e9505d37cf6eaab45b12b4bff8f6719d459abcb9ead86a4b/tokenizers-0.20.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4bb31f7b2847e439766aaa9cc7bccf7ac7088052deccdb2275c952d96f691c6a", size = 8977488, upload-time = "2024-11-05T17:31:04.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/ac/1c069e7808181ff57bcf2d39e9b6fbee9133a55410e6ebdaa89f67c32e83/tokenizers-0.20.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:843729bf0f991b29655a069a2ff58a4c24375a553c70955e15e37a90dd4e045c", size = 9294935, upload-time = "2024-11-05T17:31:06.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/47/722feb70ee68d1c4412b12d0ea4acc2713179fd63f054913990f9e259492/tokenizers-0.20.3-cp311-none-win32.whl", hash = "sha256:efcce3a927b1e20ca694ba13f7a68c59b0bd859ef71e441db68ee42cf20c2442", size = 2197175, upload-time = "2024-11-05T17:31:09.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/68/1b4f928b15a36ed278332ac75d66d7eb65d865bf344d049c452c18447bf9/tokenizers-0.20.3-cp311-none-win_amd64.whl", hash = "sha256:88301aa0801f225725b6df5dea3d77c80365ff2362ca7e252583f2b4809c4cc0", size = 2381616, upload-time = "2024-11-05T17:31:10.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/00/92a08af2a6b0c88c50f1ab47d7189e695722ad9714b0ee78ea5e1e2e1def/tokenizers-0.20.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:49d12a32e190fad0e79e5bdb788d05da2f20d8e006b13a70859ac47fecf6ab2f", size = 2667951, upload-time = "2024-11-05T17:31:12.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/9a/e17a352f0bffbf415cf7d73756f5c73a3219225fc5957bc2f39d52c61684/tokenizers-0.20.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:282848cacfb9c06d5e51489f38ec5aa0b3cd1e247a023061945f71f41d949d73", size = 2555167, upload-time = "2024-11-05T17:31:13.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/37/d108df55daf4f0fcf1f58554692ff71687c273d870a34693066f0847be96/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abe4e08c7d0cd6154c795deb5bf81d2122f36daf075e0c12a8b050d824ef0a64", size = 2898389, upload-time = "2024-11-05T17:31:15.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/27/32f29da16d28f59472fa7fb38e7782069748c7e9ab9854522db20341624c/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca94fc1b73b3883c98f0c88c77700b13d55b49f1071dfd57df2b06f3ff7afd64", size = 2795866, upload-time = "2024-11-05T17:31:16.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/4e/8a9a3c89e128c4a40f247b501c10279d2d7ade685953407c4d94c8c0f7a7/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef279c7e239f95c8bdd6ff319d9870f30f0d24915b04895f55b1adcf96d6c60d", size = 3085446, upload-time = "2024-11-05T17:31:18.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/3b/a2a7962c496ebcd95860ca99e423254f760f382cd4bd376f8895783afaf5/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16384073973f6ccbde9852157a4fdfe632bb65208139c9d0c0bd0176a71fd67f", size = 3094378, upload-time = "2024-11-05T17:31:20.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f4/a8a33f0192a1629a3bd0afcad17d4d221bbf9276da4b95d226364208d5eb/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:312d522caeb8a1a42ebdec87118d99b22667782b67898a76c963c058a7e41d4f", size = 3385755, upload-time = "2024-11-05T17:31:21.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/65/c83cb3545a65a9eaa2e13b22c93d5e00bd7624b354a44adbdc93d5d9bd91/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2b7cb962564785a83dafbba0144ecb7f579f1d57d8c406cdaa7f32fe32f18ad", size = 2997679, upload-time = "2024-11-05T17:31:23.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/e9/a80d4e592307688a67c7c59ab77e03687b6a8bd92eb5db763a2c80f93f57/tokenizers-0.20.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:124c5882ebb88dadae1fc788a582299fcd3a8bd84fc3e260b9918cf28b8751f5", size = 8989296, upload-time = "2024-11-05T17:31:24.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/af/60c957af8d2244321124e893828f1a4817cde1a2d08d09d423b73f19bd2f/tokenizers-0.20.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2b6e54e71f84c4202111a489879005cb14b92616a87417f6c102c833af961ea2", size = 9303621, upload-time = "2024-11-05T17:31:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/a9/96172310ee141009646d63a1ca267c099c462d747fe5ef7e33f74e27a683/tokenizers-0.20.3-cp312-none-win32.whl", hash = "sha256:83d9bfbe9af86f2d9df4833c22e94d94750f1d0cd9bfb22a7bb90a86f61cdb1c", size = 2188979, upload-time = "2024-11-05T17:31:29.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/68/61d85ae7ae96dde7d0974ff3538db75d5cdc29be2e4329cd7fc51a283e22/tokenizers-0.20.3-cp312-none-win_amd64.whl", hash = "sha256:44def74cee574d609a36e17c8914311d1b5dbcfe37c55fd29369d42591b91cf2", size = 2380725, upload-time = "2024-11-05T17:31:31.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/19/36e9eaafb229616cb8502b42030fa7fe347550e76cb618de71b498fc3222/tokenizers-0.20.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0b630e0b536ef0e3c8b42c685c1bc93bd19e98c0f1543db52911f8ede42cf84", size = 2666813, upload-time = "2024-11-05T17:31:32.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c7/e2ce1d4f756c8a62ef93fdb4df877c2185339b6d63667b015bf70ea9d34b/tokenizers-0.20.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a02d160d2b19bcbfdf28bd9a4bf11be4cb97d0499c000d95d4c4b1a4312740b6", size = 2555354, upload-time = "2024-11-05T17:31:34.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/cf/5309c2d173a6a67f9ec8697d8e710ea32418de6fd8541778032c202a1c3e/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e3d80d89b068bc30034034b5319218c7c0a91b00af19679833f55f3becb6945", size = 2897745, upload-time = "2024-11-05T17:31:35.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e5/af3078e32f225e680e69d61f78855880edb8d53f5850a1834d519b2b103f/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:174a54910bed1b089226512b4458ea60d6d6fd93060254734d3bc3540953c51c", size = 2794385, upload-time = "2024-11-05T17:31:37.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/bc421fe46650cc4eb4a913a236b88c243204f32c7480684d2f138925899e/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:098b8a632b8656aa5802c46689462c5c48f02510f24029d71c208ec2c822e771", size = 3084580, upload-time = "2024-11-05T17:31:39.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/22/97e1e95ee81f75922c9f569c23cb2b1fdc7f5a7a29c4c9fae17e63f751a6/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78c8c143e3ae41e718588281eb3e212c2b31623c9d6d40410ec464d7d6221fb5", size = 3093581, upload-time = "2024-11-05T17:31:41.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/14/f0df0ee3b9e516121e23c0099bccd7b9f086ba9150021a750e99b16ce56f/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b26b0aadb18cd8701077362ba359a06683662d5cafe3e8e8aba10eb05c037f1", size = 3385934, upload-time = "2024-11-05T17:31:43.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/52/7a171bd4929e3ffe61a29b4340fe5b73484709f92a8162a18946e124c34c/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07d7851a72717321022f3774e84aa9d595a041d643fafa2e87fbc9b18711dac0", size = 2997311, upload-time = "2024-11-05T17:31:46.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/64/f1993bb8ebf775d56875ca0d50a50f2648bfbbb143da92fe2e6ceeb4abd5/tokenizers-0.20.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:bd44e48a430ada902c6266a8245f5036c4fe744fcb51f699999fbe82aa438797", size = 8988601, upload-time = "2024-11-05T17:31:47.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/3f/49fa63422159bbc2f2a4ac5bfc597d04d4ec0ad3d2ef46649b5e9a340e37/tokenizers-0.20.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a4c186bb006ccbe1f5cc4e0380d1ce7806f5955c244074fd96abc55e27b77f01", size = 9303950, upload-time = "2024-11-05T17:31:50.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/11/79d91aeb2817ad1993ef61c690afe73e6dbedbfb21918b302ef5a2ba9bfb/tokenizers-0.20.3-cp313-none-win32.whl", hash = "sha256:6e19e0f1d854d6ab7ea0c743d06e764d1d9a546932be0a67f33087645f00fe13", size = 2188941, upload-time = "2024-11-05T17:31:53.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ff/ac8410f868fb8b14b5e619efa304aa119cb8a40bd7df29fc81a898e64f99/tokenizers-0.20.3-cp313-none-win_amd64.whl", hash = "sha256:d50ede425c7e60966a9680d41b58b3a0950afa1bb570488e2972fa61662c4273", size = 2380269, upload-time = "2024-11-05T17:31:54.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/cd/ff1586dd572aaf1637d59968df3f6f6532fa255f4638fbc29f6d27e0b690/tokenizers-0.20.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e919f2e3e68bb51dc31de4fcbbeff3bdf9c1cad489044c75e2b982a91059bd3c", size = 2672044, upload-time = "2024-11-05T17:33:07.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/9e/7a2c00abbc8edb021ee0b1f12aab76a7b7824b49f94bcd9f075d0818d4b0/tokenizers-0.20.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b8e9608f2773996cc272156e305bd79066163a66b0390fe21750aff62df1ac07", size = 2558841, upload-time = "2024-11-05T17:33:09.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/c1/6af62ef61316f33ecf785bbb2bee4292f34ea62b491d4480ad9b09acf6b6/tokenizers-0.20.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39270a7050deaf50f7caff4c532c01b3c48f6608d42b3eacdebdc6795478c8df", size = 2897936, upload-time = "2024-11-05T17:33:11.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/0b/c076b2ff3ee6dc70c805181fbe325668b89cfee856f8dfa24cc9aa293c84/tokenizers-0.20.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e005466632b1c5d2d2120f6de8aa768cc9d36cd1ab7d51d0c27a114c91a1e6ee", size = 3082688, upload-time = "2024-11-05T17:33:13.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/60/56510124933136c2e90879e1c81603cfa753ae5a87830e3ef95056b20d8f/tokenizers-0.20.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a07962340b36189b6c8feda552ea1bfeee6cf067ff922a1d7760662c2ee229e5", size = 2998924, upload-time = "2024-11-05T17:33:16.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/60/4107b618b7b9155cb34ad2e0fc90946b7e71f041b642122fb6314f660688/tokenizers-0.20.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:55046ad3dd5f2b3c67501fcc8c9cbe3e901d8355f08a3b745e9b57894855f85b", size = 8989514, upload-time = "2024-11-05T17:33:18.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/bd/48475818e614b73316baf37ac1e4e51b578bbdf58651812d7e55f43b88d8/tokenizers-0.20.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:efcf0eb939988b627558aaf2b9dc3e56d759cad2e0cfa04fcab378e4b48fc4fd", size = 9303476, upload-time = "2024-11-05T17:33:21.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/04/655b79dbcc9b3ac5f1479f18e931a344af67e5b7d3b251d2dcdcd7558592/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4", size = 3282301, upload-time = "2026-01-05T10:40:34.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/cd/e4851401f3d8f6f45d8480262ab6a5c8cb9c4302a790a35aa14eeed6d2fd/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c", size = 3161308, upload-time = "2026-01-05T10:40:40.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/6e/55553992a89982cd12d4a66dddb5e02126c58677ea3931efcbe601d419db/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195", size = 3718964, upload-time = "2026-01-05T10:40:46.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -7,12 +7,18 @@ Usage::
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
default_host = os.environ.get("PLATFORM_HOST", "127.0.0.1")
|
||||
parser = argparse.ArgumentParser(description="PraisonAI Platform Server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default=default_host,
|
||||
help="Bind host (default: 127.0.0.1, or PLATFORM_HOST env)",
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -71,3 +71,57 @@ async def require_workspace_member(
|
||||
)
|
||||
user.workspace_id = workspace_id
|
||||
return user
|
||||
|
||||
|
||||
async def require_workspace_admin(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> AuthIdentity:
|
||||
"""Require admin or owner role in the workspace."""
|
||||
return await require_workspace_member(
|
||||
workspace_id, user, session, min_role="admin"
|
||||
)
|
||||
|
||||
|
||||
async def require_workspace_owner(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> AuthIdentity:
|
||||
"""Require owner role in the workspace."""
|
||||
return await require_workspace_member(
|
||||
workspace_id, user, session, min_role="owner"
|
||||
)
|
||||
|
||||
|
||||
def ensure_resource_in_workspace(
|
||||
resource_workspace_id: str | None,
|
||||
workspace_id: str,
|
||||
*,
|
||||
label: str = "Resource",
|
||||
) -> None:
|
||||
"""Reject cross-workspace access (IDOR) with a generic 404."""
|
||||
if resource_workspace_id != workspace_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"{label} not found",
|
||||
)
|
||||
|
||||
|
||||
async def require_issue_in_workspace(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
session: AsyncSession,
|
||||
):
|
||||
"""Load an issue and verify it belongs to the URL workspace."""
|
||||
from ..db.models import Issue
|
||||
|
||||
issue = await session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Issue not found",
|
||||
)
|
||||
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
|
||||
return issue
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
|
||||
from ..schemas import ActivityLogResponse
|
||||
from ...services.activity_service import ActivityService
|
||||
|
||||
@@ -38,6 +38,7 @@ async def list_issue_activity(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = ActivityService(session)
|
||||
logs = await svc.list_for_issue(issue_id, limit=limit, offset=offset)
|
||||
return [ActivityLogResponse.model_validate(log) for log in logs]
|
||||
@@ -58,7 +58,7 @@ async def get_agent(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
agent = await svc.get(agent_id)
|
||||
agent = await svc.get(agent_id, workspace_id=workspace_id)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return AgentResponse.model_validate(agent)
|
||||
@@ -75,6 +75,7 @@ async def update_agent(
|
||||
svc = AgentService(session)
|
||||
agent = await svc.update(
|
||||
agent_id,
|
||||
workspace_id=workspace_id,
|
||||
name=body.name,
|
||||
status=body.status,
|
||||
instructions=body.instructions,
|
||||
@@ -95,6 +96,6 @@ async def delete_agent(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
deleted = await svc.delete(agent_id)
|
||||
deleted = await svc.delete(agent_id, workspace_id=workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
@@ -9,9 +9,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..schemas import DependencyCreate, DependencyResponse
|
||||
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
|
||||
from ...services.dependency_service import DependencyService
|
||||
from ..schemas import DependencyCreate, DependencyResponse
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/workspaces/{workspace_id}/issues/{issue_id}/dependencies",
|
||||
@@ -27,6 +27,10 @@ async def create_dependency(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
await require_issue_in_workspace(
|
||||
workspace_id, body.depends_on_issue_id, session
|
||||
)
|
||||
svc = DependencyService(session)
|
||||
dep = await svc.create(issue_id, body.depends_on_issue_id, body.type)
|
||||
return DependencyResponse.model_validate(dep)
|
||||
@@ -39,6 +43,7 @@ async def list_dependencies(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = DependencyService(session)
|
||||
deps = await svc.list_for_issue(issue_id)
|
||||
return [DependencyResponse.model_validate(d) for d in deps]
|
||||
@@ -52,7 +57,13 @@ async def delete_dependency(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = DependencyService(session)
|
||||
dep = await svc.get(dep_id)
|
||||
if dep is None:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
deleted = await svc.delete(dep_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
|
||||
from ..schemas import (
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
@@ -87,7 +87,7 @@ async def get_issue(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
issue = await svc.get(issue_id)
|
||||
issue = await svc.get(issue_id, workspace_id=workspace_id)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
return IssueResponse.model_validate(issue)
|
||||
@@ -104,6 +104,7 @@ async def update_issue(
|
||||
svc = IssueService(session)
|
||||
issue = await svc.update(
|
||||
issue_id,
|
||||
workspace_id=workspace_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
@@ -132,7 +133,7 @@ async def delete_issue(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
deleted = await svc.delete(issue_id)
|
||||
deleted = await svc.delete(issue_id, workspace_id=workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
@@ -148,6 +149,11 @@ async def add_comment(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.get(issue_id)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
|
||||
svc = CommentService(session)
|
||||
comment = await svc.create(
|
||||
issue_id=issue_id,
|
||||
@@ -166,6 +172,11 @@ async def list_comments(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.get(issue_id)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
|
||||
svc = CommentService(session)
|
||||
comments = await svc.list_for_issue(issue_id)
|
||||
return [CommentResponse.model_validate(c) for c in comments]
|
||||
@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..deps import (
|
||||
ensure_resource_in_workspace,
|
||||
get_db,
|
||||
require_issue_in_workspace,
|
||||
require_workspace_member,
|
||||
)
|
||||
from ..schemas import LabelCreate, LabelResponse, LabelUpdate
|
||||
from ...services.label_service import LabelService
|
||||
|
||||
@@ -48,6 +53,10 @@ async def update_label(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
label = await svc.get(label_id)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
label = await svc.update(label_id, body.name, body.color)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
@@ -62,6 +71,10 @@ async def delete_label(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
label = await svc.get(label_id)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
deleted = await svc.delete(label_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
@@ -78,7 +91,12 @@ async def add_label_to_issue(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = LabelService(session)
|
||||
label = await svc.get(label_id)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
await svc.add_to_issue(issue_id, label_id)
|
||||
|
||||
|
||||
@@ -90,7 +108,12 @@ async def remove_label_from_issue(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = LabelService(session)
|
||||
label = await svc.get(label_id)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
await svc.remove_from_issue(issue_id, label_id)
|
||||
|
||||
|
||||
@@ -101,6 +124,7 @@ async def list_issue_labels(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = LabelService(session)
|
||||
labels = await svc.list_for_issue(issue_id)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
@@ -56,7 +56,7 @@ async def get_project(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.get(project_id)
|
||||
project = await svc.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return ProjectResponse.model_validate(project)
|
||||
@@ -73,6 +73,7 @@ async def update_project(
|
||||
svc = ProjectService(session)
|
||||
project = await svc.update(
|
||||
project_id,
|
||||
workspace_id=workspace_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
@@ -92,7 +93,7 @@ async def delete_project(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
deleted = await svc.delete(project_id)
|
||||
deleted = await svc.delete(project_id, workspace_id=workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
@@ -105,4 +106,7 @@ async def project_stats(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return await svc.get_stats(project_id)
|
||||
@@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db, require_workspace_member
|
||||
from ..deps import (
|
||||
get_current_user,
|
||||
get_db,
|
||||
require_workspace_admin,
|
||||
require_workspace_member,
|
||||
require_workspace_owner,
|
||||
)
|
||||
from ..schemas import (
|
||||
MemberAdd,
|
||||
MemberResponse,
|
||||
@@ -64,7 +70,7 @@ async def get_workspace(
|
||||
async def update_workspace(
|
||||
workspace_id: str,
|
||||
body: WorkspaceUpdate,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_admin),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ws_svc = WorkspaceService(session)
|
||||
@@ -77,7 +83,7 @@ async def update_workspace(
|
||||
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_workspace(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_owner),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ws_svc = WorkspaceService(session)
|
||||
@@ -93,10 +99,16 @@ async def delete_workspace(
|
||||
async def add_member(
|
||||
workspace_id: str,
|
||||
body: MemberAdd,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_admin),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
if body.role in ("owner", "admin"):
|
||||
if not await member_svc.has_role(workspace_id, user.id, "owner"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only owners can add admin or owner roles",
|
||||
)
|
||||
member = await member_svc.add(workspace_id, body.user_id, body.role)
|
||||
return MemberResponse.model_validate(member)
|
||||
|
||||
@@ -117,10 +129,32 @@ async def update_member_role(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
body: MemberUpdate,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_admin),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
target = await member_svc.get(workspace_id, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
if user_id == user.id and body.role != target.role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot change your own role",
|
||||
)
|
||||
if body.role in ("owner", "admin") and not await member_svc.has_role(
|
||||
workspace_id, user.id, "owner"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only owners can assign admin or owner roles",
|
||||
)
|
||||
if target.role == "owner" and not await member_svc.has_role(
|
||||
workspace_id, user.id, "owner"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only owners can change an owner's role",
|
||||
)
|
||||
member = await member_svc.update_role(workspace_id, user_id, body.role)
|
||||
if member is None:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
@@ -131,10 +165,25 @@ async def update_member_role(
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_admin),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
if user_id == user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot remove yourself from the workspace",
|
||||
)
|
||||
target = await member_svc.get(workspace_id, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
if target.role == "owner" and not await member_svc.has_role(
|
||||
workspace_id, user.id, "owner"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only owners can remove an owner",
|
||||
)
|
||||
removed = await member_svc.remove(workspace_id, user_id)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
@@ -50,9 +50,16 @@ class AgentService:
|
||||
await self._session.flush()
|
||||
return agent
|
||||
|
||||
async def get(self, agent_id: str) -> Optional[Agent]:
|
||||
"""Get agent by ID."""
|
||||
return await self._session.get(Agent, agent_id)
|
||||
async def get(
|
||||
self, agent_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> Optional[Agent]:
|
||||
"""Get agent by ID, optionally scoped to a workspace."""
|
||||
agent = await self._session.get(Agent, agent_id)
|
||||
if agent is None:
|
||||
return None
|
||||
if workspace_id is not None and agent.workspace_id != workspace_id:
|
||||
return None
|
||||
return agent
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
@@ -72,6 +79,8 @@ class AgentService:
|
||||
async def update(
|
||||
self,
|
||||
agent_id: str,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
@@ -80,7 +89,7 @@ class AgentService:
|
||||
max_concurrent_tasks: Optional[int] = None,
|
||||
) -> Optional[Agent]:
|
||||
"""Update agent fields."""
|
||||
agent = await self.get(agent_id)
|
||||
agent = await self.get(agent_id, workspace_id=workspace_id)
|
||||
if agent is None:
|
||||
return None
|
||||
if name is not None:
|
||||
@@ -102,9 +111,11 @@ class AgentService:
|
||||
await self._session.flush()
|
||||
return agent
|
||||
|
||||
async def delete(self, agent_id: str) -> bool:
|
||||
async def delete(
|
||||
self, agent_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete an agent."""
|
||||
agent = await self.get(agent_id)
|
||||
agent = await self.get(agent_id, workspace_id=workspace_id)
|
||||
if agent is None:
|
||||
return False
|
||||
await self._session.delete(agent)
|
||||
|
||||
@@ -113,6 +113,10 @@ class AuthService:
|
||||
|
||||
def _issue_token(self, user: User) -> str:
|
||||
"""Issue a JWT for a user."""
|
||||
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
|
||||
raise RuntimeError(
|
||||
"Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev"
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user.id,
|
||||
|
||||
@@ -69,9 +69,16 @@ class IssueService:
|
||||
await self._session.flush()
|
||||
return issue
|
||||
|
||||
async def get(self, issue_id: str) -> Optional[Issue]:
|
||||
"""Get issue by ID."""
|
||||
return await self._session.get(Issue, issue_id)
|
||||
async def get(
|
||||
self, issue_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> Optional[Issue]:
|
||||
"""Get issue by ID, optionally scoped to a workspace."""
|
||||
issue = await self._session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return None
|
||||
if workspace_id is not None and issue.workspace_id != workspace_id:
|
||||
return None
|
||||
return issue
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
@@ -97,6 +104,8 @@ class IssueService:
|
||||
async def update(
|
||||
self,
|
||||
issue_id: str,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
@@ -106,7 +115,7 @@ class IssueService:
|
||||
project_id: Optional[str] = None,
|
||||
) -> Optional[Issue]:
|
||||
"""Update issue fields."""
|
||||
issue = await self.get(issue_id)
|
||||
issue = await self.get(issue_id, workspace_id=workspace_id)
|
||||
if issue is None:
|
||||
return None
|
||||
if title is not None:
|
||||
@@ -135,21 +144,27 @@ class IssueService:
|
||||
issue_id: str,
|
||||
assignee_type: str,
|
||||
assignee_id: str,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
) -> Optional[Issue]:
|
||||
"""Assign an issue to a member or agent."""
|
||||
if assignee_type not in VALID_ASSIGNEE_TYPES:
|
||||
raise ValueError(f"Invalid assignee_type: {assignee_type}")
|
||||
return await self.update(
|
||||
issue_id, assignee_type=assignee_type, assignee_id=assignee_id
|
||||
issue_id, workspace_id=workspace_id, assignee_type=assignee_type, assignee_id=assignee_id
|
||||
)
|
||||
|
||||
async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]:
|
||||
async def transition(
|
||||
self, issue_id: str, new_status: str, *, workspace_id: Optional[str] = None
|
||||
) -> Optional[Issue]:
|
||||
"""Transition an issue to a new status."""
|
||||
return await self.update(issue_id, status=new_status)
|
||||
return await self.update(issue_id, workspace_id=workspace_id, status=new_status)
|
||||
|
||||
async def delete(self, issue_id: str) -> bool:
|
||||
async def delete(
|
||||
self, issue_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete an issue."""
|
||||
issue = await self.get(issue_id)
|
||||
issue = await self.get(issue_id, workspace_id=workspace_id)
|
||||
if issue is None:
|
||||
return False
|
||||
await self._session.delete(issue)
|
||||
|
||||
@@ -44,9 +44,16 @@ class ProjectService:
|
||||
await self._session.flush()
|
||||
return project
|
||||
|
||||
async def get(self, project_id: str) -> Optional[Project]:
|
||||
"""Get project by ID."""
|
||||
return await self._session.get(Project, project_id)
|
||||
async def get(
|
||||
self, project_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> Optional[Project]:
|
||||
"""Get project by ID, optionally scoped to a workspace."""
|
||||
project = await self._session.get(Project, project_id)
|
||||
if project is None:
|
||||
return None
|
||||
if workspace_id is not None and project.workspace_id != workspace_id:
|
||||
return None
|
||||
return project
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
@@ -62,6 +69,8 @@ class ProjectService:
|
||||
async def update(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
@@ -69,7 +78,7 @@ class ProjectService:
|
||||
lead_id: Optional[str] = None,
|
||||
) -> Optional[Project]:
|
||||
"""Update project fields."""
|
||||
project = await self.get(project_id)
|
||||
project = await self.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
return None
|
||||
if title is not None:
|
||||
@@ -85,9 +94,11 @@ class ProjectService:
|
||||
await self._session.flush()
|
||||
return project
|
||||
|
||||
async def delete(self, project_id: str) -> bool:
|
||||
async def delete(
|
||||
self, project_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete a project."""
|
||||
project = await self.get(project_id)
|
||||
project = await self.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
return False
|
||||
await self._session.delete(project)
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import Member, Workspace
|
||||
@@ -98,10 +98,13 @@ class WorkspaceService:
|
||||
return ws
|
||||
|
||||
async def delete(self, workspace_id: str) -> bool:
|
||||
"""Delete a workspace."""
|
||||
"""Delete a workspace and its memberships."""
|
||||
ws = await self.get(workspace_id)
|
||||
if ws is None:
|
||||
return False
|
||||
await self._session.execute(
|
||||
delete(Member).where(Member.workspace_id == workspace_id)
|
||||
)
|
||||
await self._session.delete(ws)
|
||||
await self._session.flush()
|
||||
return True
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Cross-workspace IDOR guards for platform resources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from praisonai_platform.api.deps import ensure_resource_in_workspace
|
||||
|
||||
|
||||
def test_project_workspace_mismatch():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ensure_resource_in_workspace("ws-other", "ws-mine", label="Project")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_label_workspace_mismatch():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ensure_resource_in_workspace("ws-other", "ws-mine", label="Label")
|
||||
assert exc.value.status_code == 404
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Service-layer workspace scoping for issues and projects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_platform.services.auth_service import AuthService
|
||||
from praisonai_platform.services.issue_service import IssueService
|
||||
from praisonai_platform.services.project_service import ProjectService
|
||||
from praisonai_platform.services.workspace_service import WorkspaceService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issue_get_rejects_wrong_workspace(session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_a = await WorkspaceService(session).create("A", user.id)
|
||||
ws_b = await WorkspaceService(session).create("B", user.id)
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(
|
||||
workspace_id=ws_a.id,
|
||||
title="secret",
|
||||
creator_id=user.id,
|
||||
)
|
||||
assert await issue_svc.get(issue.id, workspace_id=ws_a.id) is not None
|
||||
assert await issue_svc.get(issue.id, workspace_id=ws_b.id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_delete_scoped_to_workspace(session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_a = await WorkspaceService(session).create("PA", user.id)
|
||||
ws_b = await WorkspaceService(session).create("PB", user.id)
|
||||
proj_svc = ProjectService(session)
|
||||
project = await proj_svc.create(workspace_id=ws_a.id, title="p1")
|
||||
assert await proj_svc.delete(project.id, workspace_id=ws_b.id) is False
|
||||
assert await proj_svc.delete(project.id, workspace_id=ws_a.id) is True
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Workspace member RBAC and cross-workspace IDOR guards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from praisonai_platform.api.deps import ensure_resource_in_workspace
|
||||
|
||||
|
||||
def test_ensure_resource_in_workspace_rejects_mismatch():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ensure_resource_in_workspace("ws-a", "ws-b", label="Issue")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_ensure_resource_in_workspace_allows_match():
|
||||
ensure_resource_in_workspace("ws-a", "ws-a", label="Issue")
|
||||
Generated
+1
-1
@@ -1208,7 +1208,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-08",
|
||||
"lastUpdated": "2026-05-19",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
@@ -1725,6 +1725,7 @@
|
||||
"planning": [],
|
||||
"plugins": [
|
||||
"PluginRegistry",
|
||||
"ToolDefinition",
|
||||
"disable_plugins",
|
||||
"enable_plugins",
|
||||
"get_plugin_manager",
|
||||
@@ -1749,7 +1750,6 @@
|
||||
"tools": [
|
||||
"FunctionTool",
|
||||
"Tool",
|
||||
"ToolDefinition",
|
||||
"ToolRegistry",
|
||||
"ToolResult"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-08",
|
||||
"lastUpdated": "2026-05-19",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-08
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-19
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
+39
-2
@@ -294,8 +294,35 @@ def search(query: str) -> str:
|
||||
|
||||
@tool
|
||||
def calculate(expression: str) -> float:
|
||||
"""Evaluate a math expression."""
|
||||
return eval(expression)
|
||||
"""Safely evaluate a numeric arithmetic expression."""
|
||||
import ast
|
||||
import operator
|
||||
|
||||
# Define allowed operations
|
||||
_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
}
|
||||
|
||||
def _safe_eval(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return node.value
|
||||
elif isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
|
||||
elif isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.operand))
|
||||
else:
|
||||
raise ValueError("Unsupported expression")
|
||||
|
||||
try:
|
||||
return _safe_eval(ast.parse(expression, mode="eval").body)
|
||||
except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError):
|
||||
raise ValueError("Invalid arithmetic expression")
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
@@ -304,6 +331,7 @@ agent = Agent(
|
||||
agent.start("Search for AI news and calculate 15*4")
|
||||
```
|
||||
|
||||
> ⚠️ **Security Note:** Never use `eval()`, `exec()`, or `subprocess` in tool functions that process LLM-generated or user-supplied input. Always validate and sanitize inputs to prevent code injection attacks.
|
||||
> 📖 [Full tools docs](https://docs.praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools
|
||||
|
||||
### 5. Persistence (Databases)
|
||||
@@ -330,6 +358,15 @@ pip install "praisonai[claw]"
|
||||
praisonai claw
|
||||
```
|
||||
|
||||
#### Required Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and configure the following variables:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls |
|
||||
| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com |
|
||||
|
||||
Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI.
|
||||
|
||||
> 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
|
||||
|
||||
@@ -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.6.37.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.37.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.41.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.41.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -36,10 +36,34 @@ __all__ = [
|
||||
_telemetry_lock = threading.Lock()
|
||||
_telemetry_initialized = False
|
||||
|
||||
def _get_telemetry_defaults() -> dict[str, str]:
|
||||
"""Get telemetry environment defaults without mutating os.environ.
|
||||
|
||||
Returns:
|
||||
Dict of environment variable defaults for telemetry
|
||||
"""
|
||||
import os
|
||||
defaults = {}
|
||||
|
||||
# Respect any value the user already set
|
||||
if "OTEL_SDK_DISABLED" not in os.environ:
|
||||
langfuse_configured = bool(
|
||||
os.getenv("LANGFUSE_PUBLIC_KEY")
|
||||
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
|
||||
)
|
||||
defaults["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
|
||||
|
||||
if "EC_TELEMETRY" not in os.environ:
|
||||
defaults["EC_TELEMETRY"] = "false"
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
def _ensure_telemetry_defaults() -> None:
|
||||
"""Apply telemetry env defaults exactly once, on first observability use.
|
||||
|
||||
Thread-safe implementation using double-checked locking pattern.
|
||||
DEPRECATED: Use _apply_telemetry_defaults() with explicit config instead.
|
||||
"""
|
||||
global _telemetry_initialized
|
||||
if _telemetry_initialized: # fast path, OK without lock
|
||||
@@ -47,18 +71,21 @@ def _ensure_telemetry_defaults() -> None:
|
||||
with _telemetry_lock:
|
||||
if _telemetry_initialized:
|
||||
return
|
||||
import os
|
||||
# Respect any value the user already set
|
||||
if "OTEL_SDK_DISABLED" not in os.environ:
|
||||
langfuse_configured = bool(
|
||||
os.getenv("LANGFUSE_PUBLIC_KEY")
|
||||
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
|
||||
)
|
||||
os.environ["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
|
||||
os.environ.setdefault("EC_TELEMETRY", "false") # respect user overrides
|
||||
_apply_telemetry_defaults(_get_telemetry_defaults())
|
||||
_telemetry_initialized = True
|
||||
|
||||
|
||||
def _apply_telemetry_defaults(env_vars: dict[str, str]) -> None:
|
||||
"""Apply telemetry defaults to os.environ.
|
||||
|
||||
Args:
|
||||
env_vars: Dict of environment variables to set
|
||||
"""
|
||||
import os
|
||||
for key, value in env_vars.items():
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
# Lazy loading for heavy imports
|
||||
def __getattr__(name):
|
||||
"""Lazy load heavy modules to improve import time."""
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Generic plugin registry implementation for PraisonAI.
|
||||
|
||||
This module provides a unified registry pattern to replace the divergent singleton
|
||||
implementations across the codebase. It supports both built-in and entry-point
|
||||
based plugin discovery with thread-safe registration and dependency injection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from importlib.metadata import entry_points
|
||||
from typing import Callable, Dict, Generic, Optional, Type, TypeVar
|
||||
import logging
|
||||
|
||||
T = TypeVar("T")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginRegistry(Generic[T]):
|
||||
"""Generic plugin registry: builtins + entry points + runtime register().
|
||||
|
||||
This replaces the singleton pattern with dependency injection while maintaining
|
||||
the same functionality. Supports:
|
||||
- Built-in plugins with lazy loading
|
||||
- Entry points discovery
|
||||
- Runtime registration
|
||||
- Alias support for alternative names
|
||||
- Thread-safe operations
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
entry_point_group: str,
|
||||
builtins: Optional[Dict[str, Callable[[], Type[T]]]] = None
|
||||
) -> None:
|
||||
"""Initialize the registry.
|
||||
|
||||
Args:
|
||||
entry_point_group: Entry points group name for plugin discovery
|
||||
builtins: Dict of name -> loader function for built-in plugins
|
||||
"""
|
||||
self._entry_point_group = entry_point_group
|
||||
self._items: Dict[str, Type[T]] = {}
|
||||
self._aliases: Dict[str, str] = {} # alias -> canonical name
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Load built-in plugins with error handling
|
||||
if builtins:
|
||||
for name, loader in builtins.items():
|
||||
try:
|
||||
self._items[name] = loader()
|
||||
except ImportError:
|
||||
# Built-in plugin dependencies not available, skip
|
||||
pass
|
||||
except Exception:
|
||||
# Log other errors but don't crash initialization
|
||||
logger.warning("Failed to load built-in plugin %r", name, exc_info=True)
|
||||
|
||||
self._load_entry_points()
|
||||
|
||||
def _load_entry_points(self) -> None:
|
||||
"""Load plugins from entry points."""
|
||||
try:
|
||||
for ep in entry_points(group=self._entry_point_group):
|
||||
try:
|
||||
plugin_class = ep.load()
|
||||
with self._lock:
|
||||
self._items[ep.name] = plugin_class
|
||||
except Exception:
|
||||
# Do not break plugin dispatch because one plugin is broken
|
||||
logger.warning(
|
||||
"Failed to load plugin %r from entry point",
|
||||
ep.name,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
# entry_points() might not be available in older Python versions
|
||||
logger.debug("Entry points not available for group %s", self._entry_point_group)
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
cls: Type[T],
|
||||
*,
|
||||
aliases: Optional[list[str]] = None
|
||||
) -> None:
|
||||
"""Register a plugin at runtime.
|
||||
|
||||
Args:
|
||||
name: Plugin name
|
||||
cls: Plugin class
|
||||
aliases: Optional list of alias names for this plugin
|
||||
"""
|
||||
with self._lock:
|
||||
canonical_name = name.lower()
|
||||
self._items[canonical_name] = cls
|
||||
|
||||
# Register aliases
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
normalized_alias = alias.lower()
|
||||
self._aliases[normalized_alias] = canonical_name
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""Unregister a plugin.
|
||||
|
||||
Args:
|
||||
name: Plugin name
|
||||
|
||||
Returns:
|
||||
True if plugin was found and removed, False otherwise
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Check if it's an alias
|
||||
if normalized_name in self._aliases:
|
||||
del self._aliases[normalized_name]
|
||||
return True
|
||||
|
||||
# Check if it's a canonical name
|
||||
if normalized_name in self._items:
|
||||
# Remove all aliases pointing to this plugin
|
||||
aliases_to_remove = [
|
||||
alias for alias, canonical in self._aliases.items()
|
||||
if canonical == normalized_name
|
||||
]
|
||||
for alias in aliases_to_remove:
|
||||
del self._aliases[alias]
|
||||
|
||||
del self._items[normalized_name]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def resolve(self, name: str) -> Type[T]:
|
||||
"""Resolve a plugin name to its class.
|
||||
|
||||
Args:
|
||||
name: Plugin name
|
||||
|
||||
Returns:
|
||||
Plugin class
|
||||
|
||||
Raises:
|
||||
ValueError: If plugin is not found
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Resolve alias to canonical name
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
cls = self._items.get(canonical_name)
|
||||
|
||||
# Capture available plugins snapshot while holding lock
|
||||
# to avoid race condition between check and error message
|
||||
if cls is None:
|
||||
available_plugins = sorted(self._items.keys())
|
||||
available_aliases = sorted(self._aliases.keys())
|
||||
available_snapshot = available_plugins + available_aliases
|
||||
else:
|
||||
available_snapshot = None
|
||||
|
||||
if cls is None:
|
||||
raise ValueError(
|
||||
f"Unknown {self._entry_point_group} plugin: {name!r}. "
|
||||
f"Available: {available_snapshot}"
|
||||
)
|
||||
return cls
|
||||
|
||||
def create(self, name: str, *args, **kwargs) -> T:
|
||||
"""Create an instance of the specified plugin.
|
||||
|
||||
Args:
|
||||
name: Plugin name
|
||||
*args, **kwargs: Arguments to pass to plugin constructor
|
||||
|
||||
Returns:
|
||||
Plugin instance
|
||||
|
||||
Raises:
|
||||
ValueError: If plugin is not found
|
||||
"""
|
||||
cls = self.resolve(name)
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
def list_names(self) -> list[str]:
|
||||
"""List all registered plugin names.
|
||||
|
||||
Returns:
|
||||
Sorted list of plugin names
|
||||
"""
|
||||
with self._lock:
|
||||
return sorted(self._items.keys())
|
||||
|
||||
def is_available(self, name: str) -> bool:
|
||||
"""Check if a plugin is available.
|
||||
|
||||
Args:
|
||||
name: Plugin name
|
||||
|
||||
Returns:
|
||||
True if plugin exists and can be created
|
||||
"""
|
||||
try:
|
||||
self.resolve(name)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def list_aliases(self) -> Dict[str, str]:
|
||||
"""List all aliases and their target plugins.
|
||||
|
||||
Returns:
|
||||
Dict mapping alias -> canonical name
|
||||
"""
|
||||
with self._lock:
|
||||
return dict(self._aliases)
|
||||
|
||||
def list_all_names(self) -> list[str]:
|
||||
"""List all names including aliases.
|
||||
|
||||
Returns:
|
||||
Sorted list of all registered names and aliases
|
||||
"""
|
||||
with self._lock:
|
||||
return sorted(list(self._items.keys()) + list(self._aliases.keys()))
|
||||
@@ -19,7 +19,7 @@ import difflib
|
||||
|
||||
# Import new architecture components
|
||||
from .framework_adapters.base import FrameworkAdapter
|
||||
from .framework_adapters.registry import FrameworkAdapterRegistry
|
||||
from .framework_adapters.registry import FrameworkAdapterRegistry, get_default_registry
|
||||
from .tool_registry import ToolRegistry
|
||||
|
||||
# Import availability flags
|
||||
@@ -43,10 +43,12 @@ except ImportError:
|
||||
# Check for additional framework availability
|
||||
AG2_AVAILABLE = False
|
||||
PRAISONAI_AVAILABLE = False
|
||||
AGENTOPS_AVAILABLE = False
|
||||
try:
|
||||
import importlib.util
|
||||
AG2_AVAILABLE = importlib.util.find_spec("ag2") is not None
|
||||
PRAISONAI_AVAILABLE = importlib.util.find_spec("praisonaiagents") is not None
|
||||
AGENTOPS_AVAILABLE = importlib.util.find_spec("agentops") is not None
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -179,7 +181,7 @@ def _resolve_yaml_cli_backend(cli_backend_config, logger):
|
||||
|
||||
|
||||
class AgentsGenerator:
|
||||
def __init__(self, agent_file, framework, config_list, log_level=None, agent_callback=None, task_callback=None, agent_yaml=None, tools=None, cli_config=None):
|
||||
def __init__(self, agent_file, framework, config_list, log_level=None, agent_callback=None, task_callback=None, agent_yaml=None, tools=None, cli_config=None, adapter_registry=None):
|
||||
"""
|
||||
Initialize the AgentsGenerator object.
|
||||
|
||||
@@ -193,6 +195,7 @@ class AgentsGenerator:
|
||||
agent_yaml (str, optional): The content of the YAML file. Defaults to None.
|
||||
tools (dict, optional): A dictionary containing the tools to be used for the agents. Defaults to None.
|
||||
cli_config (dict, optional): CLI configuration to override YAML settings. Defaults to None.
|
||||
adapter_registry (FrameworkAdapterRegistry, optional): Registry for framework adapters. Defaults to process default.
|
||||
|
||||
Attributes:
|
||||
agent_file (str): The path to the agent file.
|
||||
@@ -233,6 +236,10 @@ class AgentsGenerator:
|
||||
self.tool_registry = ToolRegistry()
|
||||
self.tool_registry.register_builtin_autogen_adapters()
|
||||
|
||||
# DI-friendly: tests/multi-tenant runtimes pass their own registry;
|
||||
# CLI users get the process default.
|
||||
self._adapter_registry = adapter_registry or get_default_registry()
|
||||
|
||||
# Get framework adapter (availability already validated at CLI entry)
|
||||
self.framework_adapter = self._get_framework_adapter(framework)
|
||||
|
||||
@@ -249,8 +256,7 @@ class AgentsGenerator:
|
||||
Raises:
|
||||
ValueError: If framework is not supported
|
||||
"""
|
||||
adapter_registry = FrameworkAdapterRegistry.get_instance()
|
||||
return adapter_registry.create(framework)
|
||||
return self._adapter_registry.create(framework)
|
||||
|
||||
def _merge_cli_config(self, config, cli_config):
|
||||
"""
|
||||
@@ -375,20 +381,19 @@ class AgentsGenerator:
|
||||
|
||||
def load_tools_from_module(self, module_path):
|
||||
"""
|
||||
Loads tools from a specified module path.
|
||||
Load function tools from a user-supplied module (gated by PRAISONAI_ALLOW_LOCAL_TOOLS).
|
||||
|
||||
Parameters:
|
||||
module_path (str): The path to the module containing the tools.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the names of the tools as keys and the corresponding functions or objects as values.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the specified module path does not exist.
|
||||
Returns an empty dict if the module cannot be loaded (path missing, loading blocked by PRAISONAI_ALLOW_LOCAL_TOOLS, or any other load error).
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location("tools_module", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
from ._safe_loader import load_user_module
|
||||
module = load_user_module(module_path, name="tools_module")
|
||||
if module is None:
|
||||
return {}
|
||||
return {name: obj for name, obj in inspect.getmembers(module, self.is_function_or_decorated)}
|
||||
|
||||
def _extract_tool_classes(self, module):
|
||||
@@ -411,21 +416,13 @@ class AgentsGenerator:
|
||||
|
||||
def load_tools_from_module_class(self, module_path):
|
||||
"""
|
||||
Loads tools from a specified module path containing classes that inherit from BaseTool
|
||||
or are part of langchain_community.tools package.
|
||||
Load BaseTool / langchain tool classes from a user-supplied module (gated by PRAISONAI_ALLOW_LOCAL_TOOLS).
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location("tools_module", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
return {name: obj() for name, obj in inspect.getmembers(module,
|
||||
lambda x: inspect.isclass(x) and (
|
||||
x.__module__.startswith('langchain_community.tools') or
|
||||
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
|
||||
) and x is not BaseTool)}
|
||||
except ImportError as e:
|
||||
self.logger.warning(f"Error loading tools from {module_path}: {e}")
|
||||
from ._safe_loader import load_user_module
|
||||
module = load_user_module(module_path, name="tools_module")
|
||||
if module is None:
|
||||
return {}
|
||||
return self._extract_tool_classes(module)
|
||||
|
||||
def load_tools_from_package(self, package_path):
|
||||
"""
|
||||
@@ -451,36 +448,6 @@ class AgentsGenerator:
|
||||
tools_dict[name] = obj
|
||||
return tools_dict
|
||||
|
||||
def load_tools_from_tools_py(self):
|
||||
"""
|
||||
Imports and returns all contents from tools.py file.
|
||||
Uses the tool registry instead of global namespace pollution.
|
||||
|
||||
Returns:
|
||||
list: A list of callable functions with proper formatting
|
||||
"""
|
||||
tools_list = []
|
||||
try:
|
||||
# Try to import tools.py from current directory using safe loading
|
||||
from ._safe_loader import load_user_module
|
||||
module = load_user_module("tools.py", name="tools")
|
||||
if module is None:
|
||||
self.logger.debug("tools.py not found or local tools loading disabled")
|
||||
return tools_list
|
||||
|
||||
# Register functions in the tool registry instead of globals()
|
||||
registered_tools = self.tool_registry.register_from_module(module)
|
||||
tools_list = [self.tool_registry.get_function(name) for name in registered_tools]
|
||||
|
||||
self.logger.debug(f"Loaded {len(tools_list)} tool functions from tools.py")
|
||||
self.logger.debug(f"Registered tools: {registered_tools}")
|
||||
|
||||
except FileNotFoundError:
|
||||
self.logger.debug("tools.py not found in current directory")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error loading tools from tools.py: {e}")
|
||||
|
||||
return tools_list
|
||||
|
||||
def generate_crew_and_kickoff(self):
|
||||
"""
|
||||
@@ -590,12 +557,10 @@ class AgentsGenerator:
|
||||
tools_py_path = os.path.join(root_directory, 'tools.py')
|
||||
tools_dir_path = Path(root_directory) / 'tools'
|
||||
|
||||
# Use consolidated ToolResolver for tools.py loading
|
||||
tools_dict.update(self.tool_resolver.get_local_tool_classes())
|
||||
if os.path.isfile(tools_py_path):
|
||||
from ._safe_loader import load_user_module
|
||||
module = load_user_module(tools_py_path, name="tools_module")
|
||||
if module is not None:
|
||||
tools_dict.update(self._extract_tool_classes(module))
|
||||
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
|
||||
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
|
||||
elif tools_dir_path.is_dir():
|
||||
from ._safe_loader import load_user_module
|
||||
for py_file in tools_dir_path.glob("*.py"):
|
||||
@@ -784,7 +749,11 @@ class AgentsGenerator:
|
||||
result = "### Output ###\n" + response[-1].summary if hasattr(response[-1], 'summary') else ""
|
||||
|
||||
if AGENTOPS_AVAILABLE:
|
||||
agentops.end_session("Success")
|
||||
import agentops
|
||||
try:
|
||||
agentops.end_session("Success")
|
||||
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
|
||||
self.logger.warning(f"agentops.end_session failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
@@ -1171,7 +1140,11 @@ class AgentsGenerator:
|
||||
result = f"### Task Output ###\n{response}"
|
||||
|
||||
if AGENTOPS_AVAILABLE:
|
||||
agentops.end_session("Success")
|
||||
import agentops
|
||||
try:
|
||||
agentops.end_session("Success")
|
||||
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
|
||||
self.logger.warning(f"agentops.end_session failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
@@ -1190,8 +1163,8 @@ class AgentsGenerator:
|
||||
|
||||
# Use existing tool resolver instance
|
||||
|
||||
# Load tools from local tools.py (backward compat)
|
||||
tools_list = self.load_tools_from_tools_py()
|
||||
# Load tools from local tools.py (backward compat) - use consolidated ToolResolver
|
||||
tools_list = self.tool_resolver.get_local_callables()
|
||||
self.logger.debug(f"Loaded tools from tools.py: {tools_list}")
|
||||
|
||||
# Initialize InteractiveRuntime for ACP/LSP if enabled globally
|
||||
@@ -1199,9 +1172,9 @@ class AgentsGenerator:
|
||||
acp_enabled = global_config.get('acp', False)
|
||||
lsp_enabled = global_config.get('lsp', False)
|
||||
interactive_runtime = None
|
||||
interactive_loop = None
|
||||
|
||||
if acp_enabled or lsp_enabled:
|
||||
runtime_started = False
|
||||
try:
|
||||
import asyncio
|
||||
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
|
||||
@@ -1217,27 +1190,34 @@ class AgentsGenerator:
|
||||
interactive_runtime = InteractiveRuntime(runtime_config)
|
||||
self.logger.info(f"Starting InteractiveRuntime (ACP: {acp_enabled}, LSP: {lsp_enabled})")
|
||||
|
||||
# Create a scoped event loop instead of modifying process globals
|
||||
interactive_loop = asyncio.new_event_loop()
|
||||
try:
|
||||
interactive_loop.run_until_complete(interactive_runtime.start())
|
||||
|
||||
centric_tools = create_agent_centric_tools(interactive_runtime)
|
||||
self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
|
||||
tools_list.extend(centric_tools)
|
||||
|
||||
finally:
|
||||
try:
|
||||
interactive_loop.run_until_complete(interactive_runtime.stop())
|
||||
except Exception as stop_error:
|
||||
self.logger.warning(f"Error stopping InteractiveRuntime: {stop_error}")
|
||||
finally:
|
||||
interactive_loop.close()
|
||||
# Runs on the persistent background loop; safe from sync and async callers.
|
||||
# run_sync raises RuntimeError early if called from inside a running loop
|
||||
# so the bug is loud instead of a deadlock.
|
||||
from ._async_bridge import run_sync
|
||||
run_sync(interactive_runtime.start())
|
||||
runtime_started = True
|
||||
|
||||
centric_tools = create_agent_centric_tools(interactive_runtime)
|
||||
self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
|
||||
tools_list.extend(centric_tools)
|
||||
|
||||
except ImportError as e:
|
||||
self.logger.warning(f"Failed to load InteractiveRuntime components: {e}")
|
||||
interactive_runtime = None
|
||||
except RuntimeError:
|
||||
# Don't swallow RuntimeError from run_sync - preserve fail-fast semantics
|
||||
raise
|
||||
except Exception as e:
|
||||
if runtime_started and interactive_runtime is not None:
|
||||
try:
|
||||
from ._async_bridge import run_sync
|
||||
run_sync(interactive_runtime.stop())
|
||||
except Exception as stop_error:
|
||||
self.logger.error(
|
||||
f"Error stopping partially started InteractiveRuntime: {stop_error}"
|
||||
)
|
||||
self.logger.error(f"Error starting InteractiveRuntime: {e}")
|
||||
interactive_runtime = None
|
||||
|
||||
# Create agents from config
|
||||
for role, details in config['roles'].items():
|
||||
@@ -1471,14 +1451,19 @@ class AgentsGenerator:
|
||||
self.logger.debug(f"Result: {response}")
|
||||
result = response if response else ""
|
||||
finally:
|
||||
if interactive_runtime and interactive_loop:
|
||||
if interactive_runtime:
|
||||
try:
|
||||
self.logger.info("Stopping InteractiveRuntime...")
|
||||
interactive_loop.run_until_complete(interactive_runtime.stop())
|
||||
from ._async_bridge import run_sync
|
||||
run_sync(interactive_runtime.stop())
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping InteractiveRuntime: {e}")
|
||||
|
||||
if AGENTOPS_AVAILABLE:
|
||||
agentops.end_session("Success")
|
||||
import agentops
|
||||
try:
|
||||
agentops.end_session("Success")
|
||||
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
|
||||
self.logger.warning(f"agentops.end_session failed: {e}")
|
||||
|
||||
return result
|
||||
@@ -29,14 +29,26 @@ logger = logging.getLogger(__name__)
|
||||
# Authentication
|
||||
import os
|
||||
CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')
|
||||
_CALL_AUTH_DISABLED = os.getenv('PRAISONAI_CALL_AUTH', '').lower() == 'disabled'
|
||||
|
||||
|
||||
async def verify_token(
|
||||
request: Request,
|
||||
authorization: Optional[str] = Header(None)
|
||||
) -> None:
|
||||
"""Verify API token for authentication."""
|
||||
if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN:
|
||||
return # No authentication if FastAPI unavailable or no token set
|
||||
if not FASTAPI_AVAILABLE:
|
||||
return
|
||||
if _CALL_AUTH_DISABLED:
|
||||
return
|
||||
if not CALL_SERVER_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"CALL_SERVER_TOKEN is not configured. Set CALL_SERVER_TOKEN or "
|
||||
"PRAISONAI_CALL_AUTH=disabled to run without authentication."
|
||||
),
|
||||
)
|
||||
|
||||
token = None
|
||||
|
||||
|
||||
@@ -250,10 +250,21 @@ class AsyncAgentScheduler:
|
||||
self.is_running = False
|
||||
|
||||
logger.info("Async agent scheduler stopped")
|
||||
logger.info(f"Execution stats - Total: {self._execution_count}, Success: {self._success_count}, Failed: {self._failure_count}")
|
||||
|
||||
# Log final stats with consistent snapshot
|
||||
if self._stats_lock is not None:
|
||||
async with self._stats_lock:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
else:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
logger.info(f"Execution stats - Total: {total}, Success: {ok}, Failed: {fail}")
|
||||
return True
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current execution statistics (synchronous, best-effort).
|
||||
|
||||
@@ -264,12 +275,26 @@ class AsyncAgentScheduler:
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
# Primitives are created lazily in _ensure_async_primitives(); if not yet
|
||||
# initialized there are no concurrent writers, so a direct read is safe.
|
||||
if self._stats_lock is not None:
|
||||
async with self._stats_lock:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
running = self.is_running
|
||||
else:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
running = self.is_running
|
||||
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"total_executions": self._execution_count,
|
||||
"successful_executions": self._success_count,
|
||||
"failed_executions": self._failure_count,
|
||||
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0
|
||||
"is_running": running,
|
||||
"total_executions": total,
|
||||
"successful_executions": ok,
|
||||
"failed_executions": fail,
|
||||
"success_rate": (ok / total * 100) if total > 0 else 0
|
||||
}
|
||||
|
||||
async def get_stats_async(self) -> Dict[str, Any]:
|
||||
@@ -327,11 +352,8 @@ class AsyncAgentScheduler:
|
||||
|
||||
async def _execute_with_retry(self, max_retries: int):
|
||||
"""Execute agent with retry logic."""
|
||||
# Ensure async primitives are available
|
||||
if self._stats_lock is None:
|
||||
self._ensure_async_primitives()
|
||||
|
||||
# Atomically increment execution count
|
||||
self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop
|
||||
|
||||
async with self._stats_lock:
|
||||
self._execution_count += 1
|
||||
|
||||
@@ -344,7 +366,6 @@ class AsyncAgentScheduler:
|
||||
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
|
||||
logger.info(f"Result: {result}")
|
||||
|
||||
# Atomically increment success count
|
||||
async with self._stats_lock:
|
||||
self._success_count += 1
|
||||
safe_call(self.on_success, result)
|
||||
@@ -359,7 +380,6 @@ class AsyncAgentScheduler:
|
||||
logger.info(f"Waiting {wait_time}s before async retry...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# Atomically increment failure count
|
||||
async with self._stats_lock:
|
||||
self._failure_count += 1
|
||||
logger.error(f"Async agent execution failed after {max_retries} attempts")
|
||||
|
||||
@@ -15,9 +15,8 @@ from typing import Any, Dict, List, Optional, Type, TypeVar
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
from rich import print
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from rich import print
|
||||
from praisonai._logging import get_logger
|
||||
|
||||
# Type variable for Pydantic models
|
||||
@@ -59,10 +58,6 @@ def _load_optional(key: str, loader):
|
||||
return _optional_cache[key]
|
||||
|
||||
|
||||
# Bounded LRU cache for OpenAI clients (one per (api_key, base_url) tuple)
|
||||
_OPENAI_CLIENT_CACHE_MAX = 8
|
||||
_openai_clients: "OrderedDict[tuple, object]" = OrderedDict()
|
||||
_openai_clients_lock = threading.Lock()
|
||||
|
||||
|
||||
# --- CrewAI lazy loading ---
|
||||
@@ -199,38 +194,6 @@ def _check_openai_available() -> bool:
|
||||
return result is not None
|
||||
|
||||
|
||||
def _get_openai_client(api_key: str = None, base_url: str = None):
|
||||
"""Lazy load OpenAI client with bounded LRU cache (thread-safe, multi-tenant).
|
||||
|
||||
Multi-tenant safe: each (api_key, base_url) tuple gets its own cached client.
|
||||
Bounded by _OPENAI_CLIENT_CACHE_MAX with proper LRU eviction.
|
||||
"""
|
||||
key = (api_key or os.environ.get("OPENAI_API_KEY"), base_url)
|
||||
|
||||
with _openai_clients_lock:
|
||||
# Check if client exists and update LRU position
|
||||
client = _openai_clients.get(key)
|
||||
if client is not None:
|
||||
_openai_clients.move_to_end(key)
|
||||
return client
|
||||
|
||||
# Create new client
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as e:
|
||||
raise ImportError("Install with: pip install openai") from e
|
||||
client = OpenAI(api_key=key[0], base_url=key[1])
|
||||
_openai_clients[key] = client
|
||||
|
||||
# Bound the cache; close the LRU victim
|
||||
if len(_openai_clients) > _OPENAI_CLIENT_CACHE_MAX:
|
||||
_, victim = _openai_clients.popitem(last=False)
|
||||
try:
|
||||
victim.close()
|
||||
except Exception:
|
||||
pass # Best-effort cleanup
|
||||
|
||||
return client
|
||||
|
||||
|
||||
# Use namespaced logger; root logger is configured only by the CLI
|
||||
@@ -478,6 +441,38 @@ class BaseAutoGenerator:
|
||||
'api_key': ep.api_key
|
||||
}
|
||||
]
|
||||
self._openai_client = None # lazy, per-instance
|
||||
self._openai_client_lock = threading.Lock()
|
||||
|
||||
def _get_openai_client(self):
|
||||
"""Get or create the OpenAI client for this instance."""
|
||||
if self._openai_client is None:
|
||||
with self._openai_client_lock:
|
||||
if self._openai_client is None:
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as e:
|
||||
raise ImportError("Install with: pip install openai") from e
|
||||
cfg = self.config_list[0]
|
||||
self._openai_client = OpenAI(
|
||||
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
|
||||
base_url=cfg.get("base_url"),
|
||||
)
|
||||
return self._openai_client
|
||||
|
||||
def close(self):
|
||||
"""Close the OpenAI client if it exists."""
|
||||
if not hasattr(self, '_openai_client_lock'):
|
||||
return # Object was never fully initialized
|
||||
with self._openai_client_lock:
|
||||
client = getattr(self, '_openai_client', None)
|
||||
self._openai_client = None
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
def __del__(self):
|
||||
"""Best-effort cleanup, but the canonical path is explicit close()."""
|
||||
self.close()
|
||||
|
||||
def _structured_completion(self, response_model: Type[T], messages: List[Dict], **kwargs) -> T:
|
||||
"""
|
||||
@@ -514,10 +509,7 @@ class BaseAutoGenerator:
|
||||
|
||||
# Fallback to OpenAI SDK (uses beta.chat.completions.parse)
|
||||
if _check_openai_available():
|
||||
client = _get_openai_client(
|
||||
api_key=self.config_list[0].get('api_key'),
|
||||
base_url=self.config_list[0].get('base_url')
|
||||
)
|
||||
client = self._get_openai_client()
|
||||
response = client.beta.chat.completions.parse(
|
||||
model=model_name,
|
||||
messages=messages,
|
||||
@@ -628,7 +620,8 @@ class AutoGenerator(BaseAutoGenerator):
|
||||
|
||||
def __init__(self, topic="Movie Story writing about AI", agent_file="test.yaml",
|
||||
framework="crewai", config_list: Optional[List[Dict]] = None,
|
||||
pattern: str = "sequential", single_agent: bool = False):
|
||||
pattern: str = "sequential", single_agent: bool = False,
|
||||
adapter_registry=None):
|
||||
"""
|
||||
Initialize the AutoGenerator class with the specified topic, agent file, and framework.
|
||||
|
||||
@@ -646,15 +639,15 @@ class AutoGenerator(BaseAutoGenerator):
|
||||
super().__init__(config_list=config_list)
|
||||
|
||||
# Validate framework availability using adapter registry
|
||||
from .framework_adapters.registry import FrameworkAdapterRegistry
|
||||
from .framework_adapters.registry import get_default_registry
|
||||
|
||||
registry = FrameworkAdapterRegistry.get_instance()
|
||||
self._adapter_registry = adapter_registry or get_default_registry()
|
||||
try:
|
||||
adapter = registry.create(framework)
|
||||
adapter = self._adapter_registry.create(framework)
|
||||
except ValueError as e:
|
||||
raise ImportError(
|
||||
f"Unknown framework '{framework}'. Available frameworks: "
|
||||
f"{', '.join(registry.list_registered())}"
|
||||
f"{', '.join(self._adapter_registry.list_registered())}"
|
||||
) from e
|
||||
|
||||
# Use safe fallbacks for new adapter attributes
|
||||
@@ -1536,4 +1529,4 @@ Generate a workflow for: {self.topic}
|
||||
with open(full_path, 'w') as f:
|
||||
yaml.dump(workflow_yaml, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
return full_path
|
||||
return full_path
|
||||
@@ -17,6 +17,8 @@ if TYPE_CHECKING:
|
||||
from .agentmail import AgentMailBot
|
||||
from .bot import Bot
|
||||
from .botos import BotOS
|
||||
from ._session import BotSessionManager
|
||||
from ._dlq import InboundDLQ, DLQEntry
|
||||
from ._slack_approval import SlackApproval
|
||||
from ._telegram_approval import TelegramApproval
|
||||
from ._discord_approval import DiscordApproval
|
||||
@@ -74,11 +76,20 @@ def __getattr__(name: str):
|
||||
if name == "BotSessionManager":
|
||||
from ._session import BotSessionManager
|
||||
return BotSessionManager
|
||||
# N4 — inbound dead-letter queue
|
||||
if name == "InboundDLQ":
|
||||
from ._dlq import InboundDLQ
|
||||
return InboundDLQ
|
||||
if name == "DLQEntry":
|
||||
from ._dlq import DLQEntry
|
||||
return DLQEntry
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot", "LinearBot", "EmailBot", "AgentMailBot",
|
||||
"Bot", "BotOS",
|
||||
"BotSessionManager",
|
||||
"InboundDLQ", "DLQEntry",
|
||||
"SlackApproval", "TelegramApproval", "DiscordApproval",
|
||||
"WebhookApproval", "HTTPApproval",
|
||||
# W1
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Inbound Dead-Letter Queue (N4) for PraisonAI bot adapters.
|
||||
|
||||
When ``BotSessionManager.chat()`` raises (LLM 5xx, transient timeout,
|
||||
provider rate-limit, etc.) the user message is normally lost. This
|
||||
module persists those failed inbound messages so an operator can
|
||||
inspect, retry, or purge them later.
|
||||
|
||||
Design constraints (per PraisonAI principles):
|
||||
- Wrapper-only — heavy code stays out of the core SDK.
|
||||
- Lazy: ``sqlite3`` is stdlib so no extra dependency.
|
||||
- Default OFF — ``BotSessionManager`` works exactly as before unless
|
||||
a ``dlq=InboundDLQ(...)`` is passed.
|
||||
- Bounded: TTL + ``max_size`` prevent unbounded disk growth.
|
||||
- Thread-safe: a per-instance ``threading.Lock`` guards SQLite writes
|
||||
(sqlite3 connections are not thread-safe by default).
|
||||
- Reuses ``_resilience.BackoffPolicy`` for retry timing on replay.
|
||||
|
||||
Storage schema::
|
||||
|
||||
entries(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts REAL,
|
||||
platform TEXT, user_id TEXT, prompt TEXT,
|
||||
chat_id TEXT, thread_id TEXT, user_name TEXT,
|
||||
error TEXT,
|
||||
attempts INTEGER DEFAULT 0)
|
||||
|
||||
Public API:
|
||||
- ``InboundDLQ(path, *, max_size=10_000, ttl_seconds=7*86400)``
|
||||
- ``enqueue(platform, user_id, prompt, error, ...)``
|
||||
- ``size()`` / ``list(limit=100)`` / ``purge()``
|
||||
- ``evict_expired()``
|
||||
- ``replay(handler)`` — async, drops entries handler returns ``True`` for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable, List, Optional, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 7 days default — long enough for ops to notice and act, short enough
|
||||
# not to blow up disk on chronic LLM failures.
|
||||
_DEFAULT_TTL_SECONDS = 7 * 86400
|
||||
_DEFAULT_MAX_SIZE = 10_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DLQEntry:
|
||||
"""A single failed inbound message awaiting replay."""
|
||||
|
||||
id: int
|
||||
ts: float
|
||||
platform: str
|
||||
user_id: str
|
||||
prompt: str
|
||||
chat_id: str
|
||||
thread_id: str
|
||||
user_name: str
|
||||
error: str
|
||||
attempts: int
|
||||
|
||||
|
||||
# Replay handler signature: takes an entry, returns True on success
|
||||
# (entry will be deleted) or False to keep it for later retry.
|
||||
ReplayHandler = Callable[[DLQEntry], Awaitable[bool]]
|
||||
|
||||
|
||||
class InboundDLQ:
|
||||
"""SQLite-backed dead-letter queue for failed inbound bot messages.
|
||||
|
||||
Args:
|
||||
path: SQLite file path. Created if missing; parent dirs created.
|
||||
max_size: Maximum entries kept; oldest evicted when exceeded.
|
||||
ttl_seconds: Entries older than this are evicted on the next
|
||||
``enqueue()`` or ``evict_expired()`` call.
|
||||
|
||||
Example::
|
||||
|
||||
from praisonai.bots import InboundDLQ, BotSessionManager
|
||||
|
||||
dlq = InboundDLQ(path="~/.praisonai/dlq.sqlite")
|
||||
mgr = BotSessionManager(platform="telegram", dlq=dlq)
|
||||
|
||||
# Later, an admin replays:
|
||||
async def my_handler(entry):
|
||||
try:
|
||||
await mgr.chat(agent, entry.user_id, entry.prompt)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
await dlq.replay(my_handler)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Union[str, Path],
|
||||
*,
|
||||
max_size: int = _DEFAULT_MAX_SIZE,
|
||||
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
|
||||
) -> None:
|
||||
self.path = Path(path).expanduser()
|
||||
self.max_size = int(max_size)
|
||||
self.ttl_seconds = int(ttl_seconds)
|
||||
self._lock = threading.Lock()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_schema()
|
||||
|
||||
# ── Schema ──────────────────────────────────────────────────────
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self.path))
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with self._lock, self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts REAL NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
chat_id TEXT DEFAULT '',
|
||||
thread_id TEXT DEFAULT '',
|
||||
user_name TEXT DEFAULT '',
|
||||
error TEXT DEFAULT '',
|
||||
attempts INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ts ON entries(ts)")
|
||||
|
||||
# ── Mutations ───────────────────────────────────────────────────
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
platform: str,
|
||||
user_id: str,
|
||||
prompt: str,
|
||||
error: str,
|
||||
chat_id: str = "",
|
||||
thread_id: str = "",
|
||||
user_name: str = "",
|
||||
) -> int:
|
||||
"""Persist a failed inbound message. Returns its row id.
|
||||
|
||||
Runs ``evict_expired`` and ``_evict_overflow`` as side-effects so
|
||||
TTL and ``max_size`` invariants are kept tight without a
|
||||
background sweeper.
|
||||
"""
|
||||
with self._lock, self._connect() as conn:
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
self._evict_expired_locked(conn)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO entries(ts, platform, user_id, prompt,
|
||||
chat_id, thread_id, user_name, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
time.time(), platform, user_id, prompt,
|
||||
chat_id, thread_id, user_name, error,
|
||||
),
|
||||
)
|
||||
self._evict_overflow_locked(conn)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def purge(self) -> int:
|
||||
"""Delete all entries. Returns count removed."""
|
||||
with self._lock, self._connect() as conn:
|
||||
n = conn.execute("SELECT COUNT(*) FROM entries").fetchone()[0]
|
||||
conn.execute("DELETE FROM entries")
|
||||
return int(n)
|
||||
|
||||
def evict_expired(self) -> int:
|
||||
"""Drop entries older than ``ttl_seconds``. Returns count removed."""
|
||||
with self._lock, self._connect() as conn:
|
||||
return self._evict_expired_locked(conn)
|
||||
|
||||
def _evict_expired_locked(self, conn: sqlite3.Connection) -> int:
|
||||
cutoff = time.time() - self.ttl_seconds
|
||||
cur = conn.execute("DELETE FROM entries WHERE ts <= ?", (cutoff,))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
def _evict_overflow_locked(self, conn: sqlite3.Connection) -> int:
|
||||
n = conn.execute("SELECT COUNT(*) FROM entries").fetchone()[0]
|
||||
if n <= self.max_size:
|
||||
return 0
|
||||
excess = n - self.max_size
|
||||
cur = conn.execute(
|
||||
"DELETE FROM entries WHERE id IN "
|
||||
"(SELECT id FROM entries ORDER BY ts ASC LIMIT ?)",
|
||||
(excess,),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
# ── Queries ─────────────────────────────────────────────────────
|
||||
def size(self) -> int:
|
||||
with self._lock, self._connect() as conn:
|
||||
return int(conn.execute("SELECT COUNT(*) FROM entries").fetchone()[0])
|
||||
|
||||
def list(self, limit: int = 100) -> List[DLQEntry]:
|
||||
"""Return up to ``limit`` entries, newest first."""
|
||||
with self._lock, self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, ts, platform, user_id, prompt,
|
||||
chat_id, thread_id, user_name, error, attempts
|
||||
FROM entries
|
||||
ORDER BY ts DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()
|
||||
return [DLQEntry(*r) for r in rows]
|
||||
|
||||
def _list_oldest_first(self, limit: int = 100) -> List[DLQEntry]:
|
||||
"""Return up to ``limit`` entries, oldest first for replay."""
|
||||
with self._lock, self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, ts, platform, user_id, prompt,
|
||||
chat_id, thread_id, user_name, error, attempts
|
||||
FROM entries
|
||||
ORDER BY ts ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()
|
||||
return [DLQEntry(*r) for r in rows]
|
||||
|
||||
# ── Replay ──────────────────────────────────────────────────────
|
||||
async def replay(
|
||||
self,
|
||||
handler: ReplayHandler,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Re-deliver entries via ``handler``.
|
||||
|
||||
``handler(entry)`` must return ``True`` on success (entry is
|
||||
deleted) or ``False`` to keep the entry for later. Exceptions
|
||||
from the handler are caught and treated as ``False`` (entry is
|
||||
kept and ``attempts`` incremented).
|
||||
|
||||
Returns ``(succeeded, failed)`` counts.
|
||||
"""
|
||||
# Fetch entries oldest-first for correct replay order
|
||||
entries = self._list_oldest_first(limit=limit if limit is not None else self.max_size)
|
||||
succeeded = failed = 0
|
||||
for entry in entries:
|
||||
try:
|
||||
ok = await handler(entry)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.warning("DLQ replay handler raised: %s", e)
|
||||
ok = False
|
||||
if ok:
|
||||
self._delete(entry.id)
|
||||
succeeded += 1
|
||||
else:
|
||||
self._increment_attempts(entry.id)
|
||||
failed += 1
|
||||
return succeeded, failed
|
||||
|
||||
def _delete(self, entry_id: int) -> None:
|
||||
with self._lock, self._connect() as conn:
|
||||
conn.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
|
||||
|
||||
def _increment_attempts(self, entry_id: int) -> None:
|
||||
with self._lock, self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE entries SET attempts = attempts + 1 WHERE id = ?",
|
||||
(entry_id,),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"InboundDLQ(path={str(self.path)!r}, "
|
||||
f"size={self.size()}, "
|
||||
f"max_size={self.max_size}, ttl={self.ttl_seconds}s)"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["InboundDLQ", "DLQEntry", "ReplayHandler"]
|
||||
@@ -7,34 +7,111 @@ Extensible: third-party platforms can register via ``register_platform()``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Type
|
||||
import threading
|
||||
from typing import Any, Dict, List, Type, Optional
|
||||
|
||||
# Lazy references: (module_path, class_name)
|
||||
_BUILTIN_PLATFORMS: Dict[str, tuple] = {
|
||||
"telegram": ("praisonai.bots.telegram", "TelegramBot"),
|
||||
"discord": ("praisonai.bots.discord", "DiscordBot"),
|
||||
"slack": ("praisonai.bots.slack", "SlackBot"),
|
||||
"whatsapp": ("praisonai.bots.whatsapp", "WhatsAppBot"),
|
||||
"linear": ("praisonai.bots.linear", "LinearBot"),
|
||||
"email": ("praisonai.bots.email", "EmailBot"),
|
||||
"agentmail": ("praisonai.bots.agentmail", "AgentMailBot"),
|
||||
from .._registry import PluginRegistry
|
||||
|
||||
|
||||
def _telegram_loader():
|
||||
import importlib
|
||||
mod = importlib.import_module("praisonai.bots.telegram")
|
||||
return getattr(mod, "TelegramBot")
|
||||
|
||||
def _discord_loader():
|
||||
import importlib
|
||||
mod = importlib.import_module("praisonai.bots.discord")
|
||||
return getattr(mod, "DiscordBot")
|
||||
|
||||
def _slack_loader():
|
||||
import importlib
|
||||
mod = importlib.import_module("praisonai.bots.slack")
|
||||
return getattr(mod, "SlackBot")
|
||||
|
||||
def _whatsapp_loader():
|
||||
import importlib
|
||||
mod = importlib.import_module("praisonai.bots.whatsapp")
|
||||
return getattr(mod, "WhatsAppBot")
|
||||
|
||||
def _linear_loader():
|
||||
import importlib
|
||||
mod = importlib.import_module("praisonai.bots.linear")
|
||||
return getattr(mod, "LinearBot")
|
||||
|
||||
def _email_loader():
|
||||
import importlib
|
||||
mod = importlib.import_module("praisonai.bots.email")
|
||||
return getattr(mod, "EmailBot")
|
||||
|
||||
def _agentmail_loader():
|
||||
import importlib
|
||||
mod = importlib.import_module("praisonai.bots.agentmail")
|
||||
return getattr(mod, "AgentMailBot")
|
||||
|
||||
# Built-in bot platforms with lazy loading
|
||||
_BUILTIN_PLATFORMS = {
|
||||
"telegram": _telegram_loader,
|
||||
"discord": _discord_loader,
|
||||
"slack": _slack_loader,
|
||||
"whatsapp": _whatsapp_loader,
|
||||
"linear": _linear_loader,
|
||||
"email": _email_loader,
|
||||
"agentmail": _agentmail_loader,
|
||||
}
|
||||
|
||||
# Custom platforms registered at runtime
|
||||
_custom_platforms: Dict[str, Any] = {}
|
||||
|
||||
class BotPlatformRegistry(PluginRegistry):
|
||||
"""Registry for bot platform adapters."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
entry_point_group="praisonai.bots",
|
||||
builtins=_BUILTIN_PLATFORMS
|
||||
)
|
||||
|
||||
|
||||
# Default registry (lazy, module-private)
|
||||
_default_registry: Optional[BotPlatformRegistry] = None
|
||||
_default_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_default_bot_registry() -> BotPlatformRegistry:
|
||||
"""Return the process-default bot registry. Prefer DI; use this only at the edge."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
with _default_lock:
|
||||
if _default_registry is None:
|
||||
_default_registry = BotPlatformRegistry()
|
||||
return _default_registry
|
||||
|
||||
|
||||
# Backward compatibility API - lazy loading to preserve original behavior
|
||||
_bot_registry = None
|
||||
|
||||
def _get_lazy_registry():
|
||||
"""Get registry lazily to avoid eager loading at module import."""
|
||||
global _bot_registry
|
||||
if _bot_registry is None:
|
||||
_bot_registry = get_default_bot_registry()
|
||||
return _bot_registry
|
||||
|
||||
|
||||
def get_platform_registry() -> Dict[str, Any]:
|
||||
"""Return the combined registry of all known platforms.
|
||||
|
||||
Keys are platform names, values are either:
|
||||
- A class (custom platforms, already resolved)
|
||||
- A (module, classname) tuple (builtins, lazy-resolved on use)
|
||||
|
||||
Backward compatibility function that returns original format:
|
||||
{name: class_or_tuple} to preserve external caller contracts.
|
||||
"""
|
||||
combined: Dict[str, Any] = {}
|
||||
combined.update(_BUILTIN_PLATFORMS)
|
||||
combined.update(_custom_platforms)
|
||||
return combined
|
||||
registry = _get_lazy_registry()
|
||||
result = {}
|
||||
for name in registry.list_names():
|
||||
try:
|
||||
# Return the resolved class to maintain original API contract
|
||||
result[name] = registry.resolve(name)
|
||||
except ValueError:
|
||||
# Skip broken registrations
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def register_platform(name: str, adapter_class: Type) -> None:
|
||||
@@ -44,12 +121,12 @@ def register_platform(name: str, adapter_class: Type) -> None:
|
||||
name: Platform identifier (lowercase).
|
||||
adapter_class: The bot adapter class.
|
||||
"""
|
||||
_custom_platforms[name.lower()] = adapter_class
|
||||
_get_lazy_registry().register(name.lower(), adapter_class)
|
||||
|
||||
|
||||
def list_platforms() -> List[str]:
|
||||
"""List all registered platform names."""
|
||||
return sorted(set(list(_BUILTIN_PLATFORMS.keys()) + list(_custom_platforms.keys())))
|
||||
return _get_lazy_registry().list_names()
|
||||
|
||||
|
||||
def resolve_adapter(name: str) -> Type:
|
||||
@@ -64,24 +141,4 @@ def resolve_adapter(name: str) -> Type:
|
||||
Raises:
|
||||
ValueError: If the platform is not registered.
|
||||
"""
|
||||
key = name.lower()
|
||||
|
||||
# Custom platforms are already classes
|
||||
if key in _custom_platforms:
|
||||
cls = _custom_platforms[key]
|
||||
if isinstance(cls, type):
|
||||
return cls
|
||||
|
||||
# Builtins are lazy (module, classname) tuples
|
||||
if key in _BUILTIN_PLATFORMS:
|
||||
ref = _BUILTIN_PLATFORMS[key]
|
||||
if isinstance(ref, tuple):
|
||||
module_path, class_name = ref
|
||||
import importlib
|
||||
mod = importlib.import_module(module_path)
|
||||
return getattr(mod, class_name)
|
||||
|
||||
raise ValueError(
|
||||
f"Unknown platform: {name!r}. "
|
||||
f"Available: {', '.join(list_platforms())}"
|
||||
)
|
||||
return _get_lazy_registry().resolve(name.lower())
|
||||
@@ -62,6 +62,7 @@ class BotSessionManager:
|
||||
max_history: int = 100,
|
||||
store: Optional[Any] = None,
|
||||
platform: str = "",
|
||||
dlq: Optional[Any] = None,
|
||||
identity_resolver: Optional[Any] = None,
|
||||
) -> None:
|
||||
self._histories: Dict[str, List[Dict[str, Any]]] = {}
|
||||
@@ -71,6 +72,10 @@ class BotSessionManager:
|
||||
self._store = store
|
||||
self._platform = platform
|
||||
self._last_active: Dict[str, float] = {}
|
||||
# N4: optional inbound DLQ — when set, failed agent.chat() calls
|
||||
# are persisted for later replay. Default ``None`` preserves
|
||||
# legacy behaviour (exception bubbles up untouched).
|
||||
self._dlq = dlq
|
||||
# W1: optional cross-platform identity resolver. When set, the
|
||||
# session key is the resolver-returned unified user id, so the
|
||||
# same human pinging from multiple platforms shares one history.
|
||||
@@ -172,6 +177,13 @@ class BotSessionManager:
|
||||
|
||||
Uses both a per-user lock (serialise same user) and a per-agent
|
||||
lock (prevent concurrent history swaps on a shared Agent).
|
||||
|
||||
N4 — Inbound DLQ: if a ``dlq`` was passed to ``__init__`` and
|
||||
``agent.chat()`` raises, the failing message is persisted to
|
||||
the dead-letter queue **before** the exception is re-raised.
|
||||
This makes the error visible to the caller (so the bot adapter
|
||||
can log / show the user a friendly message) while preserving
|
||||
the message for later replay.
|
||||
"""
|
||||
self._last_active[self._storage_key(user_id)] = time.monotonic()
|
||||
user_lock = self._get_lock(user_id)
|
||||
@@ -209,9 +221,6 @@ class BotSessionManager:
|
||||
# W1 robustness: hold ``agent_lock`` across the FULL LLM call
|
||||
# (not only the history swap) so concurrent users on a shared
|
||||
# Agent instance never observe each other's chat_history.
|
||||
# Throughput on a shared agent is then bounded by the LLM's
|
||||
# serial latency — this is correct: a single Agent's
|
||||
# ``chat_history`` is mutable and cannot be safely interleaved.
|
||||
async with agent_lock:
|
||||
saved_history = agent.chat_history
|
||||
agent.chat_history = user_history
|
||||
@@ -226,6 +235,28 @@ class BotSessionManager:
|
||||
)
|
||||
# Capture updated history before restoring caller's.
|
||||
updated_history = agent.chat_history
|
||||
except Exception as exc:
|
||||
# N4: persist the failed inbound message before bubbling.
|
||||
if self._dlq is not None:
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._dlq.enqueue(
|
||||
platform=self._platform,
|
||||
user_id=user_id,
|
||||
prompt=prompt,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
user_name=user_name,
|
||||
)
|
||||
)
|
||||
except Exception as dlq_exc: # pragma: no cover — defensive
|
||||
logger.error(
|
||||
"Failed to enqueue inbound DLQ entry: %s", dlq_exc
|
||||
)
|
||||
agent.chat_history = saved_history
|
||||
raise
|
||||
finally:
|
||||
agent.chat_history = saved_history
|
||||
|
||||
|
||||
@@ -86,6 +86,8 @@ Only respond with the JSON, no other text."""
|
||||
)
|
||||
|
||||
import json
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
result_text = response.choices[0].message.content
|
||||
result_data = json.loads(result_text)
|
||||
|
||||
@@ -160,6 +162,8 @@ Only respond with the JSON, no other text."""
|
||||
)
|
||||
|
||||
import json
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
result_text = response.choices[0].message.content
|
||||
result_data = json.loads(result_text)
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ def rag_query(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
answer = response.choices[0].message.content if response.choices else ""
|
||||
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
|
||||
|
||||
usage = None
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
@@ -188,7 +188,7 @@ async def arag_query(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
answer = response.choices[0].message.content if response.choices else ""
|
||||
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
|
||||
|
||||
usage = None
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
|
||||
@@ -556,6 +556,123 @@ def bot_install_daemon(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# ── N4: Inbound DLQ subcommand group ─────────────────────────────────
|
||||
dlq_app = typer.Typer(
|
||||
help="Inspect / replay / purge the inbound dead-letter queue.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
app.add_typer(dlq_app, name="dlq")
|
||||
|
||||
|
||||
def _resolve_dlq_path(path: Optional[str]) -> str:
|
||||
import os
|
||||
if path:
|
||||
return os.path.expanduser(path)
|
||||
return os.path.expanduser(
|
||||
os.environ.get("PRAISONAI_DLQ_PATH", "~/.praisonai/dlq.sqlite")
|
||||
)
|
||||
|
||||
|
||||
@dlq_app.command("list")
|
||||
def dlq_list(
|
||||
path: Optional[str] = typer.Option(
|
||||
None, "--path", "-p", help="Path to DLQ sqlite file."
|
||||
),
|
||||
limit: int = typer.Option(20, "--limit", "-n", help="Max entries to show."),
|
||||
):
|
||||
"""List entries in the inbound DLQ (newest first)."""
|
||||
from praisonai.bots import InboundDLQ
|
||||
|
||||
dlq = InboundDLQ(path=_resolve_dlq_path(path))
|
||||
entries = dlq.list(limit=limit)
|
||||
if not entries:
|
||||
typer.echo(f"DLQ empty (path={dlq.path})")
|
||||
raise typer.Exit(0)
|
||||
|
||||
typer.echo(f"DLQ {dlq.path} — {dlq.size()} entries (showing {len(entries)}):")
|
||||
for e in entries:
|
||||
prompt_preview = (e.prompt[:60] + "…") if len(e.prompt) > 60 else e.prompt
|
||||
typer.echo(
|
||||
f" [{e.id:>5}] {e.platform:<10} user={e.user_id:<16} "
|
||||
f"attempts={e.attempts} err={e.error[:50]} prompt={prompt_preview!r}"
|
||||
)
|
||||
|
||||
|
||||
@dlq_app.command("purge")
|
||||
def dlq_purge(
|
||||
path: Optional[str] = typer.Option(None, "--path", "-p"),
|
||||
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
||||
):
|
||||
"""Delete all entries from the DLQ."""
|
||||
from praisonai.bots import InboundDLQ
|
||||
|
||||
dlq = InboundDLQ(path=_resolve_dlq_path(path))
|
||||
n = dlq.size()
|
||||
if n == 0:
|
||||
typer.echo("DLQ already empty.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
if not yes:
|
||||
confirm = typer.confirm(f"Delete all {n} entries from {dlq.path}?")
|
||||
if not confirm:
|
||||
raise typer.Exit(0)
|
||||
|
||||
removed = dlq.purge()
|
||||
typer.echo(f"Purged {removed} entries.")
|
||||
|
||||
|
||||
@dlq_app.command("replay")
|
||||
def dlq_replay(
|
||||
config: str = typer.Option(
|
||||
..., "--config", "-c",
|
||||
help="Path to bot YAML config (provides agent + platform).",
|
||||
),
|
||||
path: Optional[str] = typer.Option(None, "--path", "-p"),
|
||||
limit: int = typer.Option(50, "--limit", "-n"),
|
||||
):
|
||||
"""Replay DLQ entries through the configured agent.
|
||||
|
||||
Loads the bot YAML, instantiates the agent, and re-runs each entry
|
||||
through ``BotSessionManager.chat()``. Successful entries are removed;
|
||||
failed entries are kept for the next attempt.
|
||||
"""
|
||||
import asyncio
|
||||
from praisonai.bots import InboundDLQ, BotSessionManager
|
||||
from praisonai.cli.features.bots_cli import _load_bot_config, _build_agent
|
||||
|
||||
cfg = _load_bot_config(config)
|
||||
agent = _build_agent(cfg)
|
||||
platform = cfg.get("platform", "")
|
||||
|
||||
dlq = InboundDLQ(path=_resolve_dlq_path(path))
|
||||
if dlq.size() == 0:
|
||||
typer.echo("DLQ empty — nothing to replay.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
mgr = BotSessionManager(platform=platform)
|
||||
|
||||
async def replayer(entry):
|
||||
try:
|
||||
await mgr.chat(
|
||||
agent, entry.user_id, entry.prompt,
|
||||
chat_id=entry.chat_id,
|
||||
thread_id=entry.thread_id,
|
||||
user_name=entry.user_name,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
typer.echo(f" entry {entry.id} failed: {e}")
|
||||
return False
|
||||
|
||||
succeeded, failed = asyncio.run(dlq.replay(replayer, limit=limit))
|
||||
typer.echo(f"Replay done: succeeded={succeeded}, failed={failed}, "
|
||||
f"remaining={dlq.size()}")
|
||||
|
||||
# Return non-zero exit code if any failures occurred and entries remain
|
||||
if failed > 0 and dlq.size() > 0:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def bot_callback(ctx: typer.Context):
|
||||
"""Show bot help if no subcommand provided."""
|
||||
|
||||
@@ -73,48 +73,52 @@ def _get_process_using_port(port: int) -> Optional[Dict]:
|
||||
elif sys.platform == "win32":
|
||||
# Use netstat on Windows
|
||||
result = subprocess.run(
|
||||
["netstat", "-ano", "|", "findstr", f":{port}"],
|
||||
["netstat", "-ano"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=True,
|
||||
timeout=5
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
return None
|
||||
|
||||
# Parse netstat output
|
||||
# Parse netstat output and filter by exact port
|
||||
lines = result.stdout.strip().split("\n")
|
||||
for line in lines:
|
||||
if f":{port}" in line and "LISTENING" in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 5:
|
||||
pid = parts[-1]
|
||||
# Get process name from PID
|
||||
try:
|
||||
proc_result = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
name = "unknown"
|
||||
if proc_result.returncode == 0:
|
||||
proc_lines = proc_result.stdout.strip().split("\n")
|
||||
if len(proc_lines) >= 2:
|
||||
name = proc_lines[1].split()[0]
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"pid": pid,
|
||||
"user": "unknown",
|
||||
"type": "IPv4",
|
||||
"protocol": "TCP",
|
||||
"port": port,
|
||||
"raw": line
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
parts = line.split()
|
||||
if len(parts) >= 5 and "LISTENING" in line:
|
||||
local_addr = parts[1]
|
||||
# Extract port from address, handle IPv6 format [::]:port and IPv4 format host:port
|
||||
port_match = re.search(r":(\d+)$", local_addr)
|
||||
if not port_match or int(port_match.group(1)) != port:
|
||||
continue
|
||||
|
||||
pid = parts[-1]
|
||||
# Get process name from PID
|
||||
try:
|
||||
proc_result = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
name = "unknown"
|
||||
if proc_result.returncode == 0:
|
||||
proc_lines = proc_result.stdout.strip().split("\n")
|
||||
if len(proc_lines) >= 2:
|
||||
name = proc_lines[1].split()[0]
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"pid": pid,
|
||||
"user": "unknown",
|
||||
"type": "IPv4",
|
||||
"protocol": "TCP",
|
||||
"port": port,
|
||||
"raw": line
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
@@ -166,7 +170,6 @@ def _get_all_ports() -> List[Dict]:
|
||||
["netstat", "-ano"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import pstats
|
||||
import time
|
||||
import platform
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .request import ExecutionRequest
|
||||
@@ -409,7 +409,7 @@ class ProfileReport:
|
||||
def __post_init__(self):
|
||||
"""Set timestamp if not provided."""
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.utcnow().isoformat() + "Z"
|
||||
self.timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
|
||||
@@ -15,7 +15,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -113,7 +113,7 @@ class ProfilerResult:
|
||||
callees: Dict[str, List[str]] = field(default_factory=dict)
|
||||
import_times: List[Tuple[str, float]] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -304,7 +304,7 @@ class QueryProfiler:
|
||||
"platform": platform.platform(),
|
||||
"praisonai_version": praisonai_version,
|
||||
"model": model or "default",
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def _extract_function_stats(self) -> List[FunctionStats]:
|
||||
|
||||
@@ -11,7 +11,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -85,7 +85,7 @@ class SuiteResult:
|
||||
startup_cold_ms: float = 0.0
|
||||
startup_warm_ms: float = 0.0
|
||||
import_analysis: List[Dict[str, Any]] = field(default_factory=list)
|
||||
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -182,7 +182,7 @@ class ProfileSuiteRunner:
|
||||
"python_version": platform.python_version(),
|
||||
"platform": platform.platform(),
|
||||
"praisonai_version": praisonai_version,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def _measure_startup(self) -> tuple:
|
||||
|
||||
@@ -4065,6 +4065,31 @@ Do NOT add any explanations or formatting."""
|
||||
|
||||
return results[-1].get("output", "") if results else ""
|
||||
|
||||
def _execute_agent_with_budget_handling(self, agent, method_name, *args, **kwargs):
|
||||
"""Run ``agent.<method_name>(*args, **kwargs)`` with a graceful
|
||||
BudgetExceededError handler.
|
||||
|
||||
Wrapper-only fix (no core SDK changes). Users configure budgets via
|
||||
``execution=ExecutionConfig(max_budget=...)`` on the Agent — per
|
||||
AGENTS.md §5.3 there is NO top-level ``max_budget=`` parameter on
|
||||
Agent.__init__ (avoids parameter bloat).
|
||||
|
||||
When the budget is hit this prints a single-line actionable error
|
||||
message and exits with code 1 instead of leaking a raw traceback.
|
||||
Any other exception is re-raised unchanged.
|
||||
"""
|
||||
from praisonaiagents.errors import BudgetExceededError
|
||||
try:
|
||||
return getattr(agent, method_name)(*args, **kwargs)
|
||||
except BudgetExceededError as e:
|
||||
from rich import print as rich_print
|
||||
rich_print(
|
||||
f"[red]Budget limit exceeded: {e!s}. "
|
||||
"Hint: set budget via "
|
||||
"execution=ExecutionConfig(max_budget=1.00) on your Agent.[/red]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def _extract_cli_config_for_yaml(self):
|
||||
"""
|
||||
Extract CLI configuration that should be passed to YAML processing.
|
||||
@@ -4611,9 +4636,9 @@ Do NOT add any explanations or formatting."""
|
||||
from rich.panel import Panel
|
||||
|
||||
with Live(Panel(Spinner("dots", text="Generating..."), border_style="cyan"), refresh_per_second=10, transient=True):
|
||||
result = auto_rag.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(auto_rag, 'chat', prompt)
|
||||
else:
|
||||
result = auto_rag.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(auto_rag, 'chat', prompt)
|
||||
else:
|
||||
# Resolve display mode from CLI flags
|
||||
display_mode = self._resolve_display_mode()
|
||||
@@ -4621,16 +4646,16 @@ Do NOT add any explanations or formatting."""
|
||||
if display_mode == 'silent':
|
||||
# -qq: No output at all, exit code only
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
|
||||
elif display_mode == 'quiet':
|
||||
# -q: Result only, no spinners or status
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
if result is not None:
|
||||
output = getattr(result, 'output', None) or (str(result) if result else None)
|
||||
if output:
|
||||
@@ -4642,15 +4667,15 @@ Do NOT add any explanations or formatting."""
|
||||
from praisonaiagents.output.status import enable_status_output, disable_status_output
|
||||
enable_status_output(show_timestamps=True, show_metrics=True)
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
disable_status_output()
|
||||
except ImportError:
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
|
||||
elif display_mode == 'debug':
|
||||
# -vv: SDK TraceOutput with markdown rendering
|
||||
@@ -4658,15 +4683,15 @@ Do NOT add any explanations or formatting."""
|
||||
from praisonaiagents.output.trace import enable_trace_output, disable_trace_output
|
||||
enable_trace_output(use_markdown=True)
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
disable_trace_output()
|
||||
except ImportError:
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
|
||||
elif display_mode == 'jsonl':
|
||||
# --output jsonl: JSONL structured output for CI/CD
|
||||
@@ -4688,9 +4713,9 @@ Do NOT add any explanations or formatting."""
|
||||
|
||||
start_time = time.time()
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
|
||||
# Emit final result
|
||||
reason = getattr(result, 'completion_reason', None) if hasattr(result, 'completion_reason') else 'complete'
|
||||
@@ -4709,9 +4734,9 @@ Do NOT add any explanations or formatting."""
|
||||
import json as json_mod
|
||||
start_time = time.time()
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
|
||||
output = result.output if hasattr(result, 'output') else str(result)
|
||||
envelope = {
|
||||
@@ -4732,15 +4757,15 @@ Do NOT add any explanations or formatting."""
|
||||
flow = track_workflow()
|
||||
flow.start()
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
flow.stop()
|
||||
except ImportError:
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
|
||||
elif display_mode == 'editor':
|
||||
# --output editor: User-friendly step-by-step format
|
||||
@@ -4750,9 +4775,9 @@ Do NOT add any explanations or formatting."""
|
||||
|
||||
# Run agent
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
|
||||
# SDK callbacks (interaction, llm_content) handle display —
|
||||
# no explicit editor.output() needed here.
|
||||
@@ -4774,15 +4799,15 @@ Do NOT add any explanations or formatting."""
|
||||
from praisonaiagents.output.status import enable_status_output, disable_status_output
|
||||
enable_status_output(show_timestamps=False, show_metrics=False)
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
disable_status_output()
|
||||
except ImportError:
|
||||
if hasattr(agent, 'start'):
|
||||
result = agent.start(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
|
||||
|
||||
# ===== POST-PROCESSING WITH NEW FEATURES =====
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import secrets
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ def generate_run_id() -> str:
|
||||
Format: run_YYYYMMDD_HHMMSS_<random>
|
||||
Example: run_20241231_143022_a1b2c3
|
||||
"""
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
random_suffix = secrets.token_hex(3) # 6 characters
|
||||
return f"run_{timestamp}_{random_suffix}"
|
||||
|
||||
@@ -68,7 +68,7 @@ class RunContext:
|
||||
"""
|
||||
run_id: str = field(default_factory=generate_run_id)
|
||||
trace_id: str = field(default_factory=generate_trace_id)
|
||||
start_time: datetime = field(default_factory=datetime.utcnow)
|
||||
start_time: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Agent tracking
|
||||
agents: Dict[str, str] = field(default_factory=dict) # name -> agent_id
|
||||
|
||||
@@ -7,7 +7,7 @@ Provides persistence and resume functionality for sessions.
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
@@ -129,7 +129,7 @@ class SessionManager:
|
||||
session_dir = self._get_session_dir(session_id)
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
metadata = SessionMetadata(
|
||||
session_id=session_id,
|
||||
run_id=context.run_id,
|
||||
@@ -206,7 +206,7 @@ class SessionManager:
|
||||
metadata = self._load_metadata(session_id)
|
||||
if metadata:
|
||||
metadata.event_count += 1
|
||||
metadata.updated_at = datetime.utcnow()
|
||||
metadata.updated_at = datetime.now(timezone.utc)
|
||||
self._save_metadata(metadata)
|
||||
|
||||
def get(self, session_id: str) -> Optional[SessionMetadata]:
|
||||
@@ -365,7 +365,7 @@ class SessionManager:
|
||||
metadata = self._load_metadata(session_id)
|
||||
if metadata:
|
||||
metadata.status = status
|
||||
metadata.updated_at = datetime.utcnow()
|
||||
metadata.updated_at = datetime.now(timezone.utc)
|
||||
self._save_metadata(metadata)
|
||||
|
||||
|
||||
|
||||
@@ -8,10 +8,36 @@ Plugin registry for CLI backend implementations following AGENTS.md patterns:
|
||||
|
||||
import threading
|
||||
from typing import Dict, Callable, Any, Optional, Union
|
||||
from .._registry import PluginRegistry
|
||||
|
||||
# Thread-safe registry for CLI backend factories
|
||||
_REGISTRY: Dict[str, Callable[[], Any]] = {}
|
||||
_REGISTRY_LOCK = threading.Lock()
|
||||
# CLI Backend Registry using canonical PluginRegistry
|
||||
def _get_builtin_cli_backend_loaders() -> Dict[str, Callable[[], Any]]:
|
||||
"""Get built-in CLI backend loaders."""
|
||||
def claude_loader():
|
||||
def factory():
|
||||
from .claude import ClaudeCodeBackend
|
||||
return ClaudeCodeBackend()
|
||||
return factory
|
||||
|
||||
return {
|
||||
"claude-code": claude_loader
|
||||
}
|
||||
|
||||
# Global CLI backend registry instance
|
||||
_cli_backend_registry: Optional[PluginRegistry] = None
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
def _get_cli_backend_registry() -> PluginRegistry:
|
||||
"""Get the CLI backend registry instance."""
|
||||
global _cli_backend_registry
|
||||
if _cli_backend_registry is None:
|
||||
with _registry_lock:
|
||||
if _cli_backend_registry is None:
|
||||
_cli_backend_registry = PluginRegistry(
|
||||
entry_point_group="praisonai.cli_backends",
|
||||
builtins=_get_builtin_cli_backend_loaders()
|
||||
)
|
||||
return _cli_backend_registry
|
||||
|
||||
|
||||
def register_cli_backend(backend_id: str, factory: Callable[[], Any]) -> None:
|
||||
@@ -21,14 +47,18 @@ def register_cli_backend(backend_id: str, factory: Callable[[], Any]) -> None:
|
||||
backend_id: Unique identifier (e.g., "claude-code", "codex-cli")
|
||||
factory: Factory function that returns a CliBackendProtocol instance
|
||||
"""
|
||||
with _REGISTRY_LOCK:
|
||||
_REGISTRY[backend_id] = factory
|
||||
# Wrap factory in a loader function for PluginRegistry
|
||||
def factory_loader():
|
||||
return factory
|
||||
|
||||
registry = _get_cli_backend_registry()
|
||||
registry.register(backend_id, factory_loader)
|
||||
|
||||
|
||||
def list_cli_backends() -> list[str]:
|
||||
"""List all registered CLI backend IDs."""
|
||||
with _REGISTRY_LOCK:
|
||||
return list(_REGISTRY.keys())
|
||||
registry = _get_cli_backend_registry()
|
||||
return registry.list_names()
|
||||
|
||||
|
||||
def resolve_cli_backend(
|
||||
@@ -47,12 +77,15 @@ def resolve_cli_backend(
|
||||
Raises:
|
||||
ValueError: If backend_id is not registered
|
||||
"""
|
||||
with _REGISTRY_LOCK:
|
||||
if backend_id not in _REGISTRY:
|
||||
available = list(_REGISTRY.keys())
|
||||
raise ValueError(f"Unknown CLI backend: {backend_id}. Available: {available}")
|
||||
|
||||
factory = _REGISTRY[backend_id]
|
||||
registry = _get_cli_backend_registry()
|
||||
|
||||
try:
|
||||
# Get the factory loader and create the factory
|
||||
factory_loader = registry.resolve(backend_id)
|
||||
factory = factory_loader()
|
||||
except ValueError:
|
||||
available = registry.list_names()
|
||||
raise ValueError(f"Unknown CLI backend: {backend_id}. Available: {available}")
|
||||
|
||||
# Create instance with factory
|
||||
backend = factory()
|
||||
@@ -77,24 +110,4 @@ def resolve_cli_backend(
|
||||
return backend
|
||||
|
||||
|
||||
def _register_builtin_backends() -> None:
|
||||
"""Register built-in CLI backends (lazy loaded)."""
|
||||
# Only register if not already done
|
||||
with _REGISTRY_LOCK:
|
||||
if "claude-code" in _REGISTRY:
|
||||
return
|
||||
|
||||
def claude_factory():
|
||||
from .claude import ClaudeCodeBackend
|
||||
return ClaudeCodeBackend()
|
||||
|
||||
register_cli_backend("claude-code", claude_factory)
|
||||
|
||||
|
||||
# Auto-register built-in backends on first access
|
||||
def __getattr__(name: str):
|
||||
"""Lazy registration trigger."""
|
||||
_register_builtin_backends()
|
||||
raise AttributeError(f"No attribute {name}")
|
||||
|
||||
# Registration happens lazily via __getattr__ on first attribute access
|
||||
# Built-in backends are now registered via _get_builtin_cli_backend_loaders()
|
||||
@@ -58,9 +58,10 @@ def write_file(
|
||||
>>> if result['success']:
|
||||
... print(f"Wrote to {result['path']}")
|
||||
"""
|
||||
# Resolve path
|
||||
if workspace and not os.path.isabs(path):
|
||||
abs_path = os.path.abspath(os.path.join(workspace, path))
|
||||
# Resolve path — default workspace is cwd so relative paths cannot escape
|
||||
effective_workspace = workspace or os.getcwd()
|
||||
if not os.path.isabs(path):
|
||||
abs_path = os.path.abspath(os.path.join(effective_workspace, path))
|
||||
else:
|
||||
abs_path = os.path.abspath(path)
|
||||
|
||||
@@ -73,14 +74,12 @@ def write_file(
|
||||
'path': path,
|
||||
}
|
||||
|
||||
# Security check - ensure path is within workspace if specified
|
||||
if workspace:
|
||||
if not is_path_within_directory(abs_path, workspace):
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Path '{path}' is outside the workspace",
|
||||
'path': path,
|
||||
}
|
||||
if not is_path_within_directory(abs_path, effective_workspace):
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Path '{path}' is outside the workspace",
|
||||
'path': path,
|
||||
}
|
||||
|
||||
# Process content
|
||||
processed_content = content
|
||||
|
||||
@@ -554,6 +554,162 @@ class PraisonAIDB:
|
||||
return traces[: max(limit, 0)]
|
||||
return traces
|
||||
|
||||
# ========================================================================
|
||||
# Async Surface for async-safe agents
|
||||
# ========================================================================
|
||||
|
||||
async def aon_agent_start(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
agent_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_agent_start."""
|
||||
self._init_stores()
|
||||
|
||||
if self._state_store:
|
||||
if hasattr(self._state_store, "async_set_agent_state"):
|
||||
await self._state_store.async_set_agent_state(
|
||||
session_id,
|
||||
agent_id or name,
|
||||
{"status": "started", "metadata": metadata or {}}
|
||||
)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(
|
||||
self._state_store.set_agent_state,
|
||||
session_id,
|
||||
agent_id or name,
|
||||
{"status": "started", "metadata": metadata or {}}
|
||||
)
|
||||
|
||||
async def aon_user_message(
|
||||
self,
|
||||
session_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_user_message."""
|
||||
self._init_stores()
|
||||
|
||||
if self._conversation_store:
|
||||
from ..persistence.conversation.models import ConversationMessage
|
||||
msg = ConversationMessage(
|
||||
role="user",
|
||||
content=content,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
if hasattr(self._conversation_store, "async_add_message"):
|
||||
await self._conversation_store.async_add_message(session_id, msg)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(self._conversation_store.add_message, session_id, msg)
|
||||
|
||||
async def aon_agent_message(
|
||||
self,
|
||||
session_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_agent_message."""
|
||||
self._init_stores()
|
||||
|
||||
if self._conversation_store:
|
||||
from ..persistence.conversation.models import ConversationMessage
|
||||
msg = ConversationMessage(
|
||||
role="assistant",
|
||||
content=content,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
if hasattr(self._conversation_store, "async_add_message"):
|
||||
await self._conversation_store.async_add_message(session_id, msg)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(self._conversation_store.add_message, session_id, msg)
|
||||
|
||||
async def aon_tool_call(
|
||||
self,
|
||||
session_id: str,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
result: Any = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_tool_call."""
|
||||
self._init_stores()
|
||||
|
||||
if self._conversation_store:
|
||||
if hasattr(self._conversation_store, "async_add_tool_call"):
|
||||
await self._conversation_store.async_add_tool_call(
|
||||
session_id, tool_name, arguments, result, metadata
|
||||
)
|
||||
elif hasattr(self._conversation_store, "add_tool_call"):
|
||||
import asyncio
|
||||
await asyncio.to_thread(
|
||||
self._conversation_store.add_tool_call,
|
||||
session_id, tool_name, arguments, result, metadata
|
||||
)
|
||||
|
||||
async def aon_agent_end(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
agent_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_agent_end."""
|
||||
self._init_stores()
|
||||
|
||||
if self._state_store:
|
||||
if hasattr(self._state_store, "async_set_agent_state"):
|
||||
await self._state_store.async_set_agent_state(
|
||||
session_id,
|
||||
agent_id or name,
|
||||
{"status": "ended", "metadata": metadata or {}}
|
||||
)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(
|
||||
self._state_store.set_agent_state,
|
||||
session_id,
|
||||
agent_id or name,
|
||||
{"status": "ended", "metadata": metadata or {}}
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Async version of close."""
|
||||
stores = [self._conversation_store, self._state_store, self._knowledge_store]
|
||||
|
||||
for store in stores:
|
||||
if store is None:
|
||||
continue
|
||||
if hasattr(store, "async_close"):
|
||||
await store.async_close()
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(store.close)
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
self._init_stores()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await self.aclose()
|
||||
|
||||
def __enter__(self):
|
||||
"""Sync context manager entry."""
|
||||
self._init_stores()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Sync context manager exit."""
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close all database connections."""
|
||||
if self._conversation_store:
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.37 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.41 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
@@ -76,7 +76,8 @@ class CloudDeployer:
|
||||
with open("api.py", "w") as file:
|
||||
file.write("from flask import Flask\n")
|
||||
file.write("from praisonai import PraisonAI\n")
|
||||
file.write("import markdown\n\n")
|
||||
file.write("import markdown\n")
|
||||
file.write("import bleach\n\n")
|
||||
file.write("app = Flask(__name__)\n\n")
|
||||
file.write("def basic():\n")
|
||||
file.write(" praisonai = PraisonAI(agent_file=\"agents.yaml\")\n")
|
||||
@@ -84,8 +85,9 @@ class CloudDeployer:
|
||||
file.write("@app.route('/')\n")
|
||||
file.write("def home():\n")
|
||||
file.write(" output = basic()\n")
|
||||
file.write(" html_output = markdown.markdown(output)\n")
|
||||
file.write(" return f'<html><body>{html_output}</body></html>'\n\n")
|
||||
file.write(" rendered = markdown.markdown(str(output))\n")
|
||||
file.write(" safe_html = bleach.clean(rendered, tags=bleach.sanitizer.ALLOWED_TAGS, attributes=bleach.sanitizer.ALLOWED_ATTRIBUTES)\n")
|
||||
file.write(" return f'<html><body>{safe_html}</body></html>'\n\n")
|
||||
file.write("if __name__ == \"__main__\":\n")
|
||||
file.write(" import os\n")
|
||||
file.write(" app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true')\n")
|
||||
|
||||
@@ -5,7 +5,7 @@ Provides lazy-loaded integration with AutoGen v0.2, AutoGen v0.4, and AG2 framew
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from .base import BaseFrameworkAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,7 +26,17 @@ class AutoGenAdapter(BaseFrameworkAdapter):
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
|
||||
def run(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
llm_config: List[Dict],
|
||||
topic: str,
|
||||
*,
|
||||
tools_dict: Optional[Dict[str, Any]] = None,
|
||||
agent_callback: Optional[Callable] = None,
|
||||
task_callback: Optional[Callable] = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Run AutoGen v0.2 with given configuration.
|
||||
|
||||
@@ -34,6 +44,10 @@ class AutoGenAdapter(BaseFrameworkAdapter):
|
||||
config: AutoGen configuration with agents
|
||||
llm_config: LLM configuration list
|
||||
topic: Topic for the tasks
|
||||
tools_dict: Available tools dictionary
|
||||
agent_callback: Callback for agent events
|
||||
task_callback: Callback for task events
|
||||
cli_config: CLI configuration
|
||||
|
||||
Returns:
|
||||
Execution result as string
|
||||
@@ -110,7 +124,17 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
|
||||
def run(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
llm_config: List[Dict],
|
||||
topic: str,
|
||||
*,
|
||||
tools_dict: Optional[Dict[str, Any]] = None,
|
||||
agent_callback: Optional[Callable] = None,
|
||||
task_callback: Optional[Callable] = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Run AutoGen v0.4 with given configuration.
|
||||
|
||||
@@ -118,6 +142,10 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
|
||||
config: AutoGen v0.4 configuration with agents
|
||||
llm_config: LLM configuration list
|
||||
topic: Topic for the tasks
|
||||
tools_dict: Available tools dictionary
|
||||
agent_callback: Callback for agent events
|
||||
task_callback: Callback for task events
|
||||
cli_config: CLI configuration
|
||||
|
||||
Returns:
|
||||
Execution result as string
|
||||
@@ -128,7 +156,7 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
|
||||
# For now, return a proper error message instead of delegating
|
||||
# TODO: Implement full AutoGen v0.4 adapter logic
|
||||
logger.warning("AutoGen v0.4 adapter is not yet fully implemented")
|
||||
return "### AutoGen v0.4 Output ###\nAutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support."
|
||||
raise NotImplementedError("AutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support.")
|
||||
|
||||
|
||||
class AG2Adapter(BaseFrameworkAdapter):
|
||||
@@ -148,7 +176,17 @@ class AG2Adapter(BaseFrameworkAdapter):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
|
||||
def run(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
llm_config: List[Dict],
|
||||
topic: str,
|
||||
*,
|
||||
tools_dict: Optional[Dict[str, Any]] = None,
|
||||
agent_callback: Optional[Callable] = None,
|
||||
task_callback: Optional[Callable] = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Run AG2 with given configuration.
|
||||
|
||||
@@ -156,6 +194,10 @@ class AG2Adapter(BaseFrameworkAdapter):
|
||||
config: AG2 configuration with agents
|
||||
llm_config: LLM configuration list
|
||||
topic: Topic for the tasks
|
||||
tools_dict: Available tools dictionary
|
||||
agent_callback: Callback for agent events
|
||||
task_callback: Callback for task events
|
||||
cli_config: CLI configuration
|
||||
|
||||
Returns:
|
||||
Execution result as string
|
||||
@@ -166,4 +208,4 @@ class AG2Adapter(BaseFrameworkAdapter):
|
||||
# For now, return a proper error message instead of delegating
|
||||
# TODO: Implement full AG2 adapter logic
|
||||
logger.warning("AG2 adapter is not yet fully implemented")
|
||||
return "### AG2 Output ###\nAG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support."
|
||||
raise NotImplementedError("AG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support.")
|
||||
@@ -3,148 +3,68 @@ Framework Adapter Registry for PraisonAI.
|
||||
|
||||
Provides a registry pattern for managing framework adapters with entry points support,
|
||||
enabling dynamic registration and discovery of framework adapters.
|
||||
Mirrors the design of integrations/registry.py for consistency.
|
||||
Uses dependency injection instead of singleton pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from importlib.metadata import entry_points
|
||||
from typing import Dict, Type, Optional
|
||||
import logging
|
||||
|
||||
from .base import FrameworkAdapter
|
||||
from .._registry import PluginRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FrameworkAdapterRegistry:
|
||||
def _crewai_loader():
|
||||
from .crewai_adapter import CrewAIAdapter
|
||||
return CrewAIAdapter
|
||||
|
||||
def _autogen_loader():
|
||||
from .autogen_adapter import AutoGenAdapter
|
||||
return AutoGenAdapter
|
||||
|
||||
def _autogen_v4_loader():
|
||||
from .autogen_adapter import AutoGenV4Adapter
|
||||
return AutoGenV4Adapter
|
||||
|
||||
def _ag2_loader():
|
||||
from .autogen_adapter import AG2Adapter
|
||||
return AG2Adapter
|
||||
|
||||
def _praisonai_loader():
|
||||
from .praisonai_adapter import PraisonAIAdapter
|
||||
return PraisonAIAdapter
|
||||
|
||||
# Built-in framework adapters with lazy loading
|
||||
_BUILTIN_ADAPTERS = {
|
||||
"crewai": _crewai_loader,
|
||||
"autogen": _autogen_loader,
|
||||
"autogen_v4": _autogen_v4_loader,
|
||||
"ag2": _ag2_loader,
|
||||
"praisonai": _praisonai_loader,
|
||||
}
|
||||
|
||||
class FrameworkAdapterRegistry(PluginRegistry[FrameworkAdapter]):
|
||||
"""
|
||||
Registry for framework adapters.
|
||||
|
||||
Provides centralized management of framework adapters with support
|
||||
for dynamic registration, entry points discovery, and availability checking.
|
||||
|
||||
Uses singleton pattern to ensure consistent state across the application.
|
||||
Uses dependency injection pattern instead of singleton.
|
||||
"""
|
||||
|
||||
_instance: Optional["FrameworkAdapterRegistry"] = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the registry with built-in adapters."""
|
||||
self._adapters: Dict[str, Type[FrameworkAdapter]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._register_builtin()
|
||||
self._register_entry_points()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "FrameworkAdapterRegistry":
|
||||
"""
|
||||
Get the singleton registry instance.
|
||||
|
||||
Returns:
|
||||
FrameworkAdapterRegistry: The singleton registry
|
||||
"""
|
||||
if cls._instance is None:
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def _register_builtin(self) -> None:
|
||||
"""Register built-in framework adapters with lazy imports."""
|
||||
# Lazy, optional imports - mirrors integrations/registry.py pattern
|
||||
try:
|
||||
from .crewai_adapter import CrewAIAdapter
|
||||
self._adapters["crewai"] = CrewAIAdapter
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .autogen_adapter import AutoGenAdapter, AutoGenV4Adapter, AG2Adapter
|
||||
self._adapters["autogen"] = AutoGenAdapter
|
||||
self._adapters["autogen_v4"] = AutoGenV4Adapter
|
||||
self._adapters["ag2"] = AG2Adapter
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .praisonai_adapter import PraisonAIAdapter
|
||||
self._adapters["praisonai"] = PraisonAIAdapter
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
def _register_entry_points(self) -> None:
|
||||
"""Register framework adapters from entry points."""
|
||||
try:
|
||||
for ep in entry_points(group="praisonai.framework_adapters"):
|
||||
try:
|
||||
adapter_class = ep.load()
|
||||
self._adapters[ep.name] = adapter_class
|
||||
except Exception:
|
||||
# Do not break framework dispatch because one plugin is broken.
|
||||
# Surface via structured logging instead of swallowing silently.
|
||||
logger.warning(
|
||||
"Failed to load framework adapter %r from entry point",
|
||||
ep.name,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
# entry_points() might not be available in older Python versions
|
||||
# or in certain packaging environments
|
||||
logger.debug("Entry points not available for framework adapters")
|
||||
|
||||
def register(self, name: str, adapter_class: Type[FrameworkAdapter]) -> None:
|
||||
"""
|
||||
Register a new framework adapter.
|
||||
|
||||
Args:
|
||||
name: Unique name for the adapter
|
||||
adapter_class: The adapter class (must implement FrameworkAdapter protocol)
|
||||
"""
|
||||
# Note: We don't enforce strict type checking here since FrameworkAdapter is a Protocol
|
||||
# and isinstance() doesn't work with Protocols. The runtime will catch typing issues.
|
||||
with self._lock:
|
||||
self._adapters[name] = adapter_class
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
Unregister a framework adapter.
|
||||
|
||||
Args:
|
||||
name: Name of the adapter to unregister
|
||||
|
||||
Returns:
|
||||
bool: True if the adapter was found and removed, False otherwise
|
||||
"""
|
||||
with self._lock:
|
||||
return self._adapters.pop(name, None) is not None
|
||||
|
||||
def create(self, name: str) -> FrameworkAdapter:
|
||||
"""
|
||||
Create an instance of the specified framework adapter.
|
||||
|
||||
Args:
|
||||
name: Name of the adapter to create
|
||||
|
||||
Returns:
|
||||
FrameworkAdapter: Instance of the adapter
|
||||
|
||||
Raises:
|
||||
ValueError: If the adapter is not found
|
||||
"""
|
||||
with self._lock:
|
||||
adapter_class = self._adapters.get(name)
|
||||
|
||||
if adapter_class is None:
|
||||
raise ValueError(
|
||||
f"Unsupported framework: {name}. "
|
||||
f"Registered: {sorted(self._adapters)}"
|
||||
)
|
||||
|
||||
return adapter_class()
|
||||
super().__init__(
|
||||
entry_point_group="praisonai.framework_adapters",
|
||||
builtins=_BUILTIN_ADAPTERS
|
||||
)
|
||||
|
||||
# Backward compatibility aliases - delegate to parent methods
|
||||
def list_registered(self) -> list[str]:
|
||||
"""
|
||||
List all registered framework adapter names.
|
||||
@@ -152,8 +72,7 @@ class FrameworkAdapterRegistry:
|
||||
Returns:
|
||||
list[str]: Sorted list of registered adapter names
|
||||
"""
|
||||
with self._lock:
|
||||
return sorted(self._adapters)
|
||||
return self.list_names()
|
||||
|
||||
def is_available(self, name: str) -> bool:
|
||||
"""
|
||||
@@ -174,4 +93,19 @@ class FrameworkAdapterRegistry:
|
||||
return adapter.is_available()
|
||||
except Exception:
|
||||
logger.warning("is_available() raised for adapter %r", name, exc_info=True)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# Default registry (lazy, module-private). NOT exposed as a singleton getter.
|
||||
_default_registry: Optional[FrameworkAdapterRegistry] = None
|
||||
_default_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_default_registry() -> FrameworkAdapterRegistry:
|
||||
"""Return the process-default registry. Prefer DI; use this only at the edge."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
with _default_lock:
|
||||
if _default_registry is None:
|
||||
_default_registry = FrameworkAdapterRegistry()
|
||||
return _default_registry
|
||||
@@ -5,7 +5,7 @@ Provides early validation of framework availability to fail fast at CLI entry
|
||||
rather than inside run() methods after expensive setup work.
|
||||
"""
|
||||
|
||||
from .registry import FrameworkAdapterRegistry
|
||||
from .registry import get_default_registry
|
||||
|
||||
|
||||
# Install hints for common frameworks
|
||||
@@ -26,7 +26,7 @@ def assert_framework_available(name: str) -> None:
|
||||
Raises:
|
||||
ImportError: If framework is not available with actionable install hint
|
||||
"""
|
||||
registry = FrameworkAdapterRegistry.get_instance()
|
||||
registry = get_default_registry()
|
||||
|
||||
if not registry.is_available(name):
|
||||
hint = _INSTALL_HINTS.get(name, f"pip install {name}")
|
||||
|
||||
@@ -256,17 +256,42 @@ class ExecApprovalManager:
|
||||
logger.debug("Expired approval request: %s", rid)
|
||||
|
||||
|
||||
# ── Module-level singleton accessor ──────────────────────────────────
|
||||
# ── Manager accessor (non-singleton) ─────────────────────────────────
|
||||
|
||||
_global_manager: Optional[ExecApprovalManager] = None
|
||||
# Default manager for CLI/backward compatibility
|
||||
_default_manager: Optional[ExecApprovalManager] = None
|
||||
_manager_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
|
||||
"""Get or create the global :class:`ExecApprovalManager` singleton."""
|
||||
global _global_manager
|
||||
if _global_manager is None:
|
||||
def get_default_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
|
||||
"""Get the default exec approval manager (for CLI and backward compatibility).
|
||||
|
||||
Args:
|
||||
ttl: Seconds before approval requests auto-expire (default 5 min).
|
||||
|
||||
Returns:
|
||||
The default ExecApprovalManager instance.
|
||||
"""
|
||||
global _default_manager
|
||||
if _default_manager is None:
|
||||
with _manager_lock:
|
||||
if _global_manager is None:
|
||||
_global_manager = ExecApprovalManager(ttl=ttl)
|
||||
return _global_manager
|
||||
if _default_manager is None:
|
||||
_default_manager = ExecApprovalManager(ttl=ttl)
|
||||
return _default_manager
|
||||
|
||||
|
||||
def get_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
|
||||
"""Backward compatibility alias for get_default_exec_approval_manager."""
|
||||
return get_default_exec_approval_manager(ttl)
|
||||
|
||||
|
||||
def create_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
|
||||
"""Create a new exec approval manager instance (for per-agent use).
|
||||
|
||||
Args:
|
||||
ttl: Seconds before approval requests auto-expire (default 5 min).
|
||||
|
||||
Returns:
|
||||
A new ExecApprovalManager instance.
|
||||
"""
|
||||
return ExecApprovalManager(ttl=ttl)
|
||||
@@ -1,49 +1,33 @@
|
||||
# Lazy availability checks using find_spec (no actual import)
|
||||
# This avoids the ~3200ms crewai import at CLI startup
|
||||
"""Lazy access to autogen_tools, with `find_spec` startup probes for crewai/autogen."""
|
||||
import importlib.util
|
||||
from praisonai.auto import _load_optional
|
||||
|
||||
CREWAI_AVAILABLE = importlib.util.find_spec("crewai") is not None
|
||||
AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
|
||||
PRAISONAI_TOOLS_AVAILABLE = False
|
||||
PRAISONAI_TOOLS_PACKAGE_AVAILABLE = importlib.util.find_spec("praisonai_tools") is not None
|
||||
|
||||
|
||||
def _load_autogen_tools():
|
||||
# Load the autogen_tools module unconditionally since it can work with just praisonai_tools
|
||||
import importlib
|
||||
return importlib.import_module(__name__ + ".autogen_tools")
|
||||
|
||||
# Guard against recursive imports
|
||||
_importing_autogen_tools = False
|
||||
_autogen_tools_module = None
|
||||
|
||||
# Lazy import helper - only imports when actually needed
|
||||
def _get_autogen_tools():
|
||||
"""Lazy import autogen_tools only when needed."""
|
||||
global PRAISONAI_TOOLS_AVAILABLE, _importing_autogen_tools, _autogen_tools_module
|
||||
|
||||
# Return cached module if already imported
|
||||
if _autogen_tools_module is not None:
|
||||
return _autogen_tools_module
|
||||
|
||||
# Prevent recursive import
|
||||
if _importing_autogen_tools:
|
||||
return None
|
||||
|
||||
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE:
|
||||
try:
|
||||
_importing_autogen_tools = True
|
||||
from . import autogen_tools
|
||||
_autogen_tools_module = autogen_tools
|
||||
PRAISONAI_TOOLS_AVAILABLE = True
|
||||
return autogen_tools
|
||||
except ImportError:
|
||||
pass
|
||||
finally:
|
||||
_importing_autogen_tools = False
|
||||
return None
|
||||
"""Thread-safe, single-shot lazy import. Negative result is cached too."""
|
||||
return _load_optional("inbuilt_autogen_tools", _load_autogen_tools)
|
||||
|
||||
# For backward compatibility, provide __getattr__ for lazy access
|
||||
|
||||
def _praisonai_tools_available() -> bool:
|
||||
"""Read-only accessor — never mutate this from inside a function."""
|
||||
return PRAISONAI_TOOLS_PACKAGE_AVAILABLE or _get_autogen_tools() is not None
|
||||
|
||||
|
||||
# Backward-compat: keep the constant, computed lazily on attribute access.
|
||||
def __getattr__(name):
|
||||
"""Lazy load autogen_tools exports on demand."""
|
||||
# Avoid recursion during import
|
||||
if _importing_autogen_tools:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
if name == "PRAISONAI_TOOLS_AVAILABLE":
|
||||
return _praisonai_tools_available()
|
||||
tools_module = _get_autogen_tools()
|
||||
if tools_module and hasattr(tools_module, name):
|
||||
if tools_module is not None and hasattr(tools_module, name):
|
||||
return getattr(tools_module, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -11,10 +11,10 @@ Features:
|
||||
- Backward compatibility with existing get_available_integrations()
|
||||
|
||||
Usage:
|
||||
from praisonai.integrations.registry import ExternalAgentRegistry
|
||||
from praisonai.integrations.registry import get_default_registry
|
||||
|
||||
# Get singleton registry
|
||||
registry = ExternalAgentRegistry.get_instance()
|
||||
# Get default registry (or inject custom for multi-tenant)
|
||||
registry = get_default_registry()
|
||||
|
||||
# Register custom integration
|
||||
registry.register('my-agent', MyCustomIntegration)
|
||||
@@ -30,67 +30,50 @@ import threading
|
||||
from typing import Dict, Type, Optional, Any, List
|
||||
|
||||
from .base import BaseCLIIntegration
|
||||
from .._registry import PluginRegistry
|
||||
|
||||
|
||||
class ExternalAgentRegistry:
|
||||
def _claude_code_loader():
|
||||
from .claude_code import ClaudeCodeIntegration
|
||||
return ClaudeCodeIntegration
|
||||
|
||||
def _gemini_cli_loader():
|
||||
from .gemini_cli import GeminiCLIIntegration
|
||||
return GeminiCLIIntegration
|
||||
|
||||
def _codex_cli_loader():
|
||||
from .codex_cli import CodexCLIIntegration
|
||||
return CodexCLIIntegration
|
||||
|
||||
def _cursor_cli_loader():
|
||||
from .cursor_cli import CursorCLIIntegration
|
||||
return CursorCLIIntegration
|
||||
|
||||
# Built-in external agent integrations with lazy loading
|
||||
_BUILTIN_INTEGRATIONS = {
|
||||
"claude": _claude_code_loader,
|
||||
"gemini": _gemini_cli_loader,
|
||||
"codex": _codex_cli_loader,
|
||||
"cursor": _cursor_cli_loader,
|
||||
}
|
||||
|
||||
|
||||
class ExternalAgentRegistry(PluginRegistry[BaseCLIIntegration]):
|
||||
"""
|
||||
Registry for external CLI integrations.
|
||||
|
||||
Provides centralized management of external agent integrations
|
||||
with support for dynamic registration and availability checking.
|
||||
|
||||
Uses singleton pattern to ensure consistent state across the application.
|
||||
Uses dependency injection pattern instead of singleton.
|
||||
"""
|
||||
|
||||
_instance: Optional['ExternalAgentRegistry'] = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the registry with built-in integrations."""
|
||||
self._integrations: Dict[str, Type[BaseCLIIntegration]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._register_builtin_integrations()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'ExternalAgentRegistry':
|
||||
"""
|
||||
Get the singleton registry instance.
|
||||
|
||||
Returns:
|
||||
ExternalAgentRegistry: The singleton registry
|
||||
"""
|
||||
if cls._instance is None:
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def _register_builtin_integrations(self):
|
||||
"""Register built-in integrations."""
|
||||
# Lazy imports to avoid circular dependencies and performance impact
|
||||
try:
|
||||
from .claude_code import ClaudeCodeIntegration
|
||||
self._integrations['claude'] = ClaudeCodeIntegration
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .gemini_cli import GeminiCLIIntegration
|
||||
self._integrations['gemini'] = GeminiCLIIntegration
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .codex_cli import CodexCLIIntegration
|
||||
self._integrations['codex'] = CodexCLIIntegration
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .cursor_cli import CursorCLIIntegration
|
||||
self._integrations['cursor'] = CursorCLIIntegration
|
||||
except ImportError:
|
||||
pass
|
||||
super().__init__(
|
||||
entry_point_group="praisonai.external_agents",
|
||||
builtins=_BUILTIN_INTEGRATIONS
|
||||
)
|
||||
|
||||
def register(self, name: str, integration_class: Type[BaseCLIIntegration]) -> None:
|
||||
"""
|
||||
@@ -108,24 +91,19 @@ class ExternalAgentRegistry:
|
||||
f"Integration class {integration_class.__name__} must inherit from BaseCLIIntegration"
|
||||
)
|
||||
|
||||
# Thread-safe registration
|
||||
with self._lock:
|
||||
self._integrations[name] = integration_class
|
||||
# Delegate to parent
|
||||
super().register(name, integration_class)
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
# Backward compatibility methods
|
||||
def list_registered(self) -> List[str]:
|
||||
"""
|
||||
Unregister an external agent integration.
|
||||
List all registered integration names.
|
||||
|
||||
Args:
|
||||
name: Name of the integration to unregister
|
||||
|
||||
Returns:
|
||||
bool: True if the integration was found and removed, False otherwise
|
||||
List[str]: List of registered integration names
|
||||
"""
|
||||
# Thread-safe unregistration with atomic check-then-delete
|
||||
with self._lock:
|
||||
return self._integrations.pop(name, None) is not None
|
||||
|
||||
return self.list_names()
|
||||
|
||||
def create(self, name: str, **kwargs: Any) -> Optional[BaseCLIIntegration]:
|
||||
"""
|
||||
Create an instance of the specified integration.
|
||||
@@ -137,23 +115,10 @@ class ExternalAgentRegistry:
|
||||
Returns:
|
||||
BaseCLIIntegration: Instance of the integration, or None if not found
|
||||
"""
|
||||
with self._lock:
|
||||
integration_class = self._integrations.get(name)
|
||||
|
||||
if integration_class is None:
|
||||
try:
|
||||
return super().create(name, **kwargs)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return integration_class(**kwargs)
|
||||
|
||||
def list_registered(self) -> List[str]:
|
||||
"""
|
||||
List all registered integration names.
|
||||
|
||||
Returns:
|
||||
List[str]: List of registered integration names
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._integrations.keys())
|
||||
|
||||
async def get_available(self) -> Dict[str, bool]:
|
||||
"""
|
||||
@@ -165,8 +130,9 @@ class ExternalAgentRegistry:
|
||||
import inspect
|
||||
availability = {}
|
||||
|
||||
# Get snapshot of all items from parent class
|
||||
with self._lock:
|
||||
snapshot = list(self._integrations.items())
|
||||
snapshot = list(self._items.items())
|
||||
|
||||
for name, integration_class in snapshot:
|
||||
try:
|
||||
@@ -201,15 +167,30 @@ class ExternalAgentRegistry:
|
||||
return [name for name, available in availability.items() if available]
|
||||
|
||||
|
||||
# Factory functions for convenient access
|
||||
# Default registry (lazy, module-private). NOT exposed as a singleton getter.
|
||||
_default_registry: Optional[ExternalAgentRegistry] = None
|
||||
_default_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_default_registry() -> ExternalAgentRegistry:
|
||||
"""Return the process-default registry. Prefer DI; use this only at the edge."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
with _default_lock:
|
||||
if _default_registry is None:
|
||||
_default_registry = ExternalAgentRegistry()
|
||||
return _default_registry
|
||||
|
||||
|
||||
# Factory functions for convenient access - using default registry
|
||||
def get_registry() -> ExternalAgentRegistry:
|
||||
"""
|
||||
Get the singleton external agent registry.
|
||||
Get the default external agent registry.
|
||||
|
||||
Returns:
|
||||
ExternalAgentRegistry: The singleton registry instance
|
||||
ExternalAgentRegistry: The default registry instance
|
||||
"""
|
||||
return ExternalAgentRegistry.get_instance()
|
||||
return get_default_registry()
|
||||
|
||||
|
||||
def register_integration(name: str, integration_class: Type[BaseCLIIntegration]) -> None:
|
||||
@@ -220,7 +201,7 @@ def register_integration(name: str, integration_class: Type[BaseCLIIntegration])
|
||||
name: Unique name for the integration
|
||||
integration_class: The integration class (must inherit from BaseCLIIntegration)
|
||||
"""
|
||||
registry = get_registry()
|
||||
registry = get_default_registry()
|
||||
registry.register(name, integration_class)
|
||||
|
||||
|
||||
@@ -235,7 +216,7 @@ def create_integration(name: str, **kwargs: Any) -> Optional[BaseCLIIntegration]
|
||||
Returns:
|
||||
BaseCLIIntegration: Instance of the integration, or None if not found
|
||||
"""
|
||||
registry = get_registry()
|
||||
registry = get_default_registry()
|
||||
return registry.create(name, **kwargs)
|
||||
|
||||
|
||||
@@ -249,7 +230,7 @@ def get_available_integrations() -> Dict[str, bool]:
|
||||
Dict[str, bool]: Mapping of integration name to availability status
|
||||
"""
|
||||
import asyncio
|
||||
registry = get_registry()
|
||||
registry = get_default_registry()
|
||||
|
||||
from .._async_bridge import run_sync
|
||||
return run_sync(registry.get_available())
|
||||
@@ -7,7 +7,7 @@ Defines the data structures for job submission, status, and results.
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
import socket
|
||||
import ipaddress
|
||||
@@ -177,7 +177,7 @@ class Job(BaseModel):
|
||||
run_id: Optional[str] = Field(None)
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
started_at: Optional[datetime] = Field(None)
|
||||
completed_at: Optional[datetime] = Field(None)
|
||||
|
||||
@@ -194,7 +194,7 @@ class Job(BaseModel):
|
||||
"""Calculate job duration."""
|
||||
if self.started_at is None:
|
||||
return None
|
||||
end_time = self.completed_at or datetime.utcnow()
|
||||
end_time = self.completed_at or datetime.now(timezone.utc)
|
||||
return (end_time - self.started_at).total_seconds()
|
||||
|
||||
@property
|
||||
@@ -247,26 +247,26 @@ class Job(BaseModel):
|
||||
def start(self):
|
||||
"""Mark job as started."""
|
||||
self.status = JobStatus.RUNNING
|
||||
self.started_at = datetime.utcnow()
|
||||
self.started_at = datetime.now(timezone.utc)
|
||||
|
||||
def succeed(self, result: Any, metrics: Optional[Dict[str, Any]] = None):
|
||||
"""Mark job as succeeded."""
|
||||
self.status = JobStatus.SUCCEEDED
|
||||
self.result = result
|
||||
self.metrics = metrics
|
||||
self.completed_at = datetime.utcnow()
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.progress_percentage = 100.0
|
||||
|
||||
def fail(self, error: str):
|
||||
"""Mark job as failed."""
|
||||
self.status = JobStatus.FAILED
|
||||
self.error = error
|
||||
self.completed_at = datetime.utcnow()
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
|
||||
def cancel(self):
|
||||
"""Mark job as cancelled."""
|
||||
self.status = JobStatus.CANCELLED
|
||||
self.completed_at = datetime.utcnow()
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self._cancel_requested = True
|
||||
|
||||
def update_progress(
|
||||
|
||||
@@ -7,7 +7,7 @@ Provides HTTP endpoints for job management.
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Header, Request, Query, Response
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
@@ -304,7 +304,7 @@ def create_router(store: JobStore, executor: JobExecutor) -> APIRouter:
|
||||
# Send heartbeat
|
||||
yield {
|
||||
"event": "heartbeat",
|
||||
"data": f'{{"timestamp": "{datetime.utcnow().isoformat()}"}}'
|
||||
"data": f'{{"timestamp": "{datetime.now(timezone.utc).isoformat()}"}}'
|
||||
}
|
||||
finally:
|
||||
executor.unregister_progress_callback(job_id)
|
||||
|
||||
@@ -83,8 +83,9 @@ class InMemoryJobStore(JobStore):
|
||||
def _get_lock(self) -> asyncio.Lock:
|
||||
"""Get the asyncio lock, creating it lazily if needed.
|
||||
|
||||
This deferred creation is required for Python 3.9 compatibility
|
||||
where asyncio.Lock() calls get_event_loop() at creation time.
|
||||
Deferred creation avoids touching the event loop at __init__ time,
|
||||
which keeps the store safe to instantiate from sync contexts on
|
||||
Python 3.9 (where asyncio.Lock() implicitly calls get_event_loop()).
|
||||
"""
|
||||
if self.__lock is None:
|
||||
self.__lock = asyncio.Lock()
|
||||
@@ -125,14 +126,14 @@ class InMemoryJobStore(JobStore):
|
||||
|
||||
async def get(self, job_id: str) -> Optional[Job]:
|
||||
"""Get a job by ID."""
|
||||
return self._jobs.get(job_id)
|
||||
async with self._get_lock():
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
async def get_by_idempotency_key(self, key: str) -> Optional[Job]:
|
||||
"""Get a job by idempotency key."""
|
||||
job_id = self._idempotency_keys.get(key)
|
||||
if job_id:
|
||||
return self._jobs.get(job_id)
|
||||
return None
|
||||
async with self._get_lock():
|
||||
job_id = self._idempotency_keys.get(key)
|
||||
return self._jobs.get(job_id) if job_id else None
|
||||
|
||||
async def list_jobs(
|
||||
self,
|
||||
@@ -142,9 +143,10 @@ class InMemoryJobStore(JobStore):
|
||||
offset: int = 0
|
||||
) -> List[Job]:
|
||||
"""List jobs with optional filters."""
|
||||
jobs = list(self._jobs.values())
|
||||
async with self._get_lock():
|
||||
jobs = list(self._jobs.values())
|
||||
|
||||
# Apply filters
|
||||
# Apply filters outside the lock - values() snapshot is already taken
|
||||
if status:
|
||||
jobs = [j for j in jobs if j.status == status]
|
||||
if session_id:
|
||||
@@ -162,7 +164,8 @@ class InMemoryJobStore(JobStore):
|
||||
session_id: Optional[str] = None
|
||||
) -> int:
|
||||
"""Count jobs matching filters."""
|
||||
jobs = list(self._jobs.values())
|
||||
async with self._get_lock():
|
||||
jobs = list(self._jobs.values())
|
||||
|
||||
if status:
|
||||
jobs = [j for j in jobs if j.status == status]
|
||||
@@ -201,15 +204,20 @@ class InMemoryJobStore(JobStore):
|
||||
|
||||
return len(to_remove)
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get store statistics."""
|
||||
async with self._get_lock():
|
||||
# Take a snapshot to avoid RuntimeError if the dict is mutated concurrently
|
||||
jobs_snapshot = list(self._jobs.values())
|
||||
idempotency_count = len(self._idempotency_keys)
|
||||
|
||||
status_counts = {}
|
||||
for job in self._jobs.values():
|
||||
for job in jobs_snapshot:
|
||||
status_counts[job.status.value] = status_counts.get(job.status.value, 0) + 1
|
||||
|
||||
return {
|
||||
"total_jobs": len(self._jobs),
|
||||
"idempotency_keys": len(self._idempotency_keys),
|
||||
"total_jobs": len(jobs_snapshot),
|
||||
"idempotency_keys": idempotency_count,
|
||||
"max_jobs": self._max_jobs,
|
||||
"status_counts": status_counts
|
||||
}
|
||||
@@ -22,6 +22,7 @@ Example:
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||
import threading
|
||||
from .._registry import PluginRegistry
|
||||
|
||||
|
||||
# Type aliases
|
||||
@@ -30,171 +31,46 @@ ProviderFactory = Callable[[str, Optional[Dict[str, Any]]], Any]
|
||||
ProviderType = Union[ProviderClass, ProviderFactory]
|
||||
|
||||
|
||||
class LLMProviderRegistry:
|
||||
class LLMProviderRegistry(PluginRegistry[ProviderType]):
|
||||
"""
|
||||
Registry for LLM providers.
|
||||
|
||||
Manages registration and resolution of LLM providers by name.
|
||||
Supports lazy loading, aliases, and isolated instances.
|
||||
Thread-safe for concurrent operations.
|
||||
|
||||
Now inherits from PluginRegistry to eliminate duplication.
|
||||
"""
|
||||
|
||||
_instance: Optional["LLMProviderRegistry"] = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty registry."""
|
||||
self._providers: Dict[str, ProviderType] = {}
|
||||
self._aliases: Dict[str, str] = {} # alias -> canonical name
|
||||
self._lock = threading.RLock() # RLock for re-entrant calls
|
||||
# Register built-in providers during initialization
|
||||
_register_builtin_providers(self)
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "LLMProviderRegistry":
|
||||
"""
|
||||
Get the singleton registry instance.
|
||||
"""Initialize LLM provider registry."""
|
||||
# Get built-in provider loaders
|
||||
builtins = _get_builtin_provider_loaders()
|
||||
|
||||
Returns:
|
||||
LLMProviderRegistry: The singleton registry instance
|
||||
"""
|
||||
if cls._instance is None:
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
# Initialize parent with entry points and builtins
|
||||
super().__init__(
|
||||
entry_point_group="praisonai.llm_providers",
|
||||
builtins=builtins
|
||||
)
|
||||
|
||||
# Register aliases for built-in providers
|
||||
self._register_builtin_aliases()
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
provider: ProviderType,
|
||||
*,
|
||||
override: bool = False,
|
||||
aliases: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Register a provider by name.
|
||||
def _register_builtin_aliases(self) -> None:
|
||||
"""Register aliases for built-in providers."""
|
||||
aliases_map = [
|
||||
("openai", ("oai",)),
|
||||
("anthropic", ("claude",)),
|
||||
("google", ("gemini", "google_genai")),
|
||||
]
|
||||
|
||||
Args:
|
||||
name: Provider name (e.g., 'cloudflare', 'ollama')
|
||||
provider: Provider class or factory function
|
||||
override: Allow overwriting existing registration
|
||||
aliases: Additional names that resolve to this provider
|
||||
|
||||
Raises:
|
||||
ValueError: If name is already registered (unless override=True)
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Check for existing registration
|
||||
if normalized_name in self._providers and not override:
|
||||
raise ValueError(
|
||||
f"Provider '{name}' is already registered. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
|
||||
# Check if this canonical name conflicts with existing alias
|
||||
if normalized_name in self._aliases and not override:
|
||||
existing_target = self._aliases[normalized_name]
|
||||
raise ValueError(
|
||||
f"Cannot register provider '{name}' - it conflicts with existing alias "
|
||||
f"(currently points to '{existing_target}'). Use override=True to replace it."
|
||||
)
|
||||
|
||||
# If override=True, clean up any existing alias before registering canonical name
|
||||
if override and normalized_name in self._aliases:
|
||||
del self._aliases[normalized_name]
|
||||
|
||||
self._providers[normalized_name] = provider
|
||||
|
||||
# Register aliases
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
normalized_alias = alias.lower()
|
||||
# Check collision with existing provider name
|
||||
if normalized_alias in self._providers and not override:
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' conflicts with existing provider name. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
# Check collision with existing alias
|
||||
if normalized_alias in self._aliases and not override:
|
||||
existing_target = self._aliases[normalized_alias]
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' is already registered (points to '{existing_target}'). "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
self._aliases[normalized_alias] = normalized_name
|
||||
for name, aliases in aliases_map:
|
||||
if name in self._items: # Only add aliases if the provider loaded
|
||||
for alias in aliases:
|
||||
self._aliases[alias.lower()] = name.lower()
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
Unregister a provider by name.
|
||||
|
||||
Args:
|
||||
name: Provider name to unregister
|
||||
|
||||
Returns:
|
||||
True if provider was unregistered, False if not found
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Check if it's an alias
|
||||
if normalized_name in self._aliases:
|
||||
del self._aliases[normalized_name]
|
||||
return True
|
||||
|
||||
# Check if it's a canonical name
|
||||
if normalized_name in self._providers:
|
||||
# Remove all aliases pointing to this provider
|
||||
aliases_to_remove = [
|
||||
alias for alias, canonical in self._aliases.items()
|
||||
if canonical == normalized_name
|
||||
]
|
||||
for alias in aliases_to_remove:
|
||||
del self._aliases[alias]
|
||||
|
||||
del self._providers[normalized_name]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
"""
|
||||
Check if a provider is registered.
|
||||
|
||||
Args:
|
||||
name: Provider name to check
|
||||
|
||||
Returns:
|
||||
True if provider is registered
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
return normalized_name in self._providers or normalized_name in self._aliases
|
||||
|
||||
def list(self) -> List[str]:
|
||||
"""
|
||||
List all registered provider names (canonical names only).
|
||||
|
||||
Returns:
|
||||
List of provider names
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._providers.keys())
|
||||
|
||||
def list_all(self) -> List[str]:
|
||||
"""
|
||||
List all names including aliases.
|
||||
|
||||
Returns:
|
||||
List of all registered names and aliases
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._providers.keys()) + list(self._aliases.keys())
|
||||
|
||||
def resolve(
|
||||
def resolve_provider(
|
||||
self,
|
||||
name: str,
|
||||
model_id: str,
|
||||
@@ -214,23 +90,69 @@ class LLMProviderRegistry:
|
||||
Raises:
|
||||
ValueError: If provider not found
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Resolve alias to canonical name
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
|
||||
provider = self._providers.get(canonical_name)
|
||||
if provider is None:
|
||||
available = list(self._providers.keys()) # Don't call self.list() to avoid double-locking
|
||||
raise ValueError(
|
||||
f"Unknown provider: '{name}'. "
|
||||
f"Available providers: {', '.join(available) if available else 'none'}. "
|
||||
f"Register a custom provider with register_llm_provider('{name}', YourProviderClass)."
|
||||
)
|
||||
|
||||
provider_cls = self.resolve(name)
|
||||
# Create instance outside the lock
|
||||
return provider(model_id, config)
|
||||
return provider_cls(model_id, config)
|
||||
|
||||
def register_provider(
|
||||
self,
|
||||
name: str,
|
||||
provider: ProviderType,
|
||||
*,
|
||||
override: bool = False,
|
||||
aliases: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Register a provider by name with override validation.
|
||||
|
||||
Args:
|
||||
name: Provider name (e.g., 'cloudflare', 'ollama')
|
||||
provider: Provider class or factory function
|
||||
override: Allow overwriting existing registration
|
||||
aliases: Additional names that resolve to this provider
|
||||
|
||||
Raises:
|
||||
ValueError: If name is already registered (unless override=True)
|
||||
"""
|
||||
# Check for existing registration if override is False
|
||||
if not override and self.is_available(name):
|
||||
raise ValueError(
|
||||
f"Provider '{name}' is already registered. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
|
||||
# Check alias conflicts if override is False
|
||||
if not override and aliases:
|
||||
for alias in aliases:
|
||||
if self.is_available(alias):
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' is already registered. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
|
||||
# If override=True, unregister existing entries first
|
||||
if override:
|
||||
self.unregister(name)
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
self.unregister(alias)
|
||||
|
||||
# Use parent class registration
|
||||
self.register(name, provider, aliases=aliases)
|
||||
|
||||
# Inherit unregister, is_available, list_names from parent
|
||||
# Add backwards compatibility aliases
|
||||
def has(self, name: str) -> bool:
|
||||
"""Check if a provider is registered (alias for is_available)."""
|
||||
return self.is_available(name)
|
||||
|
||||
def list(self) -> List[str]:
|
||||
"""List provider names (alias for list_names)."""
|
||||
return self.list_names()
|
||||
|
||||
def list_all(self) -> List[str]:
|
||||
"""List all names including aliases (alias for list_all_names)."""
|
||||
return self.list_all_names()
|
||||
|
||||
def get(self, name: str) -> Optional[ProviderType]:
|
||||
"""
|
||||
@@ -242,30 +164,40 @@ class LLMProviderRegistry:
|
||||
Returns:
|
||||
Provider class/factory or None
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
return self._providers.get(canonical_name)
|
||||
try:
|
||||
return self.resolve(name)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Default Registry Singleton
|
||||
# Default Registry Instance (No Singleton)
|
||||
# ============================================================================
|
||||
|
||||
# Default module-level registry instance
|
||||
_default_llm_registry: Optional[LLMProviderRegistry] = None
|
||||
_default_llm_lock = threading.Lock()
|
||||
|
||||
def get_default_llm_registry() -> LLMProviderRegistry:
|
||||
"""
|
||||
Get the default global LLM provider registry.
|
||||
|
||||
This is the registry used by create_llm_provider() when no custom registry
|
||||
is specified. Uses the thread-safe singleton pattern.
|
||||
is specified. Uses lazy initialization pattern.
|
||||
"""
|
||||
return LLMProviderRegistry.get_instance()
|
||||
global _default_llm_registry
|
||||
if _default_llm_registry is None:
|
||||
with _default_llm_lock:
|
||||
if _default_llm_registry is None:
|
||||
_default_llm_registry = LLMProviderRegistry()
|
||||
return _default_llm_registry
|
||||
|
||||
|
||||
def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
|
||||
def _get_builtin_provider_loaders() -> Dict[str, Callable[[], ProviderType]]:
|
||||
"""
|
||||
Register built-in providers to a registry.
|
||||
Get built-in provider loaders.
|
||||
|
||||
Returns a dict of name -> loader function that creates provider factories.
|
||||
Uses lazy loading to avoid importing heavy dependencies at module load time.
|
||||
"""
|
||||
# Built-in adapter that wraps LiteLLM so create_llm_provider works out of the box.
|
||||
@@ -324,13 +256,22 @@ def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
|
||||
return _LiteLLMProvider(model_id, cfg)
|
||||
return factory
|
||||
|
||||
# Build loader dict for the canonical PluginRegistry
|
||||
loaders: Dict[str, Callable[[], ProviderType]] = {}
|
||||
|
||||
# Cover the providers parse_model_string() already special-cases.
|
||||
for name, aliases in [
|
||||
("openai", ("oai",)),
|
||||
("anthropic", ("claude",)),
|
||||
("google", ("gemini", "google_genai")),
|
||||
]:
|
||||
registry.register(name, _make_litellm_factory(name), aliases=list(aliases))
|
||||
# Use local function to capture name correctly in closure
|
||||
def make_loader(provider_name):
|
||||
return lambda: _make_litellm_factory(provider_name)
|
||||
loaders[name] = make_loader(name)
|
||||
# Note: aliases will be registered separately via PluginRegistry.register()
|
||||
|
||||
return loaders
|
||||
|
||||
|
||||
def register_llm_provider(
|
||||
@@ -354,7 +295,7 @@ def register_llm_provider(
|
||||
|
||||
register_llm_provider("cloudflare", CloudflareProvider)
|
||||
"""
|
||||
get_default_llm_registry().register(name, provider, override=override, aliases=aliases)
|
||||
get_default_llm_registry().register_provider(name, provider, override=override, aliases=aliases)
|
||||
|
||||
|
||||
def unregister_llm_provider(name: str) -> bool:
|
||||
@@ -456,12 +397,12 @@ def create_llm_provider(
|
||||
name = input_value["name"]
|
||||
model_id = input_value.get("model_id", "default")
|
||||
provider_config = input_value.get("config") or config
|
||||
return reg.resolve(name, model_id, provider_config)
|
||||
return reg.resolve_provider(name, model_id, provider_config)
|
||||
|
||||
# Case 3: String - parse and resolve
|
||||
if isinstance(input_value, str):
|
||||
parsed = parse_model_string(input_value)
|
||||
return reg.resolve(parsed["provider_id"], parsed["model_id"], config)
|
||||
return reg.resolve_provider(parsed["provider_id"], parsed["model_id"], config)
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid provider input. Expected string, provider instance, or spec dict. "
|
||||
@@ -471,5 +412,6 @@ def create_llm_provider(
|
||||
|
||||
def _reset_default_registry() -> None:
|
||||
"""Reset the default registry (mainly for testing)."""
|
||||
with LLMProviderRegistry._instance_lock:
|
||||
LLMProviderRegistry._instance = None
|
||||
global _default_llm_registry
|
||||
with _default_llm_lock:
|
||||
_default_llm_registry = None
|
||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user