mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72e156c4d | ||
|
|
24385d6487 | ||
|
|
2f8162207f | ||
|
|
516d8b6d88 | ||
|
|
7eb98e895b | ||
|
|
ef79b7a056 | ||
|
|
3ea8376610 | ||
|
|
6360b24c06 | ||
|
|
b0d8f77752 | ||
|
|
179cab02db | ||
|
|
402d7ed9fc | ||
|
|
701e8e6a6a | ||
|
|
ef09341cc0 | ||
|
|
3a4d7e11c8 | ||
|
|
92644518c0 | ||
|
|
cd54574b20 | ||
|
|
68035af76d | ||
|
|
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 | ||
|
|
4985415e61 | ||
|
|
a29deefef7 | ||
|
|
7ffb4f87c5 | ||
|
|
057dab0ec0 | ||
|
|
89229c5c76 | ||
|
|
e20e426dd5 | ||
|
|
d49c559fce | ||
|
|
de84d22087 | ||
|
|
f3166b5bbd | ||
|
|
0d1cc32b23 | ||
|
|
e33b4b4b39 | ||
|
|
c9bbb5aee7 | ||
|
|
ed63d34ed7 | ||
|
|
d15c57d80c | ||
|
|
73eaacad35 | ||
|
|
0cec9fd1c3 | ||
|
|
7a7df247c8 | ||
|
|
cb96d03b58 | ||
|
|
d8a8a78691 | ||
|
|
120d299d85 | ||
|
|
2738e31d1f |
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Security release checklist (maintainer)
|
||||
|
||||
After merging security PRs [#1684](https://github.com/MervinPraison/PraisonAI/pull/1684) and [#1685](https://github.com/MervinPraison/PraisonAI/pull/1685):
|
||||
|
||||
## 1. Publish PyPI (requires `PYPI_TOKEN`)
|
||||
|
||||
```bash
|
||||
cd src/praisonai-agents
|
||||
export PYPI_TOKEN=...
|
||||
praisonai publish pypi
|
||||
|
||||
cd ../praisonai
|
||||
python scripts/bump_and_release.py <WRAPPER_VERSION> --agents <AGENTS_VERSION> --wait
|
||||
```
|
||||
|
||||
## 2. Publish GitHub security advisories
|
||||
|
||||
Set versions to match PyPI, then:
|
||||
|
||||
```bash
|
||||
AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
|
||||
```
|
||||
|
||||
Add reporter `credits` per advisory via `gh api` before publishing if not already set.
|
||||
|
||||
## 3. Verify
|
||||
|
||||
```bash
|
||||
pip index versions praisonaiagents | head -1
|
||||
pip index versions praisonai | head -1
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending")"'
|
||||
```
|
||||
|
||||
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
|
||||
@@ -0,0 +1,26 @@
|
||||
# Security advisory triage (maintainer)
|
||||
|
||||
| GHSA | Status after batch 1+2 | Notes |
|
||||
|------|------------------------|-------|
|
||||
| GHSA-4mr5-g6f9-cfrh | fixed batch 1 | Sandbox AST |
|
||||
| GHSA-5c6w-wwfq-7qqm | fixed batch 1 | spider_tools SSRF |
|
||||
| GHSA-9cr9-25q5-8prj | fixed batch 1 | MCP yaml paths |
|
||||
| GHSA-86qc-r5v2-v6x6 | fixed batch 1 | call server auth |
|
||||
| GHSA-hvhp-v2gc-268q | fixed batch 1 | write_file cwd |
|
||||
| GHSA-5cxw-77wg-jrf3 | fixed batch 1 | @url mentions |
|
||||
| GHSA-xp85-6wwf-r67c | fixed batch 1 | GHA branch quote |
|
||||
| GHSA-3qg8-5g3r-79v5 | fixed batch 1+2 | JWT + issue guard |
|
||||
| GHSA-xwq8, 7p8g, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial |
|
||||
| GHSA-943m, 5jx9, 4x6r, 27p4, cp4f | fixed batch 2 | platform IDOR completion |
|
||||
| GHSA-vg22-4gmj-prxw | fixed batch 2 | example eval hardening |
|
||||
| GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True |
|
||||
| GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate |
|
||||
| GHSA-gv23-xxxx-xxxx, GHSA-h8q5-cp56-rr65, GHSA-6h6v-xxxx-xxxx, GHSA-h37g-4h4p-9x97 | fixed batch 3 | service-layer workspace_id on get/update/delete |
|
||||
| GHSA-c2m8-xxxx-xxxx, GHSA-8g2p-xxxx-xxxx | fixed batch 3 | only owner assigns admin/owner |
|
||||
| GHSA-h8q5-cp56-rr65 | fixed batch 3 | bind default 127.0.0.1 (+ PLATFORM_HOST) |
|
||||
| GHSA-6xj3-927j-6pqw | not-applicable | Open WebUI path; not in this repo |
|
||||
| GHSA-gmjg, 9q28 | published | prior release |
|
||||
|
||||
**Code fixed on main; GHSA state still triage until PyPI publish + advisory publish.**
|
||||
|
||||
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
|
||||
@@ -786,6 +786,10 @@ Methods:
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <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.34" \
|
||||
"praisonai>=4.6.40" \
|
||||
"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.34" \
|
||||
"praisonai>=4.6.40" \
|
||||
"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.34" \
|
||||
"praisonai>=4.6.40" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Linear Agent Example for PraisonAI.
|
||||
|
||||
Demonstrates how to create a Linear bot that responds to issue mentions
|
||||
and assignments with full coding capabilities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import LinearBot
|
||||
|
||||
# Create an agent with Linear tools and coding capabilities
|
||||
agent = Agent(
|
||||
name="PraisonAI Coder",
|
||||
instructions="""
|
||||
You are an autonomous coding agent integrated with Linear.
|
||||
|
||||
When you are mentioned or assigned an issue:
|
||||
1. Analyze the issue description and requirements
|
||||
2. Break down complex tasks into smaller steps
|
||||
3. Use available tools to implement solutions
|
||||
4. Update the issue with progress comments
|
||||
5. Create GitHub pull requests when code changes are needed
|
||||
|
||||
Focus on being helpful, thorough, and providing clear updates.
|
||||
""",
|
||||
llm="gpt-4o-mini",
|
||||
tools=[
|
||||
"linear_search_issues",
|
||||
"linear_get_issue",
|
||||
"linear_add_comment",
|
||||
"linear_update_issue",
|
||||
"linear_list_teams",
|
||||
"linear_list_issue_states",
|
||||
"github_create_branch",
|
||||
"github_commit_and_push",
|
||||
"github_create_pull_request",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"execute_command"
|
||||
],
|
||||
memory=True,
|
||||
web_search=True,
|
||||
auto_approve_tools=True,
|
||||
)
|
||||
|
||||
# Create Linear bot
|
||||
bot = LinearBot(
|
||||
token="your-linear-oauth-token", # or set LINEAR_OAUTH_TOKEN env var
|
||||
signing_secret="your-webhook-secret", # or set LINEAR_WEBHOOK_SECRET env var
|
||||
agent=agent,
|
||||
webhook_port=8080,
|
||||
)
|
||||
|
||||
async def main():
|
||||
"""Start the Linear bot."""
|
||||
print("Starting Linear bot...")
|
||||
print("Set up your Linear webhook to point to: http://your-host:8080/webhook")
|
||||
print("Configure AgentSession and Comment events in Linear webhook settings")
|
||||
|
||||
try:
|
||||
await bot.start()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
await bot.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
platform: linear
|
||||
token: ${LINEAR_OAUTH_TOKEN}
|
||||
signing_secret: ${LINEAR_WEBHOOK_SECRET}
|
||||
webhook_port: 8080
|
||||
|
||||
agent:
|
||||
name: "PraisonAI Coder"
|
||||
instructions: |
|
||||
You are an autonomous coding agent integrated with Linear.
|
||||
|
||||
When you are mentioned or assigned an issue:
|
||||
1. Analyze the issue description and requirements
|
||||
2. Break down complex tasks into smaller steps
|
||||
3. Use available tools to implement solutions
|
||||
4. Update the issue with progress comments
|
||||
5. Create GitHub pull requests when code changes are needed
|
||||
|
||||
Focus on being helpful, thorough, and providing clear updates.
|
||||
llm: gpt-4o-mini
|
||||
tools:
|
||||
- linear_search_issues
|
||||
- linear_get_issue
|
||||
- linear_add_comment
|
||||
- linear_update_issue
|
||||
- linear_list_teams
|
||||
- linear_list_issue_states
|
||||
- github_create_branch
|
||||
- github_commit_and_push
|
||||
- github_create_pull_request
|
||||
- read_file
|
||||
- write_file
|
||||
- execute_command
|
||||
memory: true
|
||||
web_search: true
|
||||
auto_approve_tools: true
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish security advisories after PyPI release.
|
||||
# Usage: AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO="MervinPraison/PraisonAI"
|
||||
AGENTS_VERSION="${AGENTS_VERSION:?Set AGENTS_VERSION (e.g. 1.6.40)}"
|
||||
WRAPPER_VERSION="${WRAPPER_VERSION:?Set WRAPPER_VERSION (e.g. 4.6.40)}"
|
||||
LAST_AGENTS="${LAST_AGENTS:-1.6.39}"
|
||||
LAST_WRAPPER="${LAST_WRAPPER:-4.6.39}"
|
||||
|
||||
# Batch 1 + 2 GHSA IDs (update credits login per advisory via gh api first if needed)
|
||||
AGENTS_GHSAS=(
|
||||
GHSA-4mr5-g6f9-cfrh
|
||||
GHSA-5c6w-wwfq-7qqm
|
||||
GHSA-5cxw-77wg-jrf3
|
||||
)
|
||||
|
||||
WRAPPER_GHSAS=(
|
||||
GHSA-9cr9-25q5-8prj
|
||||
GHSA-86qc-r5v2-v6x6
|
||||
GHSA-hvhp-v2gc-268q
|
||||
GHSA-xp85-6wwf-r67c
|
||||
GHSA-8444-4fhq-fxpq
|
||||
GHSA-78r8-wwqv-r299
|
||||
GHSA-vg22-4gmj-prxw
|
||||
GHSA-6xj3-927j-6pqw
|
||||
)
|
||||
|
||||
patch_advisory() {
|
||||
local ghsa="$1" package="$2" last="$3" patched="$4"
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH --input - <<EOF
|
||||
{
|
||||
"state": "draft",
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"package": {"ecosystem": "pip", "name": "${package}"},
|
||||
"vulnerable_version_range": "<= ${last}",
|
||||
"patched_versions": ">= ${patched}"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH -f state=published
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}/cve" --method POST || true
|
||||
echo "Published ${ghsa}"
|
||||
}
|
||||
|
||||
for ghsa in "${AGENTS_GHSAS[@]}"; do
|
||||
patch_advisory "$ghsa" "praisonaiagents" "$LAST_AGENTS" "$AGENTS_VERSION"
|
||||
done
|
||||
|
||||
for ghsa in "${WRAPPER_GHSAS[@]}"; do
|
||||
patch_advisory "$ghsa" "praisonai" "$LAST_WRAPPER" "$WRAPPER_VERSION"
|
||||
done
|
||||
|
||||
echo "Done. Verify: gh api repos/${REPO}/security-advisories --jq '.[] | select(.state==\"published\") | .ghsa_id'"
|
||||
@@ -519,6 +519,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
model: Optional[Union[str, Any]] = None, # Alias for llm=
|
||||
base_url: Optional[str] = None, # Kept separate (connection/auth)
|
||||
api_key: Optional[str] = None, # Kept separate (connection/auth)
|
||||
auth: Optional[str] = None, # Subscription auth provider: "claude-code", "codex", etc.
|
||||
# Tools
|
||||
tools: Optional[List[Any]] = None,
|
||||
allow_delegation: bool = False, # Deprecated: use handoffs= instead
|
||||
@@ -1521,6 +1522,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
llm_config['base_url'] = base_url
|
||||
if api_key:
|
||||
llm_config['api_key'] = api_key
|
||||
if auth:
|
||||
llm_config['auth'] = auth
|
||||
llm_config['metrics'] = metrics
|
||||
self.llm_instance = LLM(**llm_config)
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
@@ -1531,6 +1534,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
auth=auth,
|
||||
metrics=metrics,
|
||||
web_search=web_search,
|
||||
web_fetch=web_fetch,
|
||||
@@ -1552,6 +1556,10 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
if api_key and 'api_key' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['api_key'] = api_key
|
||||
# Add auth if provided and not in dict
|
||||
if auth and 'auth' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['auth'] = auth
|
||||
# Add metrics parameter
|
||||
llm = llm.copy()
|
||||
llm['metrics'] = metrics
|
||||
@@ -1571,6 +1579,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
llm_params = {'model': llm}
|
||||
if api_key:
|
||||
llm_params['api_key'] = api_key
|
||||
if auth:
|
||||
llm_params['auth'] = auth
|
||||
llm_params['metrics'] = metrics
|
||||
llm_params['web_search'] = web_search
|
||||
llm_params['web_fetch'] = web_fetch
|
||||
|
||||
@@ -11,8 +11,6 @@ import time
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import threading
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
@@ -45,9 +43,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
import traceback
|
||||
from typing import List, Optional, Any, Dict, Union, Callable, Generator, TYPE_CHECKING
|
||||
from collections import OrderedDict
|
||||
from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -560,7 +556,11 @@ Your Goal: {self.goal}"""
|
||||
temperature=temperature
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
|
||||
|
||||
# C7 — honour any BEFORE_LLM hook that mutated the message stream
|
||||
# (e.g. PII redactor). The runner applies modified_input in-place on
|
||||
# before_llm_input.messages; adopt that value for the actual LLM call.
|
||||
messages = before_llm_input.messages
|
||||
|
||||
logging.debug(f"{self.name} sending messages to LLM: {messages}")
|
||||
|
||||
# Emit LLM request trace event (zero overhead when not set)
|
||||
@@ -1149,7 +1149,7 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
return rendered
|
||||
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None) -> Optional[str]:
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
|
||||
"""
|
||||
Chat with the agent.
|
||||
|
||||
@@ -1193,11 +1193,17 @@ Your Goal: {self.goal}"""
|
||||
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
|
||||
|
||||
try:
|
||||
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice)
|
||||
# C2 — cooperative cancellation: abort early if a pre-set token is given
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice, seed=seed, cancel_token=_cancel)
|
||||
finally:
|
||||
_trace_emitter.agent_end(self.name)
|
||||
|
||||
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
|
||||
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
|
||||
"""Internal chat implementation (extracted for trace wrapping)."""
|
||||
# Apply rate limiter if configured (before any LLM call)
|
||||
if self._rate_limiter is not None:
|
||||
@@ -1418,7 +1424,16 @@ Your Goal: {self.goal}"""
|
||||
effective_tool_choice = tool_choice or getattr(self, '_yaml_tool_choice', None)
|
||||
if effective_tool_choice:
|
||||
llm_kwargs['tool_choice'] = effective_tool_choice
|
||||
|
||||
|
||||
# C1 — per-call seed overrides llm_instance.seed for determinism
|
||||
if seed is not None:
|
||||
llm_kwargs['seed'] = seed
|
||||
|
||||
# C2 — last-chance cancel check before handing to the LLM
|
||||
if cancel_token is not None and getattr(cancel_token, 'is_set', lambda: False)():
|
||||
reason = getattr(cancel_token, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
response_text = self.llm_instance.get_response(**llm_kwargs)
|
||||
|
||||
self._add_to_chat_history("assistant", response_text)
|
||||
@@ -1714,7 +1729,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
cleaned = cleaned[:-3].strip()
|
||||
return cleaned
|
||||
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None):
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
|
||||
"""Async version of chat method with self-reflection support.
|
||||
|
||||
Args:
|
||||
@@ -1737,13 +1752,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reasoning_steps=reasoning_steps, stream=stream,
|
||||
task_name=task_name, task_description=task_description, task_id=task_id,
|
||||
config=config, force_retrieval=force_retrieval, skip_retrieval=skip_retrieval,
|
||||
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice
|
||||
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice,
|
||||
seed=seed, cancel_token=cancel_token
|
||||
)
|
||||
finally:
|
||||
_trace_emitter.agent_end(self.name)
|
||||
|
||||
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
|
||||
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
|
||||
"""Internal async chat implementation (extracted for trace wrapping)."""
|
||||
# C2 — cooperative cancellation: abort early if a pre-set token is given
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
# Use agent's stream setting if not explicitly provided
|
||||
if stream is None:
|
||||
stream = self.stream
|
||||
@@ -1866,31 +1888,43 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
|
||||
|
||||
try:
|
||||
response_text = await self.llm_instance.get_response_async(
|
||||
prompt=prompt,
|
||||
system_prompt=self._build_system_prompt(tools),
|
||||
chat_history=self.chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
reflection=self.self_reflect,
|
||||
max_reflect=self.max_reflect,
|
||||
min_reflect=self.min_reflect,
|
||||
console=self.console,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=[t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
execute_tool_fn=self.execute_tool_async,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream
|
||||
)
|
||||
# C1 — per-call seed forwarding (async path)
|
||||
llm_kwargs = {
|
||||
'prompt': prompt,
|
||||
'system_prompt': self._build_system_prompt(tools),
|
||||
'chat_history': self.chat_history,
|
||||
'temperature': temperature,
|
||||
'tools': tools,
|
||||
'output_json': output_json,
|
||||
'output_pydantic': output_pydantic,
|
||||
'verbose': self.verbose,
|
||||
'markdown': self.markdown,
|
||||
'reflection': self.self_reflect,
|
||||
'max_reflect': self.max_reflect,
|
||||
'min_reflect': self.min_reflect,
|
||||
'console': self.console,
|
||||
'agent_name': self.name,
|
||||
'agent_role': self.role,
|
||||
'agent_tools': [t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
|
||||
'task_name': task_name,
|
||||
'task_description': task_description,
|
||||
'task_id': task_id,
|
||||
'execute_tool_fn': self.execute_tool_async,
|
||||
'parallel_tool_calls': getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
'reasoning_steps': reasoning_steps,
|
||||
'stream': stream
|
||||
}
|
||||
|
||||
# C1 — per-call seed overrides llm_instance.seed for determinism
|
||||
if seed is not None:
|
||||
llm_kwargs['seed'] = seed
|
||||
|
||||
# C2 — last-chance cancel check before handing to the LLM
|
||||
if _cancel is not None and getattr(_cancel, 'is_set', lambda: False)():
|
||||
reason = getattr(_cancel, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
response_text = await self.llm_instance.get_response_async(**llm_kwargs)
|
||||
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -5,12 +5,10 @@ Contains all methods for run/start/launch lifecycle, planning,
|
||||
and server endpoints. Extracted from agent.py for maintainability.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import inspect
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
@@ -126,7 +124,7 @@ class ExecutionMixin:
|
||||
config=HandoffConfig(context_policy=ContextPolicy.NONE),
|
||||
)
|
||||
|
||||
async def arun(self, prompt: str, **kwargs):
|
||||
async def arun(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
"""Async version of run() - silent, non-streaming, returns structured result.
|
||||
|
||||
Production-friendly async execution. Does not stream or display output.
|
||||
|
||||
@@ -9,7 +9,6 @@ for maintainability.
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
@@ -42,7 +41,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import List, Optional, Any, Dict, Generator, TYPE_CHECKING
|
||||
from typing import Optional, Dict, Generator, TYPE_CHECKING
|
||||
from collections import OrderedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -82,7 +81,7 @@ class MemoryMixin:
|
||||
with self._history_lock:
|
||||
self.chat_history.append({"role": role, "content": content})
|
||||
|
||||
def _add_to_chat_history_if_not_duplicate(self, role, content):
|
||||
def _add_to_chat_history_if_not_duplicate(self, role, content) -> bool:
|
||||
"""Thread-safe method to add messages to chat history only if not duplicate.
|
||||
|
||||
Atomically checks for duplicate and adds message under the same lock to prevent TOCTOU races.
|
||||
|
||||
@@ -194,6 +194,21 @@ class ToolExecutionMixin:
|
||||
if res.output and res.output.modified_data:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
# C4 — optional tool-argument validation via ToolValidatorProtocol.
|
||||
# Zero overhead when not set. Users wire via `agent._tool_validator = MyValidator()`.
|
||||
_validator = getattr(self, '_tool_validator', None)
|
||||
if _validator is not None:
|
||||
try:
|
||||
_vres = _validator.validate_args(function_name, arguments)
|
||||
if _vres is not None and not getattr(_vres, 'valid', True):
|
||||
_errs = "; ".join(getattr(_vres, 'errors', []) or ["validation failed"])
|
||||
logging.warning(
|
||||
f"Tool {function_name} args rejected by validator: {_errs}"
|
||||
)
|
||||
return f"Tool arguments rejected: {_errs}"
|
||||
except Exception as _ve: # noqa: BLE001 — never break tool exec on validator bug
|
||||
logging.debug(f"Tool validator raised; skipping validation: {_ve}")
|
||||
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
if tool_timeout and tool_timeout > 0:
|
||||
@@ -758,11 +773,9 @@ class ToolExecutionMixin:
|
||||
pass
|
||||
|
||||
if func is None:
|
||||
# If not found in tools or registry, try globals and main
|
||||
func = globals().get(function_name)
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
# Tool not found in declared tools or registry — do not fall back to
|
||||
# globals() or __main__ as that allows undeclared callables to execute.
|
||||
pass
|
||||
|
||||
if func:
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@ Quick start::
|
||||
|
||||
async def authorize(self, identity, resource, action):
|
||||
return "owner" in identity.roles
|
||||
|
||||
Subscription auth (lazy-loaded)::
|
||||
|
||||
from praisonaiagents.auth import resolve_subscription_credentials
|
||||
|
||||
creds = resolve_subscription_credentials("claude-code")
|
||||
print(creds.api_key, creds.headers)
|
||||
"""
|
||||
|
||||
from .protocols import (
|
||||
@@ -25,7 +32,36 @@ from .protocols import (
|
||||
|
||||
__all__ = [
|
||||
"AuthIdentity",
|
||||
"AuthBackendProtocol",
|
||||
"AuthBackendProtocol",
|
||||
"WorkspaceContextProtocol",
|
||||
"AuthConfig",
|
||||
# Subscription auth (lazy-loaded)
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy-load subscription auth to avoid import overhead."""
|
||||
if name in (
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
):
|
||||
from .subscription import (
|
||||
SubscriptionAuthProtocol,
|
||||
SubscriptionCredentials,
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
get_subscription_provider,
|
||||
)
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Subscription / OAuth auth for LLM providers.
|
||||
|
||||
Opt-in, zero-overhead. Lazy-loaded via __getattr__ so importing
|
||||
praisonaiagents stays fast.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ("SubscriptionAuthProtocol", "SubscriptionCredentials"):
|
||||
from .protocols import SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
return locals()[name]
|
||||
if name in (
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
):
|
||||
from .registry import (
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
get_subscription_provider,
|
||||
)
|
||||
return locals()[name]
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Claude Code OAuth — read ~/.claude/.credentials.json or macOS Keychain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
_TOKEN_ENDPOINTS = (
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
)
|
||||
_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
|
||||
|
||||
|
||||
def _detect_claude_code_version() -> str:
|
||||
"""Detect the installed Claude Code version, fall back to a static constant.
|
||||
|
||||
Anthropic's OAuth infrastructure validates the user-agent version and may
|
||||
reject requests with a version that's too old. Detecting dynamically means
|
||||
users who keep Claude Code updated never hit stale-version 400s.
|
||||
"""
|
||||
cached = getattr(_detect_claude_code_version, "_cache", None)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["claude", "--version"], capture_output=True, text=True, timeout=2, check=False
|
||||
)
|
||||
match = re.match(r"(\d+\.\d+\.\d+)", out.stdout.strip())
|
||||
version = match.group(1) if match else _CLAUDE_CODE_VERSION_FALLBACK
|
||||
except Exception:
|
||||
version = _CLAUDE_CODE_VERSION_FALLBACK
|
||||
_detect_claude_code_version._cache = version
|
||||
return version
|
||||
|
||||
|
||||
_CLI_USER_AGENT_FALLBACK = f"claude-cli/{_CLAUDE_CODE_VERSION_FALLBACK} (external, cli)"
|
||||
|
||||
|
||||
def _read_keychain_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read 'Claude Code-credentials' entry from macOS Keychain.
|
||||
|
||||
Returns dict with accessToken/refreshToken/expiresAt or None.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
return None
|
||||
import subprocess
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
||||
capture_output=True, text=True, timeout=5, check=False,
|
||||
)
|
||||
if out.returncode != 0 or not out.stdout.strip():
|
||||
return None
|
||||
data = json.loads(out.stdout)
|
||||
oauth = data.get("claudeAiOauth") or {}
|
||||
if oauth.get("accessToken"):
|
||||
return {
|
||||
"accessToken": oauth["accessToken"],
|
||||
"refreshToken": oauth.get("refreshToken", ""),
|
||||
"expiresAt": int(oauth.get("expiresAt", 0)),
|
||||
"source": "claude-code-keychain",
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _read_file_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read ~/.claude/.credentials.json."""
|
||||
path = Path.home() / ".claude" / ".credentials.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
oauth = data.get("claudeAiOauth") or {}
|
||||
if not oauth.get("accessToken"):
|
||||
return None
|
||||
return {
|
||||
"accessToken": oauth["accessToken"],
|
||||
"refreshToken": oauth.get("refreshToken", ""),
|
||||
"expiresAt": int(oauth.get("expiresAt", 0)),
|
||||
"source": "claude-code-file",
|
||||
}
|
||||
|
||||
|
||||
def _is_expiring(expires_at_ms: Optional[int], skew_ms: int = 60_000) -> bool:
|
||||
if not expires_at_ms:
|
||||
return False
|
||||
return int(time.time() * 1000) >= (expires_at_ms - skew_ms)
|
||||
|
||||
|
||||
def _refresh(refresh_token: str) -> Dict[str, Any]:
|
||||
"""Pure refresh — does not touch local files."""
|
||||
if not refresh_token:
|
||||
raise AuthError("no refresh_token; please re-run 'claude /login'")
|
||||
body = urllib.parse.urlencode({
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": _OAUTH_CLIENT_ID,
|
||||
}).encode()
|
||||
last = None
|
||||
for endpoint in _TOKEN_ENDPOINTS:
|
||||
req = urllib.request.Request(
|
||||
endpoint, data=body, method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
return {
|
||||
"accessToken": payload["access_token"],
|
||||
"refreshToken": payload.get("refresh_token", refresh_token),
|
||||
"expiresAt": int(time.time() * 1000) + int(payload.get("expires_in", 3600)) * 1000,
|
||||
}
|
||||
except Exception as exc:
|
||||
last = exc
|
||||
raise AuthError(f"Anthropic refresh failed: {last}")
|
||||
|
||||
|
||||
class ClaudeCodeAuth:
|
||||
"""Claude Code OAuth subscription auth."""
|
||||
|
||||
name = "claude-code"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# 1. ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN env (Hermes-style)
|
||||
for env_key in ("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
tok = os.environ.get(env_key, "").strip()
|
||||
if tok:
|
||||
return SubscriptionCredentials(
|
||||
api_key=tok,
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
source=f"env:{env_key}",
|
||||
)
|
||||
|
||||
# 2. Keychain or file
|
||||
creds = _read_keychain_credentials() or _read_file_credentials()
|
||||
if not creds:
|
||||
raise AuthError(
|
||||
"No Claude Code credentials found. Install Claude Code and run "
|
||||
"'claude /login', or set ANTHROPIC_TOKEN."
|
||||
)
|
||||
if _is_expiring(creds["expiresAt"]) and creds.get("refreshToken"):
|
||||
creds.update(_refresh(creds["refreshToken"]))
|
||||
return SubscriptionCredentials(
|
||||
api_key=creds["accessToken"],
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
expires_at_ms=creds.get("expiresAt"),
|
||||
source=creds["source"],
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
creds = _read_keychain_credentials() or _read_file_credentials()
|
||||
if not creds or not creds.get("refreshToken"):
|
||||
raise AuthError("cannot refresh: no refresh token available")
|
||||
new = _refresh(creds["refreshToken"])
|
||||
return SubscriptionCredentials(
|
||||
api_key=new["accessToken"],
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
expires_at_ms=new["expiresAt"],
|
||||
source="claude-code-refreshed",
|
||||
)
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
# CRITICAL: without these headers Anthropic returns 500s on OAuth tokens.
|
||||
# Match Hermes' OAuth header set exactly. litellm auto-adds oauth-2025-04-20.
|
||||
return {
|
||||
"anthropic-beta": ",".join([
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"context-1m-2025-08-07",
|
||||
"claude-code-20250219",
|
||||
]),
|
||||
"user-agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
|
||||
"x-app": "cli",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("claude-code", lambda: ClaudeCodeAuth())
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Codex / ChatGPT subscription — read ~/.codex/auth.json (or CODEX_HOME)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
def _read_codex_tokens() -> Optional[Dict[str, Any]]:
|
||||
home = os.environ.get("CODEX_HOME", "").strip() or str(Path.home() / ".codex")
|
||||
auth_path = Path(home).expanduser() / "auth.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
tokens = payload.get("tokens") or {}
|
||||
if not tokens.get("access_token") or not tokens.get("refresh_token"):
|
||||
return None
|
||||
return tokens
|
||||
|
||||
|
||||
def _is_jwt_expiring(jwt: str, skew_seconds: int = 60) -> bool:
|
||||
"""Decode JWT exp claim without verifying signature."""
|
||||
import base64
|
||||
try:
|
||||
_hdr, payload_b64, _sig = jwt.split(".")
|
||||
pad = "=" * (-len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + pad))
|
||||
exp = int(payload.get("exp", 0))
|
||||
return exp and (time.time() + skew_seconds) >= exp
|
||||
except Exception:
|
||||
return False # treat unknown as not-expiring; refresh on actual 401
|
||||
|
||||
|
||||
class CodexAuth:
|
||||
name = "codex"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# EXPERIMENTAL: Codex uses Responses API, not Chat Completions
|
||||
# OpenAI chat-completions requests will 404 against this endpoint
|
||||
raise AuthError(
|
||||
"Codex auth is experimental and requires custom Responses API transport. "
|
||||
"Use a standard OpenAI API key or wait for proper Codex support in a future release."
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# OpenAI Codex tokens are refreshed by the Codex CLI itself; we rely
|
||||
# on the CLI being run periodically. A direct refresh would require
|
||||
# replicating their device-flow client — out of scope for v1.
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"openai-beta": "responses=v1",
|
||||
"user-agent": "codex-cli/0.1.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("codex", lambda: CodexAuth())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Gemini CLI OAuth — read ~/.gemini/oauth_creds.json."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_GEMINI_BASE_URL = "https://cloudcode-pa.googleapis.com/v1internal"
|
||||
|
||||
|
||||
def _read_gemini_tokens() -> Optional[Dict[str, Any]]:
|
||||
"""Read Gemini CLI OAuth credentials."""
|
||||
auth_path = Path.home() / ".gemini" / "oauth_creds.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
if not payload.get("access_token"):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
class GeminiCliAuth:
|
||||
name = "gemini-cli"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# EXPERIMENTAL: Gemini CLI uses Code Assist API, not OpenAI chat format
|
||||
# Standard OpenAI chat-completions requests will not work with cloudcode-pa endpoints
|
||||
raise AuthError(
|
||||
"Gemini CLI auth is experimental and requires custom Code Assist transport. "
|
||||
"Use standard Gemini API keys or wait for proper Code Assist support in a future release."
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# Gemini CLI handles refresh internally
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"user-agent": "gemini-cli/1.0.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("gemini-cli", lambda: GeminiCliAuth())
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Subscription-auth protocol — what every provider must implement."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionCredentials:
|
||||
"""Resolved credentials ready to inject into an LLM client."""
|
||||
api_key: str # access_token (or "" for SDK-default chains like AWS)
|
||||
base_url: str = "" # provider endpoint
|
||||
headers: Dict[str, str] = field(default_factory=dict) # extra headers (UA, x-app, betas)
|
||||
auth_scheme: str = "bearer" # "bearer" | "x-api-key" | "sdk-default"
|
||||
expires_at_ms: Optional[int] = None # epoch ms; None = no expiry tracked
|
||||
source: str = "" # "claude-code-keychain", "codex-cli-file", etc.
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SubscriptionAuthProtocol(Protocol):
|
||||
"""Discover, refresh, and inject subscription credentials."""
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
"""Return current valid credentials, refreshing if expiring soon.
|
||||
|
||||
Raises:
|
||||
AuthError: if no usable credentials can be resolved.
|
||||
"""
|
||||
...
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
"""Force a refresh and return the new credentials."""
|
||||
...
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
"""Provider-specific headers (UA, beta flags) keyed off endpoint/model."""
|
||||
...
|
||||
|
||||
|
||||
class AuthError(RuntimeError):
|
||||
"""Raised when subscription credentials cannot be resolved."""
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Qwen CLI OAuth — read ~/.qwen/oauth_creds.json."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
|
||||
|
||||
|
||||
def _read_qwen_tokens() -> Optional[Dict[str, Any]]:
|
||||
"""Read Qwen CLI OAuth credentials."""
|
||||
auth_path = Path.home() / ".qwen" / "oauth_creds.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
if not payload.get("access_token"):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
class QwenCliAuth:
|
||||
name = "qwen-cli"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
tokens = _read_qwen_tokens()
|
||||
if not tokens:
|
||||
raise AuthError(
|
||||
"No Qwen CLI credentials at ~/.qwen/oauth_creds.json. "
|
||||
"Install Qwen CLI and run 'qwen login'."
|
||||
)
|
||||
return SubscriptionCredentials(
|
||||
api_key=tokens["access_token"],
|
||||
base_url=os.environ.get("PRAISONAI_QWEN_BASE_URL", "").strip() or _QWEN_BASE_URL,
|
||||
headers=self.headers_for(_QWEN_BASE_URL, ""),
|
||||
auth_scheme="bearer",
|
||||
source="qwen-cli-file",
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# Qwen CLI handles refresh internally
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"user-agent": "qwen-cli/1.0.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("qwen-cli", lambda: QwenCliAuth())
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Thread-safe provider registry for subscription auth."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
|
||||
_REGISTRY: Dict[str, Callable[[], SubscriptionAuthProtocol]] = {}
|
||||
_LOCK = threading.RLock()
|
||||
_BUILTIN_REGISTERED = False
|
||||
|
||||
|
||||
def register_subscription_provider(
|
||||
provider_id: str,
|
||||
factory: Callable[[], SubscriptionAuthProtocol],
|
||||
) -> None:
|
||||
"""Register a new subscription-auth provider.
|
||||
|
||||
Args:
|
||||
provider_id: short id e.g. "claude-code", "codex", "gemini-cli".
|
||||
factory: zero-arg callable returning a SubscriptionAuthProtocol impl.
|
||||
"""
|
||||
with _LOCK:
|
||||
_REGISTRY[provider_id] = factory
|
||||
|
||||
|
||||
def list_subscription_providers() -> List[str]:
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
return sorted(_REGISTRY)
|
||||
|
||||
|
||||
def resolve_subscription_credentials(provider_id: str) -> SubscriptionCredentials:
|
||||
"""Look up provider by id and resolve its credentials."""
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
factory = _REGISTRY.get(provider_id)
|
||||
if factory is None:
|
||||
raise AuthError(
|
||||
f"Unknown subscription provider {provider_id!r}. "
|
||||
f"Registered: {list_subscription_providers()}"
|
||||
)
|
||||
return factory().resolve_credentials()
|
||||
|
||||
|
||||
def get_subscription_provider(provider_id: str) -> Optional[SubscriptionAuthProtocol]:
|
||||
"""Return a new provider instance for *provider_id*, or None if not registered."""
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
factory = _REGISTRY.get(provider_id)
|
||||
return factory() if factory is not None else None
|
||||
|
||||
|
||||
def _ensure_builtins() -> None:
|
||||
"""Lazy register built-in providers on first use."""
|
||||
global _BUILTIN_REGISTERED
|
||||
with _LOCK:
|
||||
if _BUILTIN_REGISTERED:
|
||||
return
|
||||
# Import modules inside the lock to prevent race conditions
|
||||
from . import claude_code, codex, gemini_cli, qwen_cli # noqa: F401
|
||||
# Each module registers itself at import time.
|
||||
_BUILTIN_REGISTERED = 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:
|
||||
|
||||
@@ -71,7 +71,8 @@ class Mem0Adapter:
|
||||
from mem0 import Memory
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is not installed. Install with: pip install mem0ai"
|
||||
"mem0ai is not installed. "
|
||||
"Run: pip install 'praisonaiagents[memory]'"
|
||||
)
|
||||
|
||||
if self._config:
|
||||
|
||||
@@ -361,6 +361,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
prompt_caching: Optional[bool] = None,
|
||||
claude_memory: Optional[Union[bool, Any]] = None,
|
||||
failover_manager: Optional[FailoverManagerProtocol] = None,
|
||||
auth: Optional[str] = None, # NEW: "claude-code", "codex", "gemini-cli", "qwen-cli"
|
||||
**extra_settings
|
||||
):
|
||||
# Configure logging only once at the class level
|
||||
@@ -437,6 +438,10 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._max_retries = extra_settings.get('max_retries', 3)
|
||||
self._retry_delay = extra_settings.get('retry_delay', 60) # Default 60 seconds
|
||||
|
||||
# Subscription auth
|
||||
self._auth_provider_id = auth
|
||||
self._cached_subscription_creds = None # SubscriptionCredentials | None
|
||||
|
||||
# Failover management
|
||||
self._failover_manager = failover_manager
|
||||
self._current_profile = None # Track current auth profile for failover
|
||||
@@ -715,6 +720,35 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
logging.info(f"Failover: switching from {self.model} to {profile.model}")
|
||||
self.model = profile.model
|
||||
|
||||
def _resolve_subscription_creds(self):
|
||||
"""Lazy resolve + cache subscription credentials, checking expiration."""
|
||||
if not self._auth_provider_id:
|
||||
return None
|
||||
|
||||
# Check if cached credentials are expired
|
||||
if (self._cached_subscription_creds and
|
||||
self._cached_subscription_creds.expires_at_ms and
|
||||
self._cached_subscription_creds.expires_at_ms <= int(time.time() * 1000)):
|
||||
self._cached_subscription_creds = None
|
||||
|
||||
if self._cached_subscription_creds is None:
|
||||
from ..auth import resolve_subscription_credentials
|
||||
self._cached_subscription_creds = resolve_subscription_credentials(
|
||||
self._auth_provider_id,
|
||||
)
|
||||
return self._cached_subscription_creds
|
||||
|
||||
def _refresh_subscription_creds(self):
|
||||
"""Force refresh of subscription credentials."""
|
||||
if not self._auth_provider_id:
|
||||
return None
|
||||
from ..auth.subscription.registry import get_subscription_provider
|
||||
provider = get_subscription_provider(self._auth_provider_id)
|
||||
if provider is None:
|
||||
return None
|
||||
self._cached_subscription_creds = provider.refresh()
|
||||
return self._cached_subscription_creds
|
||||
|
||||
def _call_with_retry(self, func, *args, **kwargs):
|
||||
"""Call a function with automatic retry on rate limit errors and failover support.
|
||||
|
||||
@@ -751,6 +785,21 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
# Check for auth errors and try refreshing subscription credentials
|
||||
if category == "auth" and self._auth_provider_id and attempt == 0:
|
||||
try:
|
||||
logging.info("Authentication error detected - attempting credential refresh")
|
||||
refreshed_creds = self._refresh_subscription_creds()
|
||||
if refreshed_creds:
|
||||
# Update parameters with refreshed credentials (don't clear cache)
|
||||
kwargs = self._build_completion_params(**kwargs)
|
||||
# Retry immediately with refreshed credentials
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info("Subscription credentials refreshed, retrying...")
|
||||
except Exception as refresh_error:
|
||||
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
|
||||
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
@@ -840,6 +889,21 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
# Check for auth errors and try refreshing subscription credentials
|
||||
if category == "auth" and self._auth_provider_id and attempt == 0:
|
||||
try:
|
||||
logging.info("Authentication error detected - attempting credential refresh")
|
||||
refreshed_creds = self._refresh_subscription_creds()
|
||||
if refreshed_creds:
|
||||
# Update parameters with refreshed credentials (don't clear cache)
|
||||
kwargs = self._build_completion_params(**kwargs)
|
||||
# Retry immediately with refreshed credentials
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info("Subscription credentials refreshed, retrying...")
|
||||
except Exception as refresh_error:
|
||||
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
|
||||
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
@@ -4593,6 +4657,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
filtered_extra_settings = {k: v for k, v in self.extra_settings.items() if k != 'metrics'}
|
||||
params.update(filtered_extra_settings)
|
||||
|
||||
# Inject subscription credentials if auth provider is set
|
||||
creds = self._resolve_subscription_creds()
|
||||
if creds:
|
||||
# Use litellm's native OAuth detection (auto-detects sk-ant-oat-* and switches to Bearer)
|
||||
params["api_key"] = creds.api_key
|
||||
if creds.base_url:
|
||||
params["base_url"] = creds.base_url
|
||||
if creds.headers:
|
||||
extra_headers = dict(params.get("extra_headers") or {})
|
||||
extra_headers.update(creds.headers)
|
||||
params["extra_headers"] = extra_headers
|
||||
|
||||
# Override with any provided parameters
|
||||
params.update(override_params)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -40,7 +40,8 @@ def create_mem0_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
import mem0
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is required for mem0 adapter. Install with: pip install mem0ai"
|
||||
"mem0ai is not installed. "
|
||||
"Run: pip install 'praisonaiagents[memory]'"
|
||||
)
|
||||
|
||||
return Mem0MemoryAdapter(mem0_config=kwargs)
|
||||
@@ -363,7 +364,7 @@ class MongoDBMemoryAdapter:
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB short-term collection."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
@@ -371,7 +372,7 @@ class MongoDBMemoryAdapter:
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
|
||||
@@ -395,7 +396,7 @@ class MongoDBMemoryAdapter:
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB long-term collection."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
@@ -403,7 +404,7 @@ class MongoDBMemoryAdapter:
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "long_term"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -143,6 +143,9 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
adapter_name = provider_mapping.get(provider, provider)
|
||||
|
||||
# Try to get preferred adapter, fallback to available ones
|
||||
adapter = None
|
||||
provider_explicitly_requested = provider != "rag" # "rag" is default, others are explicit
|
||||
|
||||
try:
|
||||
adapter_config = self._get_adapter_config_for_provider(adapter_name)
|
||||
adapter = get_memory_adapter(adapter_name, **adapter_config)
|
||||
@@ -154,6 +157,37 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
adapter = None
|
||||
|
||||
if adapter is None:
|
||||
# If the provider was explicitly requested, try to give a helpful error message
|
||||
if provider_explicitly_requested:
|
||||
try:
|
||||
# Try the factory function directly to get the specific ImportError message
|
||||
from .adapters.factories import (
|
||||
create_mem0_memory_adapter,
|
||||
create_chroma_memory_adapter,
|
||||
create_mongodb_memory_adapter,
|
||||
)
|
||||
factory_map = {
|
||||
"mem0": create_mem0_memory_adapter,
|
||||
"chroma": create_chroma_memory_adapter,
|
||||
"mongodb": create_mongodb_memory_adapter,
|
||||
}
|
||||
if adapter_name in factory_map:
|
||||
# Call the factory to get ImportError with installation hint
|
||||
# or capture successful adapter creation
|
||||
adapter_config = self._get_adapter_config_for_provider(adapter_name)
|
||||
adapter = factory_map[adapter_name](**adapter_config)
|
||||
except ImportError:
|
||||
# Re-raise ImportError with installation instructions
|
||||
raise
|
||||
# All other exceptions (e.g., ValueError for missing API key)
|
||||
# propagate naturally, preserving backward compatibility
|
||||
|
||||
# If factory succeeded, use that adapter
|
||||
if adapter is not None:
|
||||
self.memory_adapter = adapter
|
||||
self.provider = adapter_name
|
||||
return
|
||||
|
||||
# Fallback to first available adapter
|
||||
self._log_verbose(f"Provider '{adapter_name}' not available, trying fallbacks")
|
||||
# Try each fallback preference individually
|
||||
@@ -658,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)
|
||||
@@ -884,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"
|
||||
}
|
||||
|
||||
@@ -1407,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}")
|
||||
|
||||
@@ -90,7 +90,43 @@ def __getattr__(name: str):
|
||||
from .hierarchy import ExtendedSessionData
|
||||
_module_cache[name] = ExtendedSessionData
|
||||
return ExtendedSessionData
|
||||
|
||||
|
||||
# W1 — Identity resolver
|
||||
if name == "IdentityResolverProtocol":
|
||||
from .identity import IdentityResolverProtocol
|
||||
_module_cache[name] = IdentityResolverProtocol
|
||||
return IdentityResolverProtocol
|
||||
if name == "IdentityLink":
|
||||
from .identity import IdentityLink
|
||||
_module_cache[name] = IdentityLink
|
||||
return IdentityLink
|
||||
if name == "InMemoryIdentityResolver":
|
||||
from .identity import InMemoryIdentityResolver
|
||||
_module_cache[name] = InMemoryIdentityResolver
|
||||
return InMemoryIdentityResolver
|
||||
if name == "FileIdentityResolver":
|
||||
from .identity import FileIdentityResolver
|
||||
_module_cache[name] = FileIdentityResolver
|
||||
return FileIdentityResolver
|
||||
|
||||
# W1 — Task-local session context
|
||||
if name == "SessionContext":
|
||||
from .context import SessionContext
|
||||
_module_cache[name] = SessionContext
|
||||
return SessionContext
|
||||
if name == "set_session_context":
|
||||
from .context import set_session_context
|
||||
_module_cache[name] = set_session_context
|
||||
return set_session_context
|
||||
if name == "get_session_context":
|
||||
from .context import get_session_context
|
||||
_module_cache[name] = get_session_context
|
||||
return get_session_context
|
||||
if name == "clear_session_context":
|
||||
from .context import clear_session_context
|
||||
_module_cache[name] = clear_session_context
|
||||
return clear_session_context
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -105,4 +141,13 @@ __all__ = [
|
||||
"get_hierarchical_session_store",
|
||||
"SessionSnapshot",
|
||||
"ExtendedSessionData",
|
||||
# W1
|
||||
"IdentityResolverProtocol",
|
||||
"IdentityLink",
|
||||
"InMemoryIdentityResolver",
|
||||
"FileIdentityResolver",
|
||||
"SessionContext",
|
||||
"set_session_context",
|
||||
"get_session_context",
|
||||
"clear_session_context",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Task-local session context for concurrent message handlers.
|
||||
|
||||
W1 — Replaces process-global ``os.environ`` based session metadata
|
||||
with ``contextvars.ContextVar`` so that two messages handled
|
||||
concurrently never overwrite each other's platform / chat / user IDs.
|
||||
|
||||
Each ``set_session_context`` call returns a token; pass it to
|
||||
``clear_session_context`` in a ``finally`` block to restore the
|
||||
previous state.
|
||||
|
||||
Usage in a bot handler::
|
||||
|
||||
from praisonaiagents.session.context import (
|
||||
set_session_context,
|
||||
clear_session_context,
|
||||
get_session_context,
|
||||
)
|
||||
|
||||
token = set_session_context(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
user_id="alice",
|
||||
unified_user_id="alice-global",
|
||||
)
|
||||
try:
|
||||
# ... agent handles message; tools can call get_session_context()
|
||||
ctx = get_session_context()
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionContext:
|
||||
"""Snapshot of the current message-handling context."""
|
||||
|
||||
platform: str = ""
|
||||
chat_id: str = ""
|
||||
chat_name: str = ""
|
||||
thread_id: str = ""
|
||||
user_id: str = ""
|
||||
user_name: str = ""
|
||||
unified_user_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "SessionContext":
|
||||
return cls(
|
||||
platform=d.get("platform", ""),
|
||||
chat_id=d.get("chat_id", ""),
|
||||
chat_name=d.get("chat_name", ""),
|
||||
thread_id=d.get("thread_id", ""),
|
||||
user_id=d.get("user_id", ""),
|
||||
user_name=d.get("user_name", ""),
|
||||
unified_user_id=d.get("unified_user_id", ""),
|
||||
)
|
||||
|
||||
|
||||
_CTX: ContextVar[SessionContext] = ContextVar(
|
||||
"praisonai_session_context", default=SessionContext()
|
||||
)
|
||||
|
||||
|
||||
def set_session_context(
|
||||
platform: str = "",
|
||||
chat_id: str = "",
|
||||
chat_name: str = "",
|
||||
thread_id: str = "",
|
||||
user_id: str = "",
|
||||
user_name: str = "",
|
||||
unified_user_id: str = "",
|
||||
) -> Token:
|
||||
"""Set the task-local session context. Returns a token for ``clear``."""
|
||||
ctx = SessionContext(
|
||||
platform=platform,
|
||||
chat_id=chat_id,
|
||||
chat_name=chat_name,
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
unified_user_id=unified_user_id,
|
||||
)
|
||||
return _CTX.set(ctx)
|
||||
|
||||
|
||||
def get_session_context() -> SessionContext:
|
||||
"""Return the current task's session context (empty if unset)."""
|
||||
return _CTX.get()
|
||||
|
||||
|
||||
def clear_session_context(token: Token) -> None:
|
||||
"""Restore the previous session context using the token from ``set``."""
|
||||
try:
|
||||
_CTX.reset(token)
|
||||
except (LookupError, ValueError):
|
||||
# Token from a different context; reset to empty
|
||||
_CTX.set(SessionContext())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SessionContext",
|
||||
"set_session_context",
|
||||
"get_session_context",
|
||||
"clear_session_context",
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Cross-platform identity resolution for agents.
|
||||
|
||||
W1 — Optional, opt-in mapping from ``(platform, platform_user_id)``
|
||||
to a single ``unified_user_id`` so the same human is recognised across
|
||||
Telegram, Discord, Slack, etc.
|
||||
|
||||
By default (no links registered) ``resolve()`` returns the
|
||||
platform-prefixed string unchanged — no surprises, no privacy leak.
|
||||
|
||||
Linking is performed explicitly, typically after a DM-based pairing
|
||||
flow has cryptographically verified ownership of both accounts.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.session.identity import InMemoryIdentityResolver
|
||||
|
||||
resolver = InMemoryIdentityResolver()
|
||||
resolver.link("telegram", "12345", "alice")
|
||||
resolver.link("discord", "snowflake-1", "alice")
|
||||
assert resolver.resolve("telegram", "12345") == "alice"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Protocol, runtime_checkable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityLink:
|
||||
"""Immutable mapping from a platform-scoped user ID to a unified ID."""
|
||||
|
||||
platform: str
|
||||
platform_user_id: str
|
||||
unified_user_id: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentityResolverProtocol(Protocol):
|
||||
"""Maps platform-scoped user IDs to a unified identity.
|
||||
|
||||
Implementations MUST be thread-safe. Implementations SHOULD return
|
||||
``f"{platform}:{platform_user_id}"`` for unlinked users so callers
|
||||
never need to special-case the unlinked path.
|
||||
"""
|
||||
|
||||
def resolve(self, platform: str, platform_user_id: str) -> str:
|
||||
"""Return ``unified_user_id`` for the given platform user.
|
||||
|
||||
If no link is registered, return ``f"{platform}:{platform_user_id}"``.
|
||||
"""
|
||||
...
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
"""Register a mapping. Overwrites any existing mapping for the same
|
||||
``(platform, platform_user_id)`` pair.
|
||||
"""
|
||||
...
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
"""Remove a mapping. No-op when no link exists."""
|
||||
...
|
||||
|
||||
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
|
||||
"""Return all links pointing to the given unified ID."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryIdentityResolver:
|
||||
"""Thread-safe in-memory ``IdentityResolverProtocol`` implementation.
|
||||
|
||||
Suitable for tests and single-process deployments. Production
|
||||
deployments with multiple processes should use a persistent
|
||||
implementation backed by SQLite, Redis, or a database.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._links: dict[tuple[str, str], str] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def resolve(self, platform: str, platform_user_id: str) -> str:
|
||||
with self._lock:
|
||||
return self._links.get(
|
||||
(platform, platform_user_id),
|
||||
f"{platform}:{platform_user_id}",
|
||||
)
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._links[(platform, platform_user_id)] = unified_user_id
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
with self._lock:
|
||||
self._links.pop((platform, platform_user_id), None)
|
||||
|
||||
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
|
||||
with self._lock:
|
||||
return [
|
||||
IdentityLink(p, u, unified_user_id)
|
||||
for (p, u), uid in self._links.items()
|
||||
if uid == unified_user_id
|
||||
]
|
||||
|
||||
|
||||
_DEFAULT_IDENTITY_PATH = Path(
|
||||
os.environ.get(
|
||||
"PRAISONAI_IDENTITY_PATH",
|
||||
os.path.expanduser("~/.praisonai/identity.json"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class FileIdentityResolver(InMemoryIdentityResolver):
|
||||
"""JSON-file-backed ``IdentityResolverProtocol`` implementation.
|
||||
|
||||
Loads links from disk on construction; persists on every mutation
|
||||
via atomic temp-file + ``os.replace``. Thread-safe.
|
||||
|
||||
Default path: ``$PRAISONAI_IDENTITY_PATH`` or ``~/.praisonai/identity.json``.
|
||||
|
||||
Storage format::
|
||||
|
||||
{
|
||||
"links": {
|
||||
"telegram::12345": "alice",
|
||||
"discord::snowflake-1": "alice"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, path: Optional[Path | str] = None) -> None:
|
||||
super().__init__()
|
||||
self._path: Path = Path(path) if path else _DEFAULT_IDENTITY_PATH
|
||||
# Serialises concurrent disk writes so that two simultaneous
|
||||
# link()/unlink() calls cannot interleave their os.replace() and
|
||||
# lose the second writer's data.
|
||||
self._flush_lock = threading.Lock()
|
||||
self._load()
|
||||
|
||||
@staticmethod
|
||||
def _encode_key(platform: str, platform_user_id: str) -> str:
|
||||
return f"{platform}::{platform_user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _decode_key(encoded: str) -> tuple[str, str]:
|
||||
platform, _, user = encoded.partition("::")
|
||||
return platform, user
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
if not self._path.exists():
|
||||
return
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
links = data.get("links", {}) if isinstance(data, dict) else {}
|
||||
with self._lock:
|
||||
self._links = {
|
||||
self._decode_key(k): v
|
||||
for k, v in links.items()
|
||||
if isinstance(v, str)
|
||||
}
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("FileIdentityResolver: failed to load %s: %s", self._path, e)
|
||||
|
||||
def _flush(self) -> None:
|
||||
try:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Hold _flush_lock for the entire write (snapshot + disk I/O) so
|
||||
# that concurrent calls always write the latest in-memory state and
|
||||
# the last os.replace() wins rather than an older snapshot.
|
||||
with self._flush_lock:
|
||||
with self._lock:
|
||||
payload = {
|
||||
"links": {
|
||||
self._encode_key(p, u): uid
|
||||
for (p, u), uid in self._links.items()
|
||||
}
|
||||
}
|
||||
# Keep the _lock held across the entire write operation to prevent
|
||||
# TOCTOU race where a concurrent link() could modify _links between
|
||||
# payload snapshot and os.replace() completion.
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(self._path.parent), prefix=".identity-", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2, ensure_ascii=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, self._path)
|
||||
try:
|
||||
os.chmod(self._path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except OSError as e:
|
||||
logger.warning("FileIdentityResolver: failed to flush %s: %s", self._path, e)
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
super().link(platform, platform_user_id, unified_user_id)
|
||||
self._flush()
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
super().unlink(platform, platform_user_id)
|
||||
self._flush()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdentityLink",
|
||||
"IdentityResolverProtocol",
|
||||
"InMemoryIdentityResolver",
|
||||
"FileIdentityResolver",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -45,6 +45,10 @@ __all__ = [
|
||||
# Redaction
|
||||
"redact_dict",
|
||||
"REDACT_KEYS",
|
||||
# C7 — PII redaction for LLM egress
|
||||
"scrub_pii_text",
|
||||
"enable_pii_redaction",
|
||||
"disable_pii_redaction",
|
||||
# Context events (for replay)
|
||||
"ContextEvent",
|
||||
"ContextEventType",
|
||||
@@ -79,6 +83,10 @@ def __getattr__(name: str):
|
||||
if name in ("redact_dict", "REDACT_KEYS"):
|
||||
from .redact import redact_dict, REDACT_KEYS
|
||||
return locals()[name]
|
||||
|
||||
if name in ("scrub_pii_text", "enable_pii_redaction", "disable_pii_redaction"):
|
||||
from .redact import scrub_pii_text, enable_pii_redaction, disable_pii_redaction
|
||||
return locals()[name]
|
||||
|
||||
# Context events (for replay)
|
||||
if name in ("ContextEvent", "ContextEventType"):
|
||||
|
||||
@@ -5,8 +5,9 @@ Provides deterministic redaction of sensitive data in tool arguments
|
||||
and results. Uses only stdlib for zero dependencies.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Set
|
||||
from typing import Any, Dict, Optional, Set
|
||||
import re
|
||||
import threading
|
||||
|
||||
# Keys that should always be redacted (case-insensitive matching)
|
||||
REDACT_KEYS: Set[str] = {
|
||||
@@ -119,6 +120,109 @@ def _redact_key_value(key: str, value: Any) -> Any:
|
||||
return _redact_value(value)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C7 — PII redaction for LLM egress (opt-in BEFORE_LLM middleware)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Additional value-pattern rules for strings that look like secrets
|
||||
# even when no key=value pair surrounds them.
|
||||
_VALUE_PATTERNS = (
|
||||
# OpenAI-style API keys
|
||||
(re.compile(r"\bsk-[A-Za-z0-9]{12,}\b"), "[REDACTED]"),
|
||||
# US SSN
|
||||
(re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[REDACTED-SSN]"),
|
||||
# Credit card — canonical 4-group format only (e.g. "4111 1111 1111 1111").
|
||||
# @claude: The bare \b\d{16}\b branch was removed — it matches ANY 16-digit sequence:
|
||||
# UNIX microsecond timestamps, distributed trace IDs, order/invoice numbers, phone
|
||||
# numbers — causing false-positive redactions in LLM tool outputs and RAG results.
|
||||
# Unformatted 16-digit strings are indistinguishable from non-card IDs at regex level.
|
||||
# The 4-group pattern covers real-world formatted card numbers with near-zero FP rate.
|
||||
(re.compile(r"\b(?:\d{4}[ -]){3}\d{4}\b"), "[REDACTED-CC]"),
|
||||
# Email (optional — often safe, but default-scrub for compliance)
|
||||
(re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[REDACTED-EMAIL]"),
|
||||
)
|
||||
|
||||
|
||||
def scrub_pii_text(text: str) -> str:
|
||||
"""Redact API keys, passwords, SSNs, credit cards, and emails from free text.
|
||||
|
||||
Reuses REDACT_KEYS for ``key=value`` pairs and adds value-pattern rules
|
||||
for naked secrets. Zero dependency (stdlib re only).
|
||||
|
||||
Args:
|
||||
text: Input string — returned unchanged if empty or None.
|
||||
|
||||
Returns:
|
||||
Scrubbed string with literal secrets replaced by ``[REDACTED*]``.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
result = redact_string(text, enabled=True)
|
||||
for pat, repl in _VALUE_PATTERNS:
|
||||
result = pat.sub(repl, result)
|
||||
return result
|
||||
|
||||
|
||||
# Module-level state for idempotent enable/disable
|
||||
_PII_HOOK_ID: Optional[str] = None
|
||||
_PII_HOOK_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _pii_before_llm_hook(event_data):
|
||||
"""BEFORE_LLM hook that scrubs every message's content in-place."""
|
||||
try:
|
||||
messages = getattr(event_data, "messages", None) or []
|
||||
modified = []
|
||||
for m in messages:
|
||||
if isinstance(m, dict) and isinstance(m.get("content"), str):
|
||||
m = {**m, "content": scrub_pii_text(m["content"])}
|
||||
modified.append(m)
|
||||
# Import locally to avoid module-level hooks dep (keeps redact.py lightweight)
|
||||
from ..hooks.types import HookResult
|
||||
return HookResult(decision="allow", modified_input={"messages": modified})
|
||||
except Exception:
|
||||
# Never block the LLM call on a scrubber bug
|
||||
from ..hooks.types import HookResult
|
||||
return HookResult(decision="allow")
|
||||
|
||||
|
||||
def enable_pii_redaction() -> str:
|
||||
"""Register a BEFORE_LLM hook that scrubs secrets from every message.
|
||||
|
||||
Idempotent — calling twice leaves exactly one hook registered.
|
||||
|
||||
Returns:
|
||||
The hook id (useful for :func:`disable_pii_redaction`).
|
||||
"""
|
||||
global _PII_HOOK_ID
|
||||
with _PII_HOOK_LOCK:
|
||||
if _PII_HOOK_ID is not None:
|
||||
return _PII_HOOK_ID
|
||||
from ..hooks.registry import get_default_registry
|
||||
from ..hooks.types import HookEvent
|
||||
reg = get_default_registry()
|
||||
_PII_HOOK_ID = reg.register_function(
|
||||
event=HookEvent.BEFORE_LLM,
|
||||
func=_pii_before_llm_hook,
|
||||
name="praisonaiagents.pii_redactor",
|
||||
)
|
||||
return _PII_HOOK_ID
|
||||
|
||||
|
||||
def disable_pii_redaction() -> bool:
|
||||
"""Unregister the PII-redaction hook. No-op if never enabled."""
|
||||
global _PII_HOOK_ID
|
||||
if _PII_HOOK_ID is None:
|
||||
return False
|
||||
from ..hooks.registry import get_default_registry
|
||||
reg = get_default_registry()
|
||||
try:
|
||||
reg.unregister(_PII_HOOK_ID)
|
||||
finally:
|
||||
_PII_HOOK_ID = None
|
||||
return True
|
||||
|
||||
|
||||
def redact_string(text: str, enabled: bool = True) -> str:
|
||||
"""
|
||||
Redact potential secrets from a string.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.34"
|
||||
version = "1.6.40"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -30,7 +30,7 @@ mcp = [
|
||||
|
||||
memory = [
|
||||
"chromadb>=1.0.0",
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"litellm>=1.83.14",
|
||||
]
|
||||
|
||||
knowledge = [
|
||||
@@ -48,7 +48,7 @@ graph = [
|
||||
|
||||
# Add LLM dependencies
|
||||
llm = [
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"litellm>=1.83.14",
|
||||
"pydantic>=2.10.0"
|
||||
]
|
||||
|
||||
@@ -113,7 +113,9 @@ all = [
|
||||
"praisonaiagents[mongodb]",
|
||||
"praisonaiagents[auth]",
|
||||
"praisonaiagents[search]",
|
||||
"praisonaiagents[crawl]"
|
||||
"praisonaiagents[crawl]",
|
||||
"praisonaiagents[autonomy]",
|
||||
"praisonaiagents[os]"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate subscription auth fixes without pytest dependency.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add current directory to Python path
|
||||
sys.path.insert(0, '.')
|
||||
sys.path.insert(0, os.path.join(os.getcwd()))
|
||||
|
||||
def test_imports():
|
||||
"""Test that imports work correctly."""
|
||||
print("Testing imports...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials, AuthError
|
||||
print("✅ Protocol imports work")
|
||||
|
||||
from praisonaiagents.auth.subscription.claude_code import ClaudeCodeAuth
|
||||
print("✅ Claude Code auth imports work")
|
||||
|
||||
from praisonaiagents.auth.subscription.registry import resolve_subscription_credentials
|
||||
print("✅ Registry imports work")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Import test failed: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def test_codex_experimental():
|
||||
"""Test that Codex raises experimental error."""
|
||||
print("\nTesting Codex experimental error...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.codex import CodexAuth
|
||||
auth = CodexAuth()
|
||||
try:
|
||||
auth.resolve_credentials()
|
||||
print("❌ Codex should have raised experimental error")
|
||||
return False
|
||||
except Exception as e:
|
||||
if "experimental" in str(e).lower():
|
||||
print("✅ Codex correctly raises experimental error")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Codex raised wrong error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Codex test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_gemini_experimental():
|
||||
"""Test that Gemini CLI raises experimental error."""
|
||||
print("\nTesting Gemini CLI experimental error...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.gemini_cli import GeminiCliAuth
|
||||
auth = GeminiCliAuth()
|
||||
try:
|
||||
auth.resolve_credentials()
|
||||
print("❌ Gemini CLI should have raised experimental error")
|
||||
return False
|
||||
except Exception as e:
|
||||
if "experimental" in str(e).lower():
|
||||
print("✅ Gemini CLI correctly raises experimental error")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Gemini CLI raised wrong error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Gemini CLI test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_header_simplification():
|
||||
"""Test that Claude Code headers are simplified."""
|
||||
print("\nTesting Claude Code header simplification...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.claude_code import ClaudeCodeAuth
|
||||
auth = ClaudeCodeAuth()
|
||||
headers = auth.headers_for("https://api.anthropic.com", "claude-3-haiku")
|
||||
|
||||
# Should only have interleaved-thinking, not oauth header (litellm handles that)
|
||||
if "anthropic-beta" in headers:
|
||||
beta_header = headers["anthropic-beta"]
|
||||
if beta_header == "interleaved-thinking-2025-05-14":
|
||||
print("✅ Claude Code headers correctly simplified")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Unexpected beta header value: {beta_header}")
|
||||
return False
|
||||
else:
|
||||
print("❌ anthropic-beta header missing")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Header test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_agent_construction():
|
||||
"""Test basic agent construction with auth parameter."""
|
||||
print("\nTesting Agent construction with auth...")
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# This should work (though credential resolution will fail without actual creds)
|
||||
agent = Agent(name="test", auth="claude-code")
|
||||
print("✅ Agent construction with auth parameter works")
|
||||
|
||||
# Test invalid provider
|
||||
try:
|
||||
agent = Agent(name="test", auth="invalid-provider")
|
||||
print("⚠️ Invalid provider didn't fail at construction (will fail at runtime)")
|
||||
except Exception:
|
||||
print("✅ Invalid provider handling works")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Agent construction test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all validation tests."""
|
||||
print("=== Validating Subscription Auth Fixes ===\n")
|
||||
|
||||
tests = [
|
||||
test_imports,
|
||||
test_codex_experimental,
|
||||
test_gemini_experimental,
|
||||
test_header_simplification,
|
||||
test_agent_construction,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
total = len(tests)
|
||||
|
||||
for test in tests:
|
||||
if test():
|
||||
passed += 1
|
||||
else:
|
||||
print()
|
||||
|
||||
print(f"\n=== Results: {passed}/{total} tests passed ===")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 All validation tests passed!")
|
||||
return True
|
||||
else:
|
||||
print("💥 Some tests failed!")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Integration tests for Agent with subscription auth."""
|
||||
import pytest
|
||||
|
||||
# Skip entire module when LLM dependencies (e.g. pydantic) are not installed.
|
||||
# CI runs without the [llm] extra, so these are skipped there.
|
||||
pytest.importorskip("pydantic", reason="requires praisonaiagents[llm]")
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_subscription_auth():
|
||||
"""Mock subscription auth that returns test credentials."""
|
||||
def mock_resolve_credentials(provider_id):
|
||||
if provider_id == "claude-code":
|
||||
return SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-stub",
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={
|
||||
"anthropic-beta": "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
"user-agent": "claude-cli/2.1.0 (external, cli)",
|
||||
"x-app": "cli",
|
||||
},
|
||||
auth_scheme="bearer",
|
||||
source="test-mock"
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Unknown provider: {provider_id}")
|
||||
|
||||
return mock_resolve_credentials
|
||||
|
||||
|
||||
def test_agent_accepts_auth_kwarg(mock_subscription_auth):
|
||||
"""Test that Agent accepts auth parameter and passes it to LLM."""
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials', mock_subscription_auth):
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
llm="anthropic/claude-3-haiku-20240307",
|
||||
auth="claude-code"
|
||||
)
|
||||
|
||||
assert hasattr(agent, 'llm_instance')
|
||||
assert agent.llm_instance._auth_provider_id == "claude-code"
|
||||
|
||||
|
||||
def test_llm_kwargs_carry_oauth_headers(mock_subscription_auth):
|
||||
"""Test that LLM completion params include OAuth headers."""
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials', mock_subscription_auth):
|
||||
from praisonaiagents.llm import LLM
|
||||
|
||||
llm = LLM(model="anthropic/claude-3-haiku-20240307", auth="claude-code")
|
||||
params = llm._build_completion_params()
|
||||
|
||||
assert params["api_key"] == "sk-ant-oat-stub"
|
||||
assert params["base_url"] == "https://api.anthropic.com"
|
||||
assert params["extra_headers"]["x-app"] == "cli"
|
||||
assert params["extra_headers"]["user-agent"] == "claude-cli/2.1.0 (external, cli)"
|
||||
|
||||
|
||||
def test_agent_without_auth_works_normally():
|
||||
"""Test that Agent without auth parameter works as before."""
|
||||
agent = Agent(name="test-agent", llm="anthropic/claude-3-haiku-20240307")
|
||||
|
||||
if hasattr(agent, 'llm_instance'):
|
||||
assert agent.llm_instance._auth_provider_id is None
|
||||
assert agent.llm_instance._cached_subscription_creds is None
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Smoke test for subscription authentication - exercises real auth flows.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
try:
|
||||
import litellm
|
||||
except ImportError:
|
||||
pytest.skip("litellm not available", allow_module_level=True)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
|
||||
|
||||
|
||||
def test_claude_code_auth_flow():
|
||||
"""Test that Claude Code auth properly injects OAuth credentials."""
|
||||
# Mock litellm completion to capture the actual params sent
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.content = "Hello! This is a test response."
|
||||
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
mock_completion.return_value = mock_response
|
||||
|
||||
# Mock the credential resolution to return OAuth token
|
||||
mock_creds = SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-test-oauth-token", # OAuth token format
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={"user-agent": "claude-cli/2.1.0", "x-app": "cli"},
|
||||
auth_scheme="bearer",
|
||||
source="claude-code-test"
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials') as mock_resolve:
|
||||
mock_resolve.return_value = mock_creds
|
||||
|
||||
# Create agent with Claude Code auth
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
instructions="You are a test assistant.",
|
||||
auth="claude-code"
|
||||
)
|
||||
|
||||
# Make a real request
|
||||
response = agent.start("Say hello")
|
||||
|
||||
# Verify the call was made
|
||||
assert mock_completion.called
|
||||
call_args = mock_completion.call_args
|
||||
params = call_args.kwargs if call_args.kwargs else {}
|
||||
|
||||
# Verify OAuth token was passed as api_key (litellm will auto-detect and use Bearer)
|
||||
assert params.get("api_key") == "sk-ant-oat-test-oauth-token"
|
||||
assert params.get("base_url") == "https://api.anthropic.com"
|
||||
|
||||
# Verify provider headers are included
|
||||
extra_headers = params.get("extra_headers", {})
|
||||
assert "user-agent" in extra_headers
|
||||
assert "x-app" in extra_headers
|
||||
|
||||
# Verify we got a response (just check that completion was called with right params)
|
||||
assert params.get("api_key") == "sk-ant-oat-test-oauth-token"
|
||||
|
||||
|
||||
def test_auth_refresh_on_401():
|
||||
"""Test that auth errors trigger credential refresh and retry."""
|
||||
# First call: return 401, second call: success
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.content = "Success after refresh"
|
||||
|
||||
auth_error = Exception("AuthenticationError: Invalid API key")
|
||||
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
# First call raises auth error, second succeeds
|
||||
mock_completion.side_effect = [auth_error, mock_response]
|
||||
|
||||
# Mock credential resolution and refresh
|
||||
initial_creds = SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-expired-token",
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={},
|
||||
auth_scheme="bearer",
|
||||
source="claude-code-test"
|
||||
)
|
||||
|
||||
refreshed_creds = SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-fresh-token",
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={},
|
||||
auth_scheme="bearer",
|
||||
source="claude-code-refreshed"
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials') as mock_resolve:
|
||||
mock_resolve.return_value = initial_creds
|
||||
|
||||
with patch('praisonaiagents.auth.subscription.registry.get_subscription_provider') as mock_provider:
|
||||
mock_auth_provider = Mock()
|
||||
mock_auth_provider.refresh.return_value = refreshed_creds
|
||||
mock_provider.return_value = mock_auth_provider
|
||||
|
||||
# Mock error classification to detect auth error
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
with patch.object(LLM, '_classify_error_and_should_retry') as mock_classify:
|
||||
# First call: auth error, can retry
|
||||
# Second call: shouldn't be called since retry succeeds
|
||||
mock_classify.return_value = ("auth", True, 0.0)
|
||||
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
instructions="Test agent",
|
||||
auth="claude-code"
|
||||
)
|
||||
|
||||
# This should trigger refresh on first 401 and succeed on retry
|
||||
response = agent.start("Test refresh flow")
|
||||
|
||||
# Verify refresh was called
|
||||
assert mock_auth_provider.refresh.called
|
||||
|
||||
# Verify we got success response
|
||||
assert "Success after refresh" in response
|
||||
|
||||
|
||||
def test_invalid_auth_provider():
|
||||
"""Test that invalid auth providers raise clear errors."""
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
auth="nonexistent-provider"
|
||||
)
|
||||
agent.start("This should fail")
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert "unknown" in error_msg or "provider" in error_msg
|
||||
|
||||
|
||||
def test_codex_experimental_error():
|
||||
"""Test that Codex auth raises experimental error."""
|
||||
from praisonaiagents.auth.subscription.protocols import AuthError
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
agent = Agent(name="test", auth="codex")
|
||||
agent.start("test")
|
||||
assert "experimental" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_gemini_experimental_error():
|
||||
"""Test that Gemini CLI auth raises experimental error."""
|
||||
from praisonaiagents.auth.subscription.protocols import AuthError
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
agent = Agent(name="test", auth="gemini-cli")
|
||||
agent.start("test")
|
||||
assert "experimental" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Manual test runner for development
|
||||
test_claude_code_auth_flow()
|
||||
test_auth_refresh_on_401()
|
||||
test_invalid_auth_provider()
|
||||
test_codex_experimental_error()
|
||||
test_gemini_experimental_error()
|
||||
print("✅ All subscription auth smoke tests passed!")
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real agentic E2E — Phase A (C1/C2/C4/C7).
|
||||
|
||||
Exercises all four gap closures in a single live LLM run:
|
||||
|
||||
* C1 seed — request deterministic output
|
||||
* C2 cancel — pass a non-tripped InterruptController
|
||||
* C4 validator — install a passthrough ToolValidator (asserts it was invoked)
|
||||
* C7 PII — enable_pii_redaction() so secrets in the prompt are scrubbed
|
||||
|
||||
Run::
|
||||
|
||||
ANTHROPIC_API_KEY=... python tests/smoke_core_phase_a_real.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _pick_model() -> str:
|
||||
if os.getenv("ANTHROPIC_API_KEY"):
|
||||
return "anthropic/claude-haiku-4-5"
|
||||
if os.getenv("GOOGLE_API_KEY"):
|
||||
return "gemini/gemini-2.5-flash"
|
||||
return "gpt-4o-mini"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.interrupt import InterruptController
|
||||
from praisonaiagents.tools.validators import ValidationResult
|
||||
from praisonaiagents.trace.redact import (
|
||||
enable_pii_redaction,
|
||||
disable_pii_redaction,
|
||||
)
|
||||
|
||||
model = _pick_model()
|
||||
print(f"Model: {model}\n")
|
||||
|
||||
# C7 — register the PII scrubber for the whole run
|
||||
enable_pii_redaction()
|
||||
|
||||
# A trivial tool the LLM may or may not call — mainly to exercise the
|
||||
# validator wiring. We track how often it is asked to validate.
|
||||
validated: list[str] = []
|
||||
|
||||
class LoggingValidator:
|
||||
def validate_args(self, tool_name, args, context=None):
|
||||
validated.append(tool_name)
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
def validate_result(self, tool_name, result, context=None):
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
def echo(text: str) -> str:
|
||||
"""Echo a string back."""
|
||||
return f"echoed: {text}"
|
||||
|
||||
agent = Agent(
|
||||
name="PhaseATester",
|
||||
instructions=(
|
||||
"You are a concise assistant. Answer with one short sentence. "
|
||||
"Feel free to call the echo tool if asked."
|
||||
),
|
||||
llm={"model": model},
|
||||
tools=[echo],
|
||||
)
|
||||
agent._tool_validator = LoggingValidator() # C4 wiring
|
||||
|
||||
tok = InterruptController() # C2 — not set, must not affect anything
|
||||
|
||||
# Prompt contains a fake secret — C7 must scrub it before LLM egress.
|
||||
prompt = (
|
||||
"My api_key=sk-XYZSECRET12345 — please answer: what is 2 + 2? "
|
||||
"Reply with a single digit."
|
||||
)
|
||||
|
||||
print(f"[prompt raw] {prompt}")
|
||||
reply = agent.chat(prompt, seed=42, cancel_token=tok)
|
||||
print(f"[reply] {reply}\n")
|
||||
|
||||
# Validate outcomes
|
||||
ok = True
|
||||
|
||||
if not reply:
|
||||
print("FAIL: LLM returned empty reply")
|
||||
ok = False
|
||||
elif "4" not in reply:
|
||||
print("FAIL: reply does not contain expected '4'")
|
||||
ok = False
|
||||
else:
|
||||
print("PASS C1 — seed threaded and LLM returned a valid reply")
|
||||
|
||||
# Confirm the scrubber registered its hook (C7)
|
||||
# (chat_history stores the user's raw prompt by design — scrubbing happens
|
||||
# only on egress to the LLM, verified here via hook registry state.)
|
||||
from praisonaiagents.hooks import get_default_registry
|
||||
reg = get_default_registry()
|
||||
has_pii = any(
|
||||
(getattr(h, "name", "") or "").startswith("praisonaiagents.pii_redactor")
|
||||
for ev in getattr(reg, "_hooks", {}).values()
|
||||
for h in (ev if isinstance(ev, list) else [])
|
||||
)
|
||||
if has_pii:
|
||||
print("PASS C7 — PII redactor hook active during LLM call")
|
||||
else:
|
||||
print("FAIL C7 — PII redactor hook missing from registry")
|
||||
ok = False
|
||||
|
||||
# Cancel-token path did not trip (C2)
|
||||
if tok.is_set():
|
||||
print("FAIL C2 — cancel token unexpectedly set")
|
||||
ok = False
|
||||
else:
|
||||
print("PASS C2 — cancel_token honoured without tripping")
|
||||
|
||||
# Validator invocation (C4) is exercised only if LLM actually called echo;
|
||||
# don't fail the run if it chose not to call a tool.
|
||||
if validated:
|
||||
print(f"PASS C4 — validator invoked for: {validated}")
|
||||
else:
|
||||
print("SKIP C4 — LLM chose not to call a tool this run (non-fatal)")
|
||||
|
||||
disable_pii_redaction()
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Phase A gap-closure tests: C1 seed, C2 cancel_token, C4 tool validator, C7 PII.
|
||||
|
||||
These tests exercise the four gap closures identified in the core-features gap
|
||||
analysis. They target the existing infrastructure (InterruptController,
|
||||
ToolValidatorProtocol, trace/redact rules) which was defined but not wired into
|
||||
`Agent.chat()` / tool execution paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.validators import (
|
||||
ToolValidatorProtocol,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C1 — seed threaded through Agent.chat()
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC1SeedPassthrough:
|
||||
def test_chat_signature_accepts_seed(self):
|
||||
"""`Agent.chat` must accept a per-call `seed=` kwarg."""
|
||||
import inspect
|
||||
sig = inspect.signature(Agent.chat)
|
||||
assert "seed" in sig.parameters, (
|
||||
"Agent.chat() must expose seed= to let callers request determinism"
|
||||
)
|
||||
|
||||
def test_seed_reaches_llm_build_params(self, monkeypatch):
|
||||
"""A `seed=42` passed to chat() must appear in the LLM kwargs."""
|
||||
captured = {}
|
||||
|
||||
# Use dict-form llm= so the custom-LLM path (with llm_instance) is taken.
|
||||
agent = Agent(name="C1", instructions="say hi", llm={"model": "gpt-4o-mini"})
|
||||
assert getattr(agent, "llm_instance", None) is not None, \
|
||||
"dict llm= must produce a materialised llm_instance"
|
||||
|
||||
# Short-circuit the LLM call so nothing hits the network
|
||||
monkeypatch.setattr(
|
||||
agent.llm_instance,
|
||||
"get_response",
|
||||
lambda **kw: (captured.update(kw) or "ok"),
|
||||
)
|
||||
|
||||
agent.chat("hello", seed=42)
|
||||
assert captured.get("seed") == 42, (
|
||||
f"seed=42 did not reach LLM call; kwargs were {list(captured)}"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C2 — cancel_token wired into chat()
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC2CancelToken:
|
||||
def test_chat_signature_accepts_cancel_token(self):
|
||||
import inspect
|
||||
sig = inspect.signature(Agent.chat)
|
||||
assert "cancel_token" in sig.parameters, (
|
||||
"Agent.chat() must expose cancel_token= for cooperative cancellation"
|
||||
)
|
||||
|
||||
def test_preset_cancelled_token_aborts_chat_before_llm(self, monkeypatch):
|
||||
"""If the cancel token is already set when chat() is called,
|
||||
the LLM is NEVER invoked and the call returns cleanly (None)."""
|
||||
from praisonaiagents.agent.interrupt import InterruptController
|
||||
|
||||
agent = Agent(name="C2", instructions="irrelevant", llm={"model": "gpt-4o-mini"})
|
||||
|
||||
called = {"n": 0}
|
||||
|
||||
def must_not_be_called(**kwargs):
|
||||
called["n"] += 1
|
||||
return "should not be reached"
|
||||
|
||||
monkeypatch.setattr(agent.llm_instance, "get_response", must_not_be_called)
|
||||
|
||||
tok = InterruptController()
|
||||
tok.request("user-cancel")
|
||||
|
||||
try:
|
||||
agent.chat("anything", cancel_token=tok)
|
||||
except InterruptedError:
|
||||
pass # acceptable outcome
|
||||
assert called["n"] == 0, (
|
||||
"LLM was invoked despite pre-set cancel_token — cancellation not wired"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C4 — ToolValidatorProtocol wired into tool execution
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _RejectingValidator:
|
||||
"""Minimal ToolValidatorProtocol impl that always rejects."""
|
||||
|
||||
def validate_args(self, tool_name, args, context=None):
|
||||
return ValidationResult(
|
||||
valid=False,
|
||||
errors=[f"arg-validator rejected {tool_name}"],
|
||||
remediation="Fix your tool-call schema.",
|
||||
)
|
||||
|
||||
def validate_result(self, tool_name, result, context=None):
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
|
||||
class TestC4ToolValidator:
|
||||
def test_validator_implements_protocol(self):
|
||||
"""Sanity: our fixture validator satisfies the protocol."""
|
||||
v = _RejectingValidator()
|
||||
assert isinstance(v, ToolValidatorProtocol)
|
||||
|
||||
def test_validator_rejects_before_tool_runs(self):
|
||||
"""When agent._tool_validator rejects, the tool function is NEVER called
|
||||
and the returned string contains the validator's error."""
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_tool(x: int) -> int:
|
||||
calls["n"] += 1
|
||||
return x * 2
|
||||
|
||||
agent = Agent(name="C4", instructions="…", tools=[fake_tool], llm="gpt-4o-mini")
|
||||
agent._tool_validator = _RejectingValidator()
|
||||
|
||||
result = agent.execute_tool("fake_tool", {"x": 5})
|
||||
|
||||
assert calls["n"] == 0, "tool function was executed despite validator rejection"
|
||||
assert "rejected" in str(result).lower(), (
|
||||
f"validator error not surfaced: {result!r}"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C7 — enable_pii_redaction() registers a BEFORE_LLM middleware
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC7PIIRedaction:
|
||||
def test_enable_pii_redaction_is_importable(self):
|
||||
from praisonaiagents.trace.redact import enable_pii_redaction
|
||||
assert callable(enable_pii_redaction)
|
||||
|
||||
def test_enable_pii_redaction_is_idempotent(self):
|
||||
"""Calling enable_pii_redaction() twice must not double-register."""
|
||||
from praisonaiagents.trace.redact import (
|
||||
enable_pii_redaction,
|
||||
disable_pii_redaction,
|
||||
)
|
||||
disable_pii_redaction() # ensure clean baseline
|
||||
enable_pii_redaction()
|
||||
first_state = _pii_state_snapshot()
|
||||
enable_pii_redaction()
|
||||
second_state = _pii_state_snapshot()
|
||||
assert first_state == second_state, "enable_pii_redaction is not idempotent"
|
||||
disable_pii_redaction()
|
||||
|
||||
def test_pii_scrubber_scrubs_api_keys(self):
|
||||
"""The scrubber function must redact API-key-shaped strings."""
|
||||
from praisonaiagents.trace.redact import scrub_pii_text
|
||||
|
||||
raw = "my api_key=sk-ABCDEF12345 and password=hunter2"
|
||||
scrubbed = scrub_pii_text(raw)
|
||||
assert "sk-ABCDEF12345" not in scrubbed
|
||||
assert "hunter2" not in scrubbed
|
||||
assert "[REDACTED]" in scrubbed
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pii_state_snapshot():
|
||||
"""Snapshot of hook registry state used by C7 idempotency test."""
|
||||
from praisonaiagents.hooks import get_default_registry
|
||||
reg = get_default_registry()
|
||||
# Count hooks registered for BEFORE_LLM — we just want stable identity
|
||||
hooks = getattr(reg, "_hooks", None) or getattr(reg, "hooks", None) or {}
|
||||
if isinstance(hooks, dict):
|
||||
bl = hooks.get("before_llm") or hooks.get("BEFORE_LLM") or []
|
||||
return len(bl)
|
||||
return None
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Regression tests for GHSA-gmjg-hv98-qggq:
|
||||
Undeclared __main__ callables must NOT be executed via execute_tool.
|
||||
"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonaiagents.agent.tool_execution import ToolExecutionMixin
|
||||
|
||||
|
||||
class _HookRunner:
|
||||
def execute_sync(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
def is_blocked(self, results):
|
||||
return False
|
||||
|
||||
|
||||
class _DummyAgent(ToolExecutionMixin):
|
||||
def __init__(self, tools=None):
|
||||
self.name = "test_agent"
|
||||
self.tools = tools or []
|
||||
self.chat_history = []
|
||||
self._hook_runner = _HookRunner()
|
||||
self.context_manager = None
|
||||
self._doom_loop_tracker = None
|
||||
self._perm_deny = frozenset()
|
||||
self._perm_allow = None
|
||||
self._approval_backend = None
|
||||
|
||||
|
||||
def _make_approved_registry():
|
||||
reg = MagicMock()
|
||||
reg.approve_sync.return_value = MagicMock(
|
||||
approved=True, reason="mock", modified_args=None
|
||||
)
|
||||
reg.mark_approved = MagicMock()
|
||||
return reg
|
||||
|
||||
|
||||
def _sneaky_undeclared(msg="pwned"):
|
||||
"""Callable present only in __main__, never declared as a tool."""
|
||||
return {"ran": msg}
|
||||
|
||||
|
||||
def test_undeclared_main_callable_not_executed():
|
||||
"""An undeclared callable in __main__ must NOT be invoked."""
|
||||
# Inject the function into __main__ to simulate the exploit scenario.
|
||||
original = getattr(sys.modules["__main__"], "_sneaky_undeclared", None)
|
||||
sys.modules["__main__"]._sneaky_undeclared = _sneaky_undeclared
|
||||
try:
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[])
|
||||
result = agent.execute_tool("_sneaky_undeclared", {"msg": "hello"})
|
||||
# Should NOT have run the function — result must indicate not-found, not success.
|
||||
assert result != {"ran": "hello"}, (
|
||||
"Undeclared __main__ callable was executed — vulnerability still present"
|
||||
)
|
||||
finally:
|
||||
if original is None:
|
||||
delattr(sys.modules["__main__"], "_sneaky_undeclared")
|
||||
else:
|
||||
sys.modules["__main__"]._sneaky_undeclared = original
|
||||
|
||||
|
||||
def test_declared_tool_still_executes():
|
||||
"""A properly declared tool must still be callable after the fix."""
|
||||
def my_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[my_tool])
|
||||
result = agent.execute_tool("my_tool", {"x": 3})
|
||||
assert result == 6, f"Declared tool did not execute correctly: {result}"
|
||||
|
||||
|
||||
def test_globals_callable_not_executed():
|
||||
"""A callable only in tool_execution module globals must NOT be invoked."""
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[])
|
||||
# 'logging' is a module-level name in tool_execution; it must not be invocable.
|
||||
result = agent.execute_tool("logging", {})
|
||||
assert not isinstance(result, dict) or result.get("ran") is None, (
|
||||
"globals() fallback executed an unintended name"
|
||||
)
|
||||
|
||||
|
||||
def test_undeclared_returns_error_or_none():
|
||||
"""execute_tool with an unknown name should return None or an error dict, not raise."""
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[])
|
||||
result = agent.execute_tool("completely_nonexistent_xyz", {})
|
||||
# Result should be None or an error dict — not a successful execution.
|
||||
assert result is None or isinstance(result, dict), (
|
||||
f"Unexpected return type: {type(result)}"
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
assert result.get("ran") is None
|
||||
@@ -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,107 @@
|
||||
"""Tests for Claude Code subscription auth."""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import pytest
|
||||
from unittest.mock import patch, mock_open
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiagents.auth.subscription.claude_code import (
|
||||
ClaudeCodeAuth,
|
||||
_read_keychain_credentials,
|
||||
_read_file_credentials,
|
||||
_is_expiring,
|
||||
AuthError
|
||||
)
|
||||
|
||||
|
||||
def test_keychain_path_returns_none_off_darwin():
|
||||
"""Test keychain reading returns None on non-macOS platforms."""
|
||||
with patch.object(sys, 'platform', 'linux'):
|
||||
assert _read_keychain_credentials() is None
|
||||
|
||||
|
||||
def test_file_path_reads_credentials(monkeypatch):
|
||||
"""Test file credential reading."""
|
||||
# Create a temporary file with Claude Code credentials
|
||||
credentials_data = {
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat-test",
|
||||
"refreshToken": "rt-test",
|
||||
"expiresAt": 9999999999000,
|
||||
}
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
||||
json.dump(credentials_data, tmp)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
with patch('praisonaiagents.auth.subscription.claude_code.Path') as mock_path:
|
||||
mock_home_path = mock_path.home.return_value
|
||||
mock_claude_path = mock_home_path / ".claude" / ".credentials.json"
|
||||
mock_claude_path.exists.return_value = True
|
||||
mock_claude_path.read_text.return_value = json.dumps(credentials_data)
|
||||
|
||||
creds = _read_file_credentials()
|
||||
assert creds["accessToken"] == "sk-ant-oat-test"
|
||||
assert creds["refreshToken"] == "rt-test"
|
||||
assert creds["source"] == "claude-code-file"
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_is_expiring():
|
||||
"""Test token expiry detection."""
|
||||
import time
|
||||
current_ms = int(time.time() * 1000)
|
||||
|
||||
# Token expires in 30 seconds (should be considered expiring with default 60s skew)
|
||||
soon_expired = current_ms + 30_000
|
||||
assert _is_expiring(soon_expired) is True
|
||||
|
||||
# Token expires in 2 minutes (should not be expiring)
|
||||
not_expired = current_ms + 120_000
|
||||
assert _is_expiring(not_expired) is False
|
||||
|
||||
# No expiry time
|
||||
assert _is_expiring(0) is False
|
||||
assert _is_expiring(None) is False
|
||||
|
||||
|
||||
def test_resolve_credentials_uses_env_first(monkeypatch):
|
||||
"""Test that environment variables take precedence."""
|
||||
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat-from-env")
|
||||
|
||||
auth = ClaudeCodeAuth()
|
||||
creds = auth.resolve_credentials()
|
||||
|
||||
assert creds.api_key == "sk-ant-oat-from-env"
|
||||
assert creds.base_url == "https://api.anthropic.com"
|
||||
assert creds.headers["x-app"] == "cli"
|
||||
assert creds.headers["user-agent"].startswith("claude-cli/")
|
||||
assert creds.source == "env:ANTHROPIC_TOKEN"
|
||||
|
||||
|
||||
def test_resolve_credentials_no_auth_raises_error():
|
||||
"""Test that missing credentials raises AuthError."""
|
||||
auth = ClaudeCodeAuth()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch('praisonaiagents.auth.subscription.claude_code._read_keychain_credentials', return_value=None):
|
||||
with patch('praisonaiagents.auth.subscription.claude_code._read_file_credentials', return_value=None):
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
auth.resolve_credentials()
|
||||
|
||||
assert "No Claude Code credentials found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_headers_for_includes_required_oauth_headers():
|
||||
"""Test that OAuth-specific headers are included."""
|
||||
auth = ClaudeCodeAuth()
|
||||
headers = auth.headers_for("https://api.anthropic.com", "claude-3-haiku")
|
||||
|
||||
assert headers["anthropic-beta"] == "oauth-2025-04-20,interleaved-thinking-2025-05-14"
|
||||
assert headers["user-agent"] == "claude-cli/2.1.0 (external, cli)"
|
||||
assert headers["x-app"] == "cli"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for subscription auth protocols."""
|
||||
import pytest
|
||||
from praisonaiagents.auth.subscription.protocols import (
|
||||
SubscriptionCredentials,
|
||||
SubscriptionAuthProtocol,
|
||||
AuthError
|
||||
)
|
||||
|
||||
|
||||
def test_credentials_dataclass_defaults():
|
||||
"""Test SubscriptionCredentials dataclass defaults."""
|
||||
c = SubscriptionCredentials(api_key="test-key")
|
||||
assert c.api_key == "test-key"
|
||||
assert c.base_url == ""
|
||||
assert c.headers == {}
|
||||
assert c.auth_scheme == "bearer"
|
||||
assert c.expires_at_ms is None
|
||||
assert c.source == ""
|
||||
|
||||
|
||||
def test_protocol_runtime_check():
|
||||
"""Test that classes implementing the protocol are recognized."""
|
||||
class MockAuth:
|
||||
def resolve_credentials(self):
|
||||
return SubscriptionCredentials(api_key="test")
|
||||
def refresh(self):
|
||||
return SubscriptionCredentials(api_key="refreshed")
|
||||
def headers_for(self, base_url, model):
|
||||
return {"x-test": "header"}
|
||||
|
||||
assert isinstance(MockAuth(), SubscriptionAuthProtocol)
|
||||
|
||||
|
||||
def test_auth_error():
|
||||
"""Test AuthError exception."""
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
raise AuthError("test error")
|
||||
assert str(exc_info.value) == "test error"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for subscription auth registry."""
|
||||
import pytest
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials, AuthError
|
||||
from praisonaiagents.auth.subscription import registry as _reg
|
||||
from praisonaiagents.auth.subscription.registry import (
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
_REGISTRY
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry():
|
||||
"""Clean the registry before and after each test, resetting the builtin flag.
|
||||
|
||||
Direct access to _REGISTRY and _BUILTIN_REGISTERED is intentional here:
|
||||
we are testing the registry implementation itself and need full state control
|
||||
for test isolation.
|
||||
"""
|
||||
original_registry = _REGISTRY.copy()
|
||||
original_builtin_flag = _reg._BUILTIN_REGISTERED
|
||||
_REGISTRY.clear()
|
||||
_reg._BUILTIN_REGISTERED = False
|
||||
yield
|
||||
_REGISTRY.clear()
|
||||
_REGISTRY.update(original_registry)
|
||||
_reg._BUILTIN_REGISTERED = original_builtin_flag
|
||||
|
||||
|
||||
def test_register_and_resolve():
|
||||
"""Test registering and resolving a provider."""
|
||||
class MockAuth:
|
||||
def resolve_credentials(self):
|
||||
return SubscriptionCredentials(api_key="test-key", source="mock")
|
||||
def refresh(self):
|
||||
return self.resolve_credentials()
|
||||
def headers_for(self, *_):
|
||||
return {}
|
||||
|
||||
register_subscription_provider("test-mock", lambda: MockAuth())
|
||||
assert "test-mock" in list_subscription_providers()
|
||||
|
||||
creds = resolve_subscription_credentials("test-mock")
|
||||
assert creds.api_key == "test-key"
|
||||
assert creds.source == "mock"
|
||||
|
||||
|
||||
def test_resolve_unknown_provider():
|
||||
"""Test resolving an unknown provider raises AuthError."""
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
resolve_subscription_credentials("unknown-provider")
|
||||
|
||||
assert "Unknown subscription provider 'unknown-provider'" in str(exc_info.value)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Tests for IdentityResolverProtocol and InMemoryIdentityResolver.
|
||||
|
||||
W1 — Cross-platform identity linking. An IdentityResolver maps
|
||||
``(platform, platform_user_id)`` → ``unified_user_id`` so that the same
|
||||
human can be recognised across Telegram, Discord, Slack, etc.
|
||||
|
||||
Linking is OPT-IN. By default the resolver returns the platform-prefixed
|
||||
ID unchanged (no surprises, no privacy leak).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.identity import (
|
||||
IdentityLink,
|
||||
IdentityResolverProtocol,
|
||||
InMemoryIdentityResolver,
|
||||
)
|
||||
|
||||
|
||||
class TestIdentityLink:
|
||||
def test_link_holds_platform_user_unified(self):
|
||||
link = IdentityLink(
|
||||
platform="telegram",
|
||||
platform_user_id="12345",
|
||||
unified_user_id="alice",
|
||||
)
|
||||
assert link.platform == "telegram"
|
||||
assert link.platform_user_id == "12345"
|
||||
assert link.unified_user_id == "alice"
|
||||
|
||||
def test_link_is_immutable(self):
|
||||
link = IdentityLink("telegram", "12345", "alice")
|
||||
with pytest.raises((AttributeError, Exception)):
|
||||
link.unified_user_id = "bob" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestProtocolConformance:
|
||||
def test_in_memory_satisfies_protocol(self):
|
||||
resolver = InMemoryIdentityResolver()
|
||||
assert isinstance(resolver, IdentityResolverProtocol)
|
||||
|
||||
def test_protocol_has_required_methods(self):
|
||||
proto_methods = {"resolve", "link", "unlink", "links_for"}
|
||||
assert proto_methods.issubset(set(dir(IdentityResolverProtocol)))
|
||||
|
||||
|
||||
class TestInMemoryResolverDefaults:
|
||||
def test_unlinked_returns_platform_prefixed_id(self):
|
||||
"""When no link exists, return ``{platform}:{user_id}`` unchanged."""
|
||||
r = InMemoryIdentityResolver()
|
||||
assert r.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_unlinked_does_not_create_link(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.resolve("telegram", "12345")
|
||||
assert r.links_for("telegram:12345") == []
|
||||
|
||||
|
||||
class TestLinking:
|
||||
def test_link_two_platforms_resolves_to_same_unified(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.link("discord", "snowflake-1", "alice")
|
||||
assert r.resolve("telegram", "12345") == "alice"
|
||||
assert r.resolve("discord", "snowflake-1") == "alice"
|
||||
|
||||
def test_unlink_reverts_to_default(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.unlink("telegram", "12345")
|
||||
assert r.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_links_for_returns_all_platforms(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.link("discord", "snowflake-1", "alice")
|
||||
links = r.links_for("alice")
|
||||
assert len(links) == 2
|
||||
platforms = {link.platform for link in links}
|
||||
assert platforms == {"telegram", "discord"}
|
||||
|
||||
def test_link_overwrites_previous(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.link("telegram", "12345", "bob")
|
||||
assert r.resolve("telegram", "12345") == "bob"
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
def test_concurrent_links_dont_corrupt_state(self):
|
||||
import threading
|
||||
|
||||
r = InMemoryIdentityResolver()
|
||||
|
||||
def worker(i):
|
||||
r.link("telegram", f"u{i}", f"unified-{i}")
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(50)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
for i in range(50):
|
||||
assert r.resolve("telegram", f"u{i}") == f"unified-{i}"
|
||||
|
||||
|
||||
class TestFileIdentityResolver:
|
||||
def test_persists_across_instances(self, tmp_path):
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
r1 = FileIdentityResolver(path=path)
|
||||
r1.link("telegram", "12345", "alice")
|
||||
r1.link("discord", "snowflake-1", "alice")
|
||||
|
||||
# New instance, same file
|
||||
r2 = FileIdentityResolver(path=path)
|
||||
assert r2.resolve("telegram", "12345") == "alice"
|
||||
assert r2.resolve("discord", "snowflake-1") == "alice"
|
||||
|
||||
def test_unlink_persists(self, tmp_path):
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
r1 = FileIdentityResolver(path=path)
|
||||
r1.link("telegram", "12345", "alice")
|
||||
r1.unlink("telegram", "12345")
|
||||
|
||||
r2 = FileIdentityResolver(path=path)
|
||||
assert r2.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_corrupt_file_does_not_crash(self, tmp_path):
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
path.write_text("{not valid json", encoding="utf-8")
|
||||
r = FileIdentityResolver(path=path)
|
||||
# Falls back to empty state
|
||||
assert r.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_satisfies_protocol(self, tmp_path):
|
||||
from praisonaiagents.session.identity import (
|
||||
FileIdentityResolver,
|
||||
IdentityResolverProtocol,
|
||||
)
|
||||
r = FileIdentityResolver(path=tmp_path / "identity.json")
|
||||
assert isinstance(r, IdentityResolverProtocol)
|
||||
|
||||
def test_file_permissions_restricted(self, tmp_path):
|
||||
import sys
|
||||
if sys.platform.startswith("win"):
|
||||
pytest.skip("chmod semantics differ on Windows")
|
||||
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
r = FileIdentityResolver(path=path)
|
||||
r.link("telegram", "12345", "alice")
|
||||
# 0o600 = owner-only read/write
|
||||
mode = path.stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for task-local SessionContextVars.
|
||||
|
||||
W1 — Concurrent message handlers must NOT clobber each other's
|
||||
session metadata. Use ``contextvars.ContextVar`` so each asyncio task
|
||||
has its own copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.context import (
|
||||
SessionContext,
|
||||
clear_session_context,
|
||||
get_session_context,
|
||||
set_session_context,
|
||||
)
|
||||
|
||||
|
||||
class TestSetGet:
|
||||
def test_set_and_get_roundtrips(self):
|
||||
token = set_session_context(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
user_id="alice",
|
||||
)
|
||||
try:
|
||||
ctx = get_session_context()
|
||||
assert ctx.platform == "telegram"
|
||||
assert ctx.chat_id == "100"
|
||||
assert ctx.user_id == "alice"
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
|
||||
def test_default_context_is_empty(self):
|
||||
ctx = get_session_context()
|
||||
assert ctx.platform == ""
|
||||
assert ctx.chat_id == ""
|
||||
assert ctx.user_id == ""
|
||||
|
||||
def test_clear_restores_previous(self):
|
||||
token = set_session_context(platform="telegram", user_id="alice")
|
||||
clear_session_context(token)
|
||||
assert get_session_context().platform == ""
|
||||
|
||||
|
||||
class TestAsyncTaskIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_tasks_dont_clobber(self):
|
||||
seen = []
|
||||
|
||||
async def handler(name, delay):
|
||||
token = set_session_context(platform=name, user_id=name)
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
seen.append(get_session_context().platform)
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
|
||||
await asyncio.gather(
|
||||
handler("telegram", 0.02),
|
||||
handler("discord", 0.01),
|
||||
handler("slack", 0.03),
|
||||
)
|
||||
assert sorted(seen) == ["discord", "slack", "telegram"]
|
||||
|
||||
|
||||
class TestSessionContextDataclass:
|
||||
def test_context_has_all_fields(self):
|
||||
ctx = SessionContext(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
chat_name="DM",
|
||||
thread_id="t1",
|
||||
user_id="alice",
|
||||
user_name="Alice",
|
||||
unified_user_id="alice-global",
|
||||
)
|
||||
assert ctx.unified_user_id == "alice-global"
|
||||
|
||||
def test_context_dict_roundtrip(self):
|
||||
ctx = SessionContext(platform="telegram", user_id="alice")
|
||||
d = ctx.to_dict()
|
||||
ctx2 = SessionContext.from_dict(d)
|
||||
assert ctx2.platform == "telegram"
|
||||
assert ctx2.user_id == "alice"
|
||||
@@ -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
+204
-171
@@ -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]]
|
||||
@@ -1392,18 +1435,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 +1551,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 +1839,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 +1855,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 +2529,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.15.0"
|
||||
version = "2.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -2500,9 +2541,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 +2992,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.34"
|
||||
version = "1.6.40"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -2964,6 +3005,7 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
all = [
|
||||
{ name = "ast-grep-cli" },
|
||||
{ name = "chonkie" },
|
||||
{ name = "chromadb" },
|
||||
{ name = "crawl4ai" },
|
||||
@@ -3055,8 +3097,8 @@ requires-dist = [
|
||||
{ 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 +3111,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 +3119,7 @@ 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 = ["search"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["telemetry"], marker = "extra == 'all'" },
|
||||
{ name = "pydantic", specifier = ">=2.10.0" },
|
||||
@@ -3586,11 +3630,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 +4422,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-01",
|
||||
"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-01",
|
||||
"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-01
|
||||
> **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.34.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.34.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
# Suppress crewai.cli.config logger BEFORE any imports to prevent INFO log
|
||||
import logging
|
||||
import threading
|
||||
logging.getLogger('crewai.cli.config').setLevel(logging.ERROR)
|
||||
|
||||
# Version is lightweight, import directly
|
||||
from .version import __version__
|
||||
@@ -39,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
|
||||
@@ -50,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()))
|
||||
@@ -15,10 +15,11 @@ import os
|
||||
import logging
|
||||
import re
|
||||
import keyword
|
||||
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
|
||||
@@ -42,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
|
||||
|
||||
@@ -178,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.
|
||||
|
||||
@@ -192,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.
|
||||
@@ -232,10 +236,12 @@ class AgentsGenerator:
|
||||
self.tool_registry = ToolRegistry()
|
||||
self.tool_registry.register_builtin_autogen_adapters()
|
||||
|
||||
# Get framework adapter and validate availability
|
||||
# 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)
|
||||
if not self.framework_adapter.is_available():
|
||||
raise ImportError(f"Framework '{framework}' is not available. Please install the required dependencies.")
|
||||
|
||||
def _get_framework_adapter(self, framework: str) -> FrameworkAdapter:
|
||||
"""
|
||||
@@ -250,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):
|
||||
"""
|
||||
@@ -321,6 +326,47 @@ class AgentsGenerator:
|
||||
agent_config[field] = value
|
||||
self.logger.debug(f"CLI override for agent {agent_name}: {field} = {value}")
|
||||
|
||||
def _validate_agents_config(self, config):
|
||||
"""
|
||||
Validate agent configuration for typos in field names and provide suggestions.
|
||||
|
||||
Args:
|
||||
config (dict): The parsed YAML configuration
|
||||
"""
|
||||
known_fields = {
|
||||
'role', 'goal', 'instructions', 'backstory', 'tools', 'tasks', 'llm',
|
||||
'function_calling_llm', 'allow_delegation', 'max_iter', 'max_rpm',
|
||||
'max_execution_time', 'verbose', 'cache', 'system_template',
|
||||
'prompt_template', 'response_template', 'tool_timeout', 'planning_tools',
|
||||
'planning', 'autonomy', 'guardrails', 'streaming', 'stream',
|
||||
'approval', 'skills', 'cli_backend', 'reflection'
|
||||
}
|
||||
|
||||
for section_name in ('agents', 'roles'):
|
||||
section = config.get(section_name, {})
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
|
||||
entity_name = 'agent' if section_name == 'agents' else 'role'
|
||||
for name, section_config in section.items():
|
||||
if not isinstance(section_config, dict):
|
||||
continue
|
||||
|
||||
for field_name in section_config:
|
||||
if field_name in known_fields:
|
||||
continue
|
||||
|
||||
close_matches = difflib.get_close_matches(
|
||||
field_name,
|
||||
known_fields,
|
||||
n=1,
|
||||
cutoff=0.6
|
||||
)
|
||||
suggestion = f" Did you mean '{close_matches[0]}'?" if close_matches else ""
|
||||
self.logger.warning(
|
||||
f"Unknown field '{field_name}' in {entity_name} '{name}'.{suggestion}"
|
||||
)
|
||||
|
||||
def is_function_or_decorated(self, obj):
|
||||
"""
|
||||
Checks if the given object is a function or has a __call__ method.
|
||||
@@ -335,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):
|
||||
@@ -371,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):
|
||||
"""
|
||||
@@ -411,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):
|
||||
"""
|
||||
@@ -506,6 +513,10 @@ class AgentsGenerator:
|
||||
|
||||
# Get workflow input: 'input' is canonical, 'topic' is alias for backward compatibility
|
||||
topic = config.get('input', config.get('topic', ''))
|
||||
|
||||
# Validate agents configuration for typos in field names
|
||||
self._validate_agents_config(config)
|
||||
|
||||
tools_dict = {}
|
||||
|
||||
# Use ToolResolver to get available tools (consistent tool resolution)
|
||||
@@ -546,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"):
|
||||
@@ -597,12 +606,20 @@ class AgentsGenerator:
|
||||
self.framework = framework
|
||||
self.framework_adapter = self._get_framework_adapter(framework)
|
||||
|
||||
# Final availability check
|
||||
if not self.framework_adapter.is_available():
|
||||
raise ImportError(f"Framework '{framework}' is not available. Please install the required dependencies.")
|
||||
|
||||
# Validate framework availability for non-CLI callers
|
||||
from .framework_adapters.validators import assert_framework_available
|
||||
assert_framework_available(framework)
|
||||
|
||||
self.logger.info(f"Using framework: {framework}")
|
||||
return self.framework_adapter.run(config, self.config_list, topic)
|
||||
return self.framework_adapter.run(
|
||||
config,
|
||||
self.config_list,
|
||||
topic,
|
||||
tools_dict=tools_dict,
|
||||
agent_callback=getattr(self, 'agent_callback', None),
|
||||
task_callback=getattr(self, 'task_callback', None),
|
||||
cli_config=getattr(self, 'cli_config', None),
|
||||
)
|
||||
|
||||
def _run_yaml_workflow(self, config):
|
||||
"""
|
||||
@@ -732,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
|
||||
|
||||
@@ -884,10 +905,13 @@ class AgentsGenerator:
|
||||
api_type = _resolve("api_type", default="openai").lower()
|
||||
model_name = _resolve("model", default="gpt-4o-mini")
|
||||
api_key = _resolve("api_key", env_var="OPENAI_API_KEY")
|
||||
# Use resolver for consistent env-var precedence as fallback
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
base_url = (model_config.get("base_url")
|
||||
or yaml_llm.get("base_url")
|
||||
or os.environ.get("OPENAI_BASE_URL")
|
||||
or os.environ.get("OPENAI_API_BASE"))
|
||||
or ep.base_url)
|
||||
|
||||
# Build LLMConfig — Bedrock needs no api_key
|
||||
if api_type == "bedrock":
|
||||
@@ -1116,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
|
||||
|
||||
@@ -1135,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
|
||||
@@ -1144,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
|
||||
@@ -1162,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():
|
||||
@@ -1416,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,24 +250,87 @@ 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 execution statistics.
|
||||
Get current execution statistics (synchronous, best-effort).
|
||||
|
||||
Warning: This method provides a best-effort view of stats without
|
||||
guaranteeing atomicity. For consistent snapshots in async context,
|
||||
use get_stats_async() instead.
|
||||
|
||||
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": 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]:
|
||||
"""
|
||||
Get current execution statistics with atomic snapshot (async).
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
if self._stats_lock is None:
|
||||
# Not yet started: stats are all zero, no lock needed
|
||||
execs, success, failed = 0, 0, 0
|
||||
else:
|
||||
# Take atomic snapshot of all counters
|
||||
async with self._stats_lock:
|
||||
execs = self._execution_count
|
||||
success = self._success_count
|
||||
failed = self._failure_count
|
||||
|
||||
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
|
||||
"total_executions": execs,
|
||||
"successful_executions": success,
|
||||
"failed_executions": failed,
|
||||
"success_rate": (success / execs * 100) if execs > 0 else 0
|
||||
}
|
||||
|
||||
def get_stats_sync(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Alias for get_stats() for clarity.
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
return self.get_stats()
|
||||
|
||||
async def _run_schedule(self, interval: int, max_retries: int):
|
||||
"""Internal method to run scheduled agent executions."""
|
||||
try:
|
||||
@@ -289,7 +352,10 @@ class AsyncAgentScheduler:
|
||||
|
||||
async def _execute_with_retry(self, max_retries: int):
|
||||
"""Execute agent with retry logic."""
|
||||
self._execution_count += 1
|
||||
self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop
|
||||
|
||||
async with self._stats_lock:
|
||||
self._execution_count += 1
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(max_retries):
|
||||
@@ -300,7 +366,8 @@ class AsyncAgentScheduler:
|
||||
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
|
||||
logger.info(f"Result: {result}")
|
||||
|
||||
self._success_count += 1
|
||||
async with self._stats_lock:
|
||||
self._success_count += 1
|
||||
safe_call(self.on_success, result)
|
||||
return
|
||||
|
||||
@@ -313,7 +380,8 @@ class AsyncAgentScheduler:
|
||||
logger.info(f"Waiting {wait_time}s before async retry...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
self._failure_count += 1
|
||||
async with self._stats_lock:
|
||||
self._failure_count += 1
|
||||
logger.error(f"Async agent execution failed after {max_retries} attempts")
|
||||
safe_call(
|
||||
self.on_failure,
|
||||
|
||||
@@ -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
|
||||
@@ -467,21 +430,49 @@ class BaseAutoGenerator:
|
||||
Args:
|
||||
config_list: Optional LLM configuration list
|
||||
"""
|
||||
# Support multiple environment variable patterns for better compatibility
|
||||
model_name = os.environ.get("MODEL_NAME") or os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini")
|
||||
base_url = (
|
||||
os.environ.get("OPENAI_BASE_URL") or
|
||||
os.environ.get("OPENAI_API_BASE") or
|
||||
os.environ.get("OLLAMA_API_BASE", "https://api.openai.com/v1")
|
||||
)
|
||||
# Resolve LLM endpoint configuration from environment variables
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
self.config_list = config_list or [
|
||||
{
|
||||
'model': model_name,
|
||||
'base_url': base_url,
|
||||
'api_key': os.environ.get("OPENAI_API_KEY")
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'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:
|
||||
"""
|
||||
@@ -518,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,
|
||||
@@ -632,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.
|
||||
|
||||
@@ -650,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
|
||||
@@ -1540,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
|
||||
@@ -12,10 +12,13 @@ if TYPE_CHECKING:
|
||||
from .discord import DiscordBot
|
||||
from .slack import SlackBot
|
||||
from .whatsapp import WhatsAppBot
|
||||
from .linear import LinearBot
|
||||
from .email import EmailBot
|
||||
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
|
||||
@@ -36,6 +39,9 @@ def __getattr__(name: str):
|
||||
if name == "WhatsAppBot":
|
||||
from .whatsapp import WhatsAppBot
|
||||
return WhatsAppBot
|
||||
if name == "LinearBot":
|
||||
from .linear import LinearBot
|
||||
return LinearBot
|
||||
if name == "EmailBot":
|
||||
from .email import EmailBot
|
||||
return EmailBot
|
||||
@@ -63,11 +69,29 @@ def __getattr__(name: str):
|
||||
if name == "HTTPApproval":
|
||||
from ._http_approval import HTTPApproval
|
||||
return HTTPApproval
|
||||
# W1 — cross-platform mirror + identity
|
||||
if name == "mirror_to_session":
|
||||
from ._mirror import mirror_to_session
|
||||
return mirror_to_session
|
||||
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", "EmailBot", "AgentMailBot",
|
||||
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot", "LinearBot", "EmailBot", "AgentMailBot",
|
||||
"Bot", "BotOS",
|
||||
"BotSessionManager",
|
||||
"InboundDLQ", "DLQEntry",
|
||||
"SlackApproval", "TelegramApproval", "DiscordApproval",
|
||||
"WebhookApproval", "HTTPApproval",
|
||||
# W1
|
||||
"mirror_to_session", "BotSessionManager",
|
||||
]
|
||||
@@ -94,12 +94,15 @@ async def classify_with_llm(
|
||||
)
|
||||
|
||||
try:
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
client = OpenAI(
|
||||
api_key=_os.environ.get("OPENAI_API_KEY", ""),
|
||||
base_url=_os.environ.get("OPENAI_BASE_URL"),
|
||||
api_key=ep.api_key or "",
|
||||
base_url=ep.base_url,
|
||||
)
|
||||
response = client.chat.completions.create(
|
||||
model=_os.environ.get("APPROVAL_LLM_MODEL", "gpt-4o-mini"),
|
||||
model=_os.environ.get("APPROVAL_LLM_MODEL", ep.model),
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
|
||||
@@ -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"]
|
||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user