Compare commits

...
64 Commits
Author SHA1 Message Date
praison a72e156c4d Release v4.6.40 2026-05-19 07:23:30 +01:00
Mervin Praison 24385d6487 Merge pull request #1686 from MervinPraison/security/batch-12-remaining
refactor: security batch 3 — platform service scoping (do not auto-merge)
2026-05-19 07:21:49 +01:00
praisonai-triage-agent[bot]andMervin Praison 2f8162207f security: fix workspace scoping gaps in issue service
- Add workspace_id parameter to assign() and transition() methods
- Fix SECURITY_TRIAGE.md duplicate entries and formatting
- Ensures cross-tenant mutations are prevented

Co-authored-by: Mervin Praison <[email protected]>
2026-05-19 06:07:52 +00:00
praisonandCursor 516d8b6d88 refactor: enforce workspace scope in platform services
Add workspace_id checks to issue, project, and agent services;
restrict admin/owner role assignment to owners; default platform bind
to 127.0.0.1; scope dependency deletes to parent issue.

Co-authored-by: Cursor <[email protected]>
2026-05-19 07:01:41 +01:00
praisonandCursor 7eb98e895b chore: add security release and advisory publish scripts
Co-authored-by: Cursor <[email protected]>
2026-05-19 06:57:05 +01:00
Mervin Praison ef79b7a056 Merge pull request #1685 from MervinPraison/security/batch-11-hardening
refactor: security batch 2 — platform IDOR and deploy hardening
2026-05-19 06:56:40 +01:00
praisonandCursor 3ea8376610 refactor: harden platform scoping and deploy output sanitisation
Extend workspace resource checks to projects, labels, dependencies, and
activity; sanitise legacy deploy HTML output; harden example eval paths;
fix workspace delete membership cleanup; add security regression tests.

Co-authored-by: Cursor <[email protected]>
2026-05-19 06:56:26 +01:00
MervinPraison 6360b24c06 docs: auto-update feature parity trackers [skip ci] 2026-05-19 05:54:22 +00:00
Mervin Praison b0d8f77752 Merge pull request #1684 from MervinPraison/security/batch-10-hardening
refactor: security batch — 10 hardening fixes
2026-05-19 06:53:57 +01:00
praisonandCursor 179cab02db refactor: harden input validation and access controls
Batch security hardening across agents, wrapper, and platform:
sandbox AST checks, SSRF URL validation, MCP path containment,
fail-closed call-server auth, write_file workspace bounds, platform
IDOR scoping, workspace RBAC, GHA branch quoting, and JWT issuance guard.

Co-authored-by: Cursor <[email protected]>
2026-05-19 06:34:42 +01:00
praison 402d7ed9fc Release v4.6.39 2026-05-19 06:28:39 +01:00
701e8e6a6a fix: wrapper layer architectural fixes for concurrency safety (#1680) (#1681)
* fix: wrapper layer architectural fixes for concurrency safety (#1680)

- Replace _run_praisonai event loop creation with _async_bridge.run_sync to prevent deadlocks in nested async contexts
- Remove module-level OpenAI client LRU cache that evicts in-use clients; move to per-instance ownership in BaseAutoGenerator
- Replace inbuilt_tools custom lazy-import scaffolding with existing _load_optional pattern to eliminate DRY violations

Fixes #1680

Co-authored-by: MervinPraison <[email protected]>

* test: align lazy-loading tests with per-instance OpenAI client ownership

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/682c9dfc-e131-45a2-816e-05044bc4a04a

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback on concurrency and error handling

- Fix race condition in OpenAI client initialization/teardown with proper locking
- Fix AttributeError in __del__ method for partially initialized objects
- Fix RuntimeError swallowing in InteractiveRuntime exception handling to preserve fail-fast semantics
- Fix PRAISONAI_TOOLS_AVAILABLE coupling to allow tools with just praisonai_tools package
- Add missing trailing newline to inbuilt_tools/__init__.py

Addresses feedback from CodeRabbit, Greptile, and Copilot code reviews.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-18 16:35:04 +01:00
MervinPraison ef09341cc0 docs: auto-update feature parity trackers [skip ci] 2026-05-18 15:34:14 +00:00
Jonathan Hill 3a4d7e11c8 fix: guard against empty/filtered LLM responses in core LLM paths (#1682)
Add null checks before accessing choices[0].message in four files:
- openai_client.py: sync/async tool-call paths and responses-API path
- guardrails.py: check/aguard result extraction
- rag.py: query_rag and aquery_rag answer extraction
- code_agent.py: generate/review/explain/refactor/fix methods
2026-05-18 16:33:32 +01:00
92644518c0 fix: wrapper layer architectural fixes for concurrency safety (#1673)
* fix: wrapper layer architectural fixes for concurrency safety

- Fix InMemoryJobStore: Add locks to all read methods to prevent inconsistent snapshots under concurrency
- Fix scheduler retry: Replace uninterruptible time.sleep with Event.wait for responsive stop()
- Fix ToolRegistry: Add threading.Lock synchronization to match other registries

Fixes #1672

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - dead code, missing lock, unused import, tests

- Remove broken try-except in _get_lock() (loop var was unused, second call
  would have re-raised the same RuntimeError) - per Greptile P2 inline comment
- Protect get_stats() with list() snapshot to prevent RuntimeError on concurrent
  dict mutation - per Greptile P1 finding
- Remove unused `import time` from agent_scheduler.py (time.sleep was replaced
  with Event.wait() in the PR changes)
- Add newline at end of tool_registry.py
- Fix existing retry tests that patched time.sleep (now gone) to mock
  _stop_event.wait() instead; add two new tests that verify Event.wait behavior
- Add TestToolRegistryThreadSafety tests for concurrent register/read/clear
- Add TestInMemoryJobStoreConcurrency tests for concurrent async saves/reads
  and get_stats() snapshot correctness

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/82f4b33b-1b67-4451-be2c-4f53c57bdcf1

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - dead code, missing lock, unused import, tests

Fixes identified by Greptile, CodeRabbit, and Copilot reviewers:

1. InMemoryJobStore.get_stats() - Added missing async lock protection
   - Method now takes snapshot under lock to prevent concurrent mutation
   - Made get_stats() async to match other store methods

2. Simplified _get_lock() implementation
   - Removed dead try-except code that had unused 'loop' variable
   - Kept lazy initialization pattern for Python 3.9 compatibility

3. Validated ToolRegistry threading safety
   - Confirmed threading.Lock is correct (not asyncio.Lock)
   - ToolRegistry is used in sync contexts, not async
   - Thread safety tests pass

All syntax checks and focused tests pass.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-17 04:17:48 +01:00
cd54574b20 fix: Resolve architectural gaps in registry fragmentation and singleton patterns (#1675)
* fix: Resolve architectural gaps in registry fragmentation and singleton patterns

This commit addresses three critical architectural violations identified in issue #1674:

**Gap 1 - Registry Consolidation**: Unified four different registry patterns under canonical PluginRegistry:
- Enhanced PluginRegistry with alias support for consistent naming
- Migrated LLMProviderRegistry from class-level singleton to PluginRegistry inheritance
- Converted cli_backends from module-level dict to PluginRegistry pattern
- Refactored StoreRegistry to inherit from PluginRegistry instead of duplicating logic

**Gap 2 - Singleton Elimination**: Removed process-global singletons for multi-agent safety:
- Converted Profiler from __new__ singleton to per-instance with context-aware fallback
- Replaced LLMProviderRegistry._instance with module-level default pattern
- Added create_exec_approval_manager() for per-agent ExecApprovalManager instances
- Refactored telemetry init to avoid process-wide os.environ mutation

**Gap 3 - Async Surface**: Added complete async interface to PraisonAIDB adapter:
- Implemented aon_* async versions of all lifecycle hooks (aon_user_message, etc.)
- Added async context manager support (__aenter__, __aexit__, aclose)
- Graceful fallback: async methods detect store capabilities and use asyncio.to_thread for sync stores

All changes maintain backward compatibility while enabling proper multi-agent isolation and async safety.

Fixes #1674

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <[email protected]>

* fix: Resolve Profiler compatibility issue with per-instance delegation pattern

- Renamed Profiler class to _ProfilerImpl to avoid circular reference
- Created ProfilerCompat delegation wrapper for backward compatibility
- Added context-aware profiler support via get_profiler()
- Fixed all decorators and class-level API calls to work correctly
- Maintains full backward compatibility while enabling per-agent isolation

Resolves critical compatibility issue identified by Copilot review

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-17 04:16:33 +01:00
68035af76d fix(tests): BotOS integration tests use registry unregister (#1669)
* fix(tests): unregister mock bot platforms via BotPlatformRegistry

Co-authored-by: Cursor <[email protected]>

* fix(tests): add exception handling to _unregister_bot_platform helper

Prevents cleanup failures from masking primary test errors by catching
KeyError/ValueError exceptions during platform unregistration.

Addresses Gemini reviewer feedback.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-15 07:49:31 +01:00
MervinPraison c989f2c31e docs: auto-update feature parity trackers [skip ci] 2026-05-14 18:16:52 +00:00
a2c51a0760 fix(praisonaiagents): export execute_code via lazy tools loader (#1666)
* fix(tools): lazy-export execute_code as module function

Co-authored-by: Cursor <[email protected]>

* test(tools): add regression test for execute_code lazy export

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a9d10f35-24e5-4e53-a064-043ecb6a0b08

Co-authored-by: MervinPraison <[email protected]>

* fix(tools): extend lazy-export pattern to all python code tools

Maps analyze_code, format_code, lint_code, disassemble_code, and python_tools
to direct function imports (None) instead of PythonTools class instantiation.
This ensures consistent lazy loading, fixes module alias imports, and avoids
unnecessary dependency checks until functions are actually called.

Addresses reviewer feedback for architectural consistency.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:16:21 +01:00
7a601b5373 fix(tests): align framework and datastore mocks with current APIs (#1665)
* fix(tests): patch get_default_registry and session store loader

Co-authored-by: Cursor <[email protected]>

* test: scope datastore impl cache reset via patch.object

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/2a49bd60-577c-45b9-8236-3ee18f853a01

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-14 19:16:16 +01:00
12f430e29c fix(tests): preserve provider API keys from the environment (#1664)
* fix(tests): only set placeholder API keys when unset

Co-authored-by: Cursor <[email protected]>

* test: add focused coverage for setup_test_environment fixture

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* test: dedupe provider key list in fixture coverage

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* fix(tests): use existence check instead of truthiness for env vars

Addresses issue identified by all reviewers: the previous check
'if not os.environ.get(key)' treated empty strings as missing and
would overwrite them, inconsistent with the intent to preserve
any pre-existing environment variable values.

Now uses 'if key not in os.environ' which only sets placeholders
when the key is truly absent, aligning with the teardown logic.

Co-authored-by: Mervin Praison <[email protected]>

* test: avoid hardcoded fixture test path default

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* test: clarify fixture helper naming in env setup tests

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:15:51 +01:00
c3c78fbc7b docs: verify pytest failure categories (#1663)
* docs: add verification notes for pytest failure categories

Co-authored-by: Cursor <[email protected]>

* docs: fix verification command path assumptions

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/8421f477-fa95-4465-a7b1-0c1c6da79fa2

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback on pytest verification guide

- Add missing executable verification commands for sections 4 & 5
- Fix conftest.py to only set placeholder API keys when unset/empty
- Align implementation with documented behavior for mixed testing

Co-authored-by: Mervin Praison <[email protected]>

* Delete docs/testing/VERIFY_TEST_FAILURE_CATEGORIES.md

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:14:43 +01:00
bf120c8dca fix(tests): bot _BUILTIN_PLATFORMS uses lazy loaders (#1667)
* fix(tests): expect lazy loader callables in bot registry

Co-authored-by: Cursor <[email protected]>

* fix(tests): improve bot registry tests with identity checks

- Replace weak __name__ assertions with robust identity checks
- Use 'is' operator instead of name comparison for loader validation
- Addresses Gemini Code Assist feedback for more reliable tests

Co-authored-by: Mervin Praison <[email protected]>

* test(bots): split loader-shape and adapter-identity assertions

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/b57884c2-8621-4f00-94e1-85eeb82a1cd5

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-14 19:12:37 +01:00
praisonai-triage-agent[bot]andMervinPraison 13d073b02a fix: move scripts folder from root to appropriate location (#1661)
* fix: move scripts folder from root to appropriate location (fixes #1659)

- Move all smoke test scripts from /scripts to src/praisonai/tests/integration/
- Update documentation in moved files to reflect new paths
- Per AGENTS.md architecture, these bot/BotOS integration tests belong in praisonai wrapper tests
- Preserves git history with git mv command

Co-authored-by: MervinPraison <[email protected]>

* fix: add __test__ = False to smoke test files to prevent pytest collection

- Prevents accidental pytest discovery in environments with different collection rules
- Follows Python testing best practices for non-test files in test directories
- Addresses Copilot's review feedback for explicit test exclusion

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-05-13 10:33:57 +01:00
praison adcfa29941 Release v4.6.38 2026-05-13 10:28:46 +01:00
7efd0075da fix: resolve architectural gaps in src/praisonai/praisonai (#1658)
* fix: resolve architectural gaps in src/praisonai/praisonai (fixes #1657)

- Fix framework adapter dispatch kwargs mismatch by adding required keyword-only arguments to AutoGenAdapter, AutoGenV4Adapter, and AG2Adapter run() methods
- Fix InteractiveRuntime lifecycle management by keeping runtime alive during agent execution instead of stopping it prematurely
- Consolidate tools.py loading paths by making ToolResolver the single source of truth and replacing duplicate loading logic in agents_generator.py
- Remove unused load_tools_from_tools_py() method
- Update ToolResolver to use _safe_loader for consistent security model

Co-authored-by: MervinPraison <[email protected]>

* fix: define tools_path in ToolResolver local loader logging

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/bee41da9-2153-41cd-9834-439aa753a848

Co-authored-by: MervinPraison <[email protected]>

* fix: resolve critical NameError and event loop leak

- Fix NameError in ToolResolver._load_local_tools() for undefined tools_path variable
- Fix event loop leak in AgentsGenerator when InteractiveRuntime startup fails
- Change autogen_v4/ag2 adapters to raise NotImplementedError instead of returning strings

🤖 Generated with Claude Code

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-13 10:11:11 +01:00
MervinPraison 9ec90361d6 docs: auto-update feature parity trackers [skip ci] 2026-05-13 06:04:03 +00:00
2e5540a41b fix: check agent server readiness timeout return value (fixes #1631) (#1655)
* fix: check agent server readiness timeout return value (fixes #1631)

- Add return value checking for ready_event.wait() to detect timeouts
- Log warning when server doesn't become ready within timeout period
- Make timeout configurable via PRAISONAI_SERVER_READY_TIMEOUT env var
- Add comprehensive regression tests for timeout behavior

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: address reviewer feedback - add error handling for invalid timeout env var

- Add try/catch for float() conversion to prevent ValueError crashes
- Prevent real server spawning in unit tests via Thread.start mocking
- Add test for invalid env var fallback behavior
- Preserve other env vars during testing (avoid clear=True)

Addresses feedback from Gemini, CodeRabbit, Qodo, and Copilot reviewers.

Co-authored-by: Mervin Praison <[email protected]>

* fix: use monkeypatch.delenv and move import time to module level

- Replace patch.dict(os.environ, {"": ""}) in test_default_timeout_value
  with monkeypatch.delenv so the test exercises the true "env var unset"
  default path rather than the ValueError fallback path
- Move `import time` from inside test function body to module-level imports

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/5c472f85-484f-4a03-b47a-9307639ecd0a

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-13 07:03:37 +01:00
7fcd6af4c6 fix(claw): add .env.example and document TAVILY_API_KEY requirement (#1649)
* fix(claw): add .env.example and document TAVILY_API_KEY requirement (fixes #1648)

- Add comprehensive .env.example at repository root with all required and optional environment variables
- Document TAVILY_API_KEY requirement in README.md Claw section with link to get free API key
- Fixes silent failures on first run by providing clear variable reference template
- Improves developer onboarding experience for new Claw users

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix(env): use LOGLEVEL key in root .env.example

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/7fe15070-18b9-4586-ba5e-a70c271db412

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-12 14:06:07 +01:00
MervinPraison 1fb71c46b4 docs: auto-update feature parity trackers [skip ci] 2026-05-12 13:05:59 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] ca46eb1297 fix: replace deprecated datetime.utcnow() with datetime.now(timezone.utc) (fixes #1647) (#1650)
- Replace datetime.utcnow() calls across multiple components
- Update imports to include timezone module
- Ensure Python 3.12+ compatibility and prevent 3.14 runtime errors
- Fixed 10 critical files including CLI, jobs, agents, memory, and context components

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-12 14:05:16 +01:00
Mervin Praisonandpraisonai-bot 422212105a fix(cli): graceful BudgetExceededError handling (wrapper-only, no Agent param bloat) (#1645)
Re-implements the CLI UX half of reverted PR #1635 without the core SDK
changes that violated AGENTS.md.

## What changed
- Add _execute_agent_with_budget_handling(agent, method_name, *args, **kwargs)
  helper on PraisonAIApp: calls agent.<method_name>(...) and catches
  BudgetExceededError to print a single-line actionable CLI message and
  exit(1) instead of leaking a raw traceback.
- Route all 17 agent.start(prompt) / agent.chat(prompt) / auto_rag.chat(prompt)
  call sites in the direct-prompt dispatch through the helper. Covers every
  display mode: silent, quiet, verbose, debug, jsonl, json, flow, editor, default.

## What is NOT changed (intentionally)
- Zero edits to praisonaiagents (core SDK).
- NO new 'max_budget=' parameter on Agent.__init__.
- Users continue to configure budgets via the already-consolidated
  execution=ExecutionConfig(max_budget=...) pattern per AGENTS.md §5.3
  'Parameter Consolidation'.

## Why wrapper-only
AGENTS.md §4.1 reserves core SDK for protocols/hooks/adapters — CLI UX
belongs in the wrapper. AGENTS.md §5.3 explicitly consolidates execution-
related knobs (max_iter, max_rpm, max_budget, rate_limiter, ...) into
ExecutionConfig precisely to avoid adding more top-level Agent params.

## Error message
Points users to the correct API:
    Budget limit exceeded: <reason>.
    Hint: set budget via execution=ExecutionConfig(max_budget=1.00) on your Agent.

Closes #1627

Co-authored-by: praisonai-bot <[email protected]>
2026-05-09 22:59:05 +01:00
Mervin Praisonandpraisonai-bot b2611e5d61 feat(examples): add scientific writing examples (CAJAL re-implementation) (#1644)
Re-implements the CAJAL / scientific-paper functionality that was reverted
from the core SDK (PR #1611 / revert #1641) as proper examples per AGENTS.md:

- examples/scientific_writing/scientific_writer.py
  Single-agent pattern: regular Agent + @tool functions, no subclass.
  Uses the CAJAL-4B HuggingFace model via litellm.

- examples/scientific_writing/multi_agent_paper.py
  Multi-agent workflow: Literature Reviewer -> Methodologist -> Writer
  composed with AgentTeam + sequential Tasks (no custom Agent classes).

- examples/scientific_writing/agents.yaml
  YAML parity for the multi-agent workflow (AGENTS.md §7 '3-way' rule).

Why this location:
- AGENTS.md §4.1: core SDK has no heavy domain implementations.
- AGENTS.md §4.4: compose specialised Agents + tools, don't subclass Agent.
- AGENTS.md §6.1: domain-specific behaviour belongs in @tool functions.
- Wrapper / praisonai-tools were alternatives; examples/ keeps it
  discoverable and avoids bloating either package.

Co-authored-by: praisonai-bot <[email protected]>
2026-05-09 22:53:51 +01:00
Mervin Praison 78f0a71e71 Revert "fix: resolve BudgetExceededError CLI traceback issues (fixes #1627) (…" (#1642)
This reverts commit 2e05f2c0f2.
2026-05-09 22:43:59 +01:00
MervinPraison e01f63dba2 docs: auto-update feature parity trackers [skip ci] 2026-05-09 21:42:35 +00:00
Mervin Praison 99638a9129 Revert "feat: Integrate CAJAL Scientific Writer Agent (#1611)" (#1641)
This reverts commit a41a8a22d6.
2026-05-09 22:42:08 +01:00
MervinPraison a410bc88bd docs: auto-update api.md [skip ci] 2026-05-09 21:40:20 +00:00
7bcdfafb4d feat(N4): inbound dead-letter queue for failed bot messages (#1600)
* feat(N4): inbound dead-letter queue for failed bot messages

Persist failed agent.chat() calls to a SQLite-backed DLQ so user
messages are never silently lost on transient LLM failures.

- praisonai/bots/_dlq.py: InboundDLQ + DLQEntry (sqlite3 stdlib only,
  WAL mode, atomic writes via per-instance threading.Lock; TTL +
  max_size eviction; async replay with success/failure callback).
- praisonai/bots/_session.py: BotSessionManager(dlq=) optional param;
  chat() now enqueues on agent.chat() exception then re-raises (so
  adapter still shows user a friendly error). Default OFF preserves
  legacy behaviour bit-for-bit.
- praisonai/bots/__init__.py: lazy exports for InboundDLQ, DLQEntry,
  BotSessionManager.
- praisonai/cli/commands/bot.py: 'praisonai bot dlq {list,replay,purge}'
  subcommand group with --path / --limit / --yes flags.
- tests/unit/bots/test_dlq.py: 11 unit tests (basic/persistence/bounds/
  TTL/replay/wired-failure-path).
- scripts/smoke_n4_real.py: real agentic E2E — patch agent.chat to fail
  once, assert DLQ holds entry, replay through real LLM (anthropic/
  claude-haiku-4-5) returns expected answer.

Test results:
  11/11 DLQ unit tests pass
  102/102 bot/session/gateway regression pass (no W1 here, single
    pre-existing unrelated test deselected)
  Real agentic: 'What is 2+2?' fails, lands in DLQ, replay -> '4'

Docs: features/inbound-dlq.mdx with mermaid (Dark Red agents / Teal
tools / white text), CardGroup, Tabs, ParamField, Expandable,
AccordionGroup, Tip/Warning/Info/Check/Badge per Mintlify.

Closes N4.

* fix(dlq): address critical P1/P2 issues from architecture review

- Fix non-atomic SQLite enqueue operations by using explicit transactions
- Fix event loop blocking by wrapping DLQ calls in run_in_executor
- Fix replay ordering to process oldest entries first (not newest)
- Fix CLI exit codes for user cancellation and failed replays
- Fix smoke test to fail fast when no supported API key is found

Resolves critical issues identified by Greptile, CodeRabbit, Gemini reviewers

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: Cascade <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: praison <[email protected]>
2026-05-09 22:40:08 +01:00
a41a8a22d6 feat: Integrate CAJAL Scientific Writer Agent (#1611)
* feat: integrate CAJAL Scientific Writer Agent for specialized paper generation

- Add ScientificWriterAgent class with CAJAL model support
- Implement PaperSection and ScientificPaper data structures
- Add LaTeX formatting capabilities for academic output
- Create comprehensive example showing multi-agent scientific workflows
- Add unit tests for all new functionality
- Follow protocol-driven design with lazy loading
- Support both CAJAL and standard models for flexibility

Fixes #1610

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: resolve unit test failures and bugs in ScientificWriterAgent

- Move `from .agent import Agent` to module level so @patch decorators
  in tests can resolve the attribute (fixes 8 failing unit tests)
- Add `import re` at module level; remove duplicate in-method imports
- Fix broken regex in _extract_section: `#{{{name}}}` was generating
  literal curly braces, now uses `##?\s+{re.escape(name)}` which
  correctly matches markdown section headers
- Fix misleading _get_cajal_model docstring/comment
- Remove root-level test_cajal_integration.py artifact (belongs in tests/)

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/37052ea9-0c0b-4ee3-9083-cb1af5f12d9a

Co-authored-by: MervinPraison <[email protected]>

* fix: improve _extract_section regex to handle indented markdown headers

Allow optional whitespace before section headers in the lookahead
(n\s*##?\s instead of \n##?\s) so that indented LLM output like
'        ## Introduction' correctly terminates the preceding section.
Without this, the abstract section would greedily capture all
subsequent sections until end-of-string.

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/37052ea9-0c0b-4ee3-9083-cb1af5f12d9a

Co-authored-by: MervinPraison <[email protected]>

* fix: resolve __getattr__ infinite recursion in ScientificWriterAgent

Adds guard to prevent infinite recursion when self.agent is unset during
initialization failures. This was the final P1 issue identified by Greptile.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-09 22:40:05 +01:00
praisonai-triage-agent[bot]andMervin Praison c18450bb35 fix: eliminate singleton registries and optimize scheduler async handling (fixes #1638) (#1639)
* fix: eliminate singleton registries and optimize scheduler async handling (fixes #1638)

* Replace singleton pattern with dependency injection across 3 registries
* Create generic PluginRegistry base class for DRY implementation
* Convert FrameworkAdapterRegistry to use dependency injection
* Convert ExternalAgentRegistry to use dependency injection
* Convert bot platforms registry to use dependency injection
* Replace asyncio.run() in scheduler hot path with _async_bridge
* Maintain backward compatibility for all existing APIs
* Add entry-points support consistently across all registries

Resolves all three architectural gaps:
1. No more process-global singleton violations
2. Unified registry pattern eliminates code duplication
3. Scheduler uses persistent event loop instead of per-call overhead

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: MervinPraison <[email protected]>

* fix: resolve registry backward compatibility and performance issues

- Fix eager loading regression in bots/_registry.py by implementing lazy registry access
- Fix get_platform_registry() to return {name: class} instead of {name: name} for backward compatibility
- Fix thread-safety race condition in PluginRegistry.resolve() error handling
- Update stale docstring in integrations/registry.py to use get_default_registry()

Addresses all P1 and P2 issues identified by code review bots.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-05-09 22:40:03 +01:00
9955d65f82 fix: replace unsafe eval() with safe AST parser in README example (#1626)
* fix: replace unsafe eval() with safe AST parser in README example (fixes #1625)

- Replace eval(expression) with AST-based arithmetic parser in custom tools example
- Add comprehensive security warning about eval(), exec(), subprocess usage
- Prevent arbitrary code execution while preserving arithmetic functionality
- Blocks malicious inputs like __import__('os').system('whoami')

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* docs: normalize README calculator error handling and blockquote formatting

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/f86d8d08-03ae-4f02-8c4b-21dc53e084dc

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-09 22:40:00 +01:00
80c78071fc fix: resolve critical correctness, concurrency, and safety gaps (#1637)
* fix: resolve critical correctness, concurrency, and safety gaps (fixes #1636)

- Fix NameError: define missing AGENTOPS_AVAILABLE flag and add lazy import with error handling
- Fix data race: acquire _stats_lock around all counter mutations and reads in async scheduler
- Fix security bypass: route public tool-loading methods through _safe_loader.py

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - clean up docstring, ruff warnings, and lock consistency

- Remove stale FileNotFoundError from load_tools_from_module docstring
- Add noqa comments for broad Exception catches in agentops blocks
- Fix misleading comment in get_stats() and make is_running snapshot consistent
- Replace deprecated asyncio.get_event_loop() with get_running_loop()
- Add None guard for _stats_lock in stop() method to match get_stats() pattern

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praison <[email protected]>
2026-05-09 22:39:57 +01:00
2e05f2c0f2 fix: resolve BudgetExceededError CLI traceback issues (fixes #1627) (#1635)
* fix: resolve BudgetExceededError CLI traceback issues (fixes #1627)

- Add clean error handling for BudgetExceededError in CLI
  * Wrap agent execution calls with graceful error handling
  * Show user-friendly error messages instead of raw tracebacks
  * Exit with code 1 and provide clear remediation guidance

- Add max_budget parameter to Agent constructor
  * Accept max_budget directly as convenience parameter
  * Automatically create/update ExecutionConfig with budget setting
  * Maintain full backward compatibility
  * Add proper parameter documentation

Both fixes follow architecture guidelines and provide intuitive UX.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: address review feedback for budget handling and import safety

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/16ae1b3d-1c3c-4051-9465-a718a6746a90

Co-authored-by: MervinPraison <[email protected]>

* fix: resolve print shadowing and simplify budget error message

- Import rich print as rich_print to avoid shadowing builtin
- Consolidate multi-line error output into single actionable message
- Update guidance to mention new max_budget convenience parameter

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

* fix: resolve print shadowing and simplify budget error message

- Remove dangerous ImportError fallback in budget error handling
- Use dataclasses.replace() instead of mutating shared ExecutionConfig
- Simplify budget error to single actionable message
- Address CodeRabbit, Greptile, and Copilot review feedback

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-09 22:39:55 +01:00
4985415e61 fix: improve memory provider error messages for missing packages (fixes #1630) (#1634)
* fix: improve memory provider error messages for missing packages (fixes #1630)

- Update mem0 import error messages to suggest pip install 'praisonaiagents[memory]'
- Modified Memory class to show ImportError for explicitly requested providers
- Preserve graceful fallback behavior for default provider
- Fixes misleading 'API Key not provided' error when mem0ai package is missing

Changes:
- memory/adapters/factories.py: Updated mem0 ImportError message
- knowledge/adapters/mem0_adapter.py: Updated mem0 ImportError message
- memory/memory.py: Show ImportError for explicit provider requests

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: resolve critical issues in memory provider error handling

- Fix factory return value being discarded in memory.py
- Remove silent exception swallowing that broke backward compatibility
- Fix datetime import bug in MongoDB adapter (datetime.timezone.utc)
- Preserve original error propagation for misconfigured providers
- Ensure factory-created adapters are properly used when successful

Fixes issues identified by Greptile and CodeRabbit reviewers.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-08 10:48:10 +01:00
a29deefef7 fix: add validation for agents.yaml field typos with suggestions (fixes #1628) (#1633)
* fix: add validation for agents.yaml field typos with suggestions (fixes #1628)

- Add difflib import for fuzzy string matching
- Implement _validate_agents_config() method in AgentsGenerator
- Validates against known fields: role, goal, instructions, backstory, tools, llm
- Uses difflib.get_close_matches() to suggest corrections for typos
- Example: 'instrutions' -> 'Did you mean instructions?'
- Logs warnings using existing logger infrastructure
- Add comprehensive regression tests for validation functionality
- Maintains full backward compatibility - only adds warnings

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: tighten agent field validation and activate unit coverage

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/b7d4d6b8-e9fc-455a-bacf-02ff54e3fb49

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-08 10:47:52 +01:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 7ffb4f87c5 fix: add missing [autonomy] and [os] extras to [all] group (fixes #1629) (#1632)
The [all] extra group was missing [autonomy] and [os] extras, causing
ImportError when users tried to use autonomy features after installing
pip install 'praisonaiagents[all]'.

This change adds both missing extras to ensure complete feature coverage:
- [autonomy]: ast-grep-cli>=0.30.0
- [os]: FastAPI + uvicorn + PyJWT

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-08 10:47:29 +01:00
057dab0ec0 fix: resolve architectural gaps in praisonai wrapper (fixes #1623) (#1624)
* fix: resolve architectural gaps in praisonai wrapper (fixes #1623)

- Fix Issue 1: LLM registry now registers built-in providers (openai, anthropic, google) with LiteLLM backend
- Fix Issue 2: DB adapter get_runs() and get_traces() now read from state store instead of returning empty lists
- Fix Issue 3: LangfuseSink uses LIFO stack correlation for proper parallel tool span matching and replaces print with logging

All fixes maintain backward compatibility and follow protocol-driven architecture per AGENTS.md.

Co-authored-by: MervinPraison <[email protected]>

* fix: address critical architectural issues identified by reviewers

- Fix LiteLLM provider TypeError by stripping 'provider' key from config
- Fix DB adapter write-side gap allowing state store writes without conversation store
- Fix tool stacks cleanup on LangfuseSink shutdown
- Fix limit=0 logic error in get_runs/get_traces
- Add exception chaining for ImportError
- Add error handling for state store reads

Co-authored-by: Mervin Praison <[email protected]>

* test: align wrapper tests and harden provider/langfuse edge cases

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/62fe6186-39b5-43e2-85e3-491cc09facdf

Co-authored-by: MervinPraison <[email protected]>

* fix: tighten wrapper edge cases and align unit coverage

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/62fe6186-39b5-43e2-85e3-491cc09facdf

Co-authored-by: MervinPraison <[email protected]>

* fix: clean up duplicate loop and add async support for LiteLLM provider

- Remove duplicate _tool_stacks cleanup loop in LangfuseSink.close()
- Add generate_async() method to _LiteLLMProvider for event loop safety
- Extract _resolve_model_and_kwargs() helper to avoid duplication
- Addresses async concerns for BotSessionManager.chat and AgentFlow usage

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-08 10:46:18 +01:00
89229c5c76 fix: resolve architectural gaps in praisonai wrapper (fixes #1620) (#1621)
* fix: resolve architectural gaps in praisonai wrapper (fixes #1620)

- Replace bare except: blocks with specific exception handling and logging
  - metrics.py: Handle litellm import/attribute errors with debug logging
  - aicoder.py: Proper file read error handling (FileNotFoundError, PermissionError, UnicodeDecodeError)
  - agent_scheduler.py: Handle timestamp parsing errors
  - browser/cli.py: Handle page load timeout/connection errors

- Consolidate LLM endpoint env-var precedence into single resolver
  - Created praisonai/llm/env.py with resolve_llm_endpoint() function
  - Enforces consistent precedence: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE
  - Updated 8+ call sites to use resolver, eliminating duplication

- Move framework availability checks to CLI entry point for fail-fast behavior
  - Created framework_adapters/validators.py with actionable error messages
  - Validation happens before YAML parsing, saving startup time on failures
  - Removed duplicate availability checks from all adapter run() methods

These changes address all three architectural gaps identified in the issue:
silent error masking, fragmented env-var precedence, and late framework checks.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: MervinPraison <[email protected]>

* fix: remove unused NoReturn import; add unit tests for env.py and validators.py

- validators.py: remove unused `from typing import NoReturn` import
- tests/unit/llm/test_env_resolver.py: 15 tests for resolve_llm_endpoint()
  covering precedence, defaults, API key, frozen dataclass
- tests/unit/test_framework_validators.py: 8 tests for assert_framework_available()
  covering error messages, install hints, and success paths

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a147d9a2-6f9e-42f6-b2e5-0dba331316bf

Co-authored-by: MervinPraison <[email protected]>

* fix: resolve critical issues from architecture review

- Fix double /v1 URL bug in realtime WebSocket connections
- Restore framework validation for non-CLI callers
- Handle all OSError subclasses in file reading
- Use module logger consistently

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-08 10:46:02 +01:00
e20e426dd5 feat(core): wire seed, cancel_token, tool validator, PII redactor into Agent.chat (Phase A) (#1617)
* feat(core): wire seed, cancel_token, tool validator, PII redactor into Agent.chat (Phase A)

Four gap-closures identified in the core-features analysis. Every change
is opt-in and zero-overhead when unused — existing agents behave
identically unless the new kwargs or APIs are actively used.

C1 — Deterministic seed
  Agent.chat(..., seed=42) is now threaded into the LLM payload via
  llm_kwargs['seed']. Overrides any seed set on the LLM instance for
  this call only. (chat_mixin.py)

C2 — Cooperative cancellation
  Agent.chat(..., cancel_token=InterruptController()) is checked at
  agent start and right before the LLM call. A tripped token raises
  InterruptedError instead of hitting the network. Falls back to
  self.interrupt_controller when no per-call token is supplied.
  (chat_mixin.py)

C4 — Tool-argument validator
  _execute_tool_with_context now consults agent._tool_validator (any
  ToolValidatorProtocol impl) after BEFORE_TOOL hooks. Invalid args
  short-circuit to an error string; the tool function is never called.
  Validator exceptions are swallowed so a buggy validator can never
  break tool execution. (tool_execution.py)

C7 — PII redactor for LLM egress
  trace/redact.py gains scrub_pii_text(), enable_pii_redaction(), and
  disable_pii_redaction(). enable_pii_redaction() registers an
  idempotent BEFORE_LLM hook that rewrites message content with
  REDACT_KEYS + value-pattern rules (OpenAI keys, SSN, CC, email).
  chat_mixin now reads back before_llm_input.messages after the hook
  runs so modified_input actually reaches the LLM. (trace/redact.py,
  trace/__init__.py, chat_mixin.py)

Tests
  tests/unit/agent/test_core_phase_a.py — 9 TDD tests (all passing)
  tests/smoke_core_phase_a_real.py — real Claude Haiku 4.5 run,
    exercises all four features together

Regression sweep: 4616 unit tests pass, 0 new failures.
Pre-existing test-pollution failures (plugins/permissions/thread_safety)
unchanged.

* fix: resolve P1 issues in core phase A implementation

- Fix agent_end trace never emitted on cancellation by moving cancel check inside try block
- Fix credit card regex too broad by restricting to canonical 4-group or 16-digit patterns
- Add thread safety to PII redaction with mutex lock
- Add missing seed and cancel_token parameters to achat method for API parity
- Forward seed and cancel_token properly in async LLM calls

Co-authored-by: Mervin Praison <[email protected]>

* fix(pii): remove false-positive \d{16} CC regex — matches timestamps/trace IDs

@claude: The bare \b\d{16}\b branch matched ANY 16-digit sequence (UNIX microsecond
timestamps, distributed trace IDs, order/invoice numbers) causing false-positive
redactions in LLM tool outputs and RAG results. Removed — the canonical 4-group
pattern (?:\d{4}[ -]){3}\d{4} covers real-world formatted cards with near-zero FP rate.

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-08 10:45:15 +01:00
d49c559fce fix: resolve shell injection, session cache races, and dead stats lock (#1609)
* fix: resolve shell injection, session cache races, and dead stats lock (fixes #1608)

- Add shell=False parameter to sandbox backends with safe command parsing
- Fix shell injection in subprocess.py, docker.py, and ssh.py
- Add thread-safe session cache operations to PersistenceOrchestrator
- Fix AsyncAgentScheduler stats lock usage with atomic counter operations
- Create shared _shell.py utility for consistent command handling

Security fixes:
1. Shell injection: Default to safe argv execution, explicit shell opt-in
2. Session races: Thread-safe cache with RLock and defensive copying
3. Dead lock: Use existing _stats_lock for atomic counter updates

Co-authored-by: MervinPraison <[email protected]>

* fix: correct test await, dead ssh branch, unused import, and docker shlex placement

- Fix test_get_stats_initial_state: add await before get_stats() (now async)
- Remove dead if/else branches in ssh._run_command_with_limits (both identical)
- Remove unused 'from ._shell import build_argv' in ssh.run_command
- Move 'import shlex' to top-level in docker.py (was inside method body)
- Add missing EOF newline to _shell.py"

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/3ece618b-0046-4e3b-b0e9-a3dfc870f1a7

Co-authored-by: MervinPraison <[email protected]>

* fix: restore backward compatibility for get_stats() method

- Revert get_stats() from async to sync to fix breaking change
- Add get_stats_async() for atomic stats in async context
- Update test to use sync get_stats() again
- Minor comment cleanup in SSH sandbox

Fixes breaking API change affecting 8+ call sites across CLI, examples, and tests.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-08 10:42:52 +01:00
de84d22087 feat(W1): cross-platform mirror + unified per-user session (#1599)
* feat(W1): cross-platform mirror + unified per-user session

- Core SDK: IdentityResolverProtocol, IdentityLink, InMemoryIdentityResolver
- Core SDK: task-local SessionContext via contextvars (async-safe)
- Wrapper: BotSessionManager.identity_resolver param + _persist_key separation
- Wrapper: praisonai.bots.mirror_to_session() outbound delivery mirror
- Tests: 17 core SDK + 8 wrapper unit tests; 0 regressions on 146 session + 99 bot tests
- Real agentic smoke: anthropic/claude-haiku-4-5 recalled cross-platform fact
- Docs: features/cross-platform-mirror.mdx

Closes W1.

* feat(W1): wire identity_resolver through Bot/BotOS + persistent FileIdentityResolver

- FileIdentityResolver: JSON-backed persistent resolver (atomic writes, 0o600 perms)
- Bot(identity_resolver=...) constructor param + adapter splice
- BotOS(identity_resolver=...) propagates to all bots (shortcut + add_bot)
- BotSessionManager.chat() now sets task-local SessionContext (platform/chat/user/unified_user_id)
- ContextVars propagated into executor thread via copy_context()
- Telegram/Discord/Slack handlers pass chat_id + thread_id + user_name to chat()
- 5 new FileIdentityResolver tests + 7 Bot/BotOS wiring tests = 12 new tests
- 151 core SDK session tests + 106 wrapper bot/session tests pass
- Real agentic smoke (BotOS API): claude-haiku-4-5 recalled cross-platform 'darkwave'
- Docs updated with high-level BotOS API quickstart

Closes W1.

* fix(W1): hold agent_lock across full LLM call to prevent history leak

Pre-existing concurrency bug exposed by W1 robustness suite (T3):
agent_lock only protected the history swap, not the LLM call.
Concurrent users on a shared Agent could observe each other's
chat_history during the LLM round-trip.

Fix: scope agent_lock to cover the whole load+swap+chat+capture
sequence. Throughput on a shared agent is bounded by serial LLM
latency — correct, since chat_history is a mutable instance attr.

- New regression test test_no_history_leak_between_concurrent_users
- All 16 W1 unit tests pass; 5/5 W1 robustness real-LLM tests pass:
  T1 3-platform unification | T2 restart persistence | T3 concurrent
  users isolated | T4 SessionContext visible to tools | T5 self-
  improving probe (correctly demonstrates W1 boundary vs W2)

* fix(review): address Qodo/Coderabbit/Gemini reviewer feedback on W1 PR

- memory_mixin: add -> bool return type to _add_to_chat_history_if_not_duplicate (Gemini)
- execution_mixin: add -> Optional[str] return type to arun (Gemini)
- chat_mixin: remove unused asyncio, threading, traceback, Callable, OrderedDict (Coderabbit/Qodo)
- execution_mixin: remove unused os, get_logger (Coderabbit/Qodo)
- memory_mixin: remove unused get_logger, List, Any (Coderabbit/Qodo)
- identity.py: fix TOCTOU race in FileIdentityResolver._flush() by adding _flush_lock
  that serialises the full snapshot+disk-write sequence
- _mirror.py: clarify comment - _storage_key() is a duck-typing check, not bucket creation
All 151 core SDK + 16 W1 bot tests pass.

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a8f7b411-9c5e-426b-99ce-bcfea79deaaa

Co-authored-by: MervinPraison <[email protected]>

* fix(review): address Qodo/Coderabbit/Gemini reviewer feedback on W1 PR

Critical fixes for race conditions and robustness:

P1 Race Condition Fixes:
- FileIdentityResolver._flush: Hold _lock across entire write operation to prevent TOCTOU race where concurrent link() calls could cause data loss during os.replace()
- mirror_to_session: Add thread-safe _add_mirror_entry_sync() method to BotSessionManager that properly coordinates with asyncio locks used by chat()

P2 Robustness Fixes:
- Session context cleanup: Use try/finally in chat() to ensure clear_session_context() is always called, even on agent exceptions
- Variable shadowing: Fix reset() method to use distinct storage_key/persist_key variables instead of reusing 'key'
- Model identifiers: Update smoke tests to use valid model names (claude-3-5-haiku-latest, gemini-1.5-flash)

All fixes maintain backward compatibility and follow AGENTS.md architecture guidelines.

Co-authored-by: Mervin Praison <[email protected]>

* fix(review): address Qodo/Coderabbit/Gemini reviewer feedback on W1 PR

Comprehensive fixes for race conditions and code quality issues:

P1 Race Condition Fixes:
- Fixed mirror_to_session sync path race: added per-user threading locks
  when no event loop is running to prevent concurrent history corruption
- Reserved field protection in mirror entries: filter metadata to prevent
  callers from overwriting role/mirror/timestamp invariants

P2 Code Quality Fixes:
- Enhanced session context cleanup logging for debugging
- Use UTC timestamps in mirror entries for consistency
- Remove unused imports from _add_mirror_entry_sync
- Fix f-string literals without placeholders (Ruff F541)
- Fix hardcoded developer paths in smoke test docstrings
- Fix integration test skip condition to require explicit RUN_REAL_AGENTIC opt-in

All fixes maintain backward compatibility and follow AGENTS.md principles.

🤖 Generated with Claude Code

* fix(mirror): replace process-wide global lock with per-call/per-manager lock

@claude: The module-level _mirror_lock = threading.RLock() serialised
mirror_to_session() calls across ALL BotSessionManager instances in the process.
Slow disk I/O for one bot would block mirror ops for every other bot.
Now accepts optional 'lock' param (caller supplies their own per-manager lock)
or falls back to a fresh per-call RLock — never shared across bot instances.

---------

Co-authored-by: Cascade <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: praison <[email protected]>
2026-05-08 10:42:06 +01:00
MervinPraison f3166b5bbd docs: auto-update feature parity trackers [skip ci] 2026-05-08 08:20:46 +00:00
0d1cc32b23 feat: add subscription-token auth for Claude Pro/ChatGPT Plus/Gemini CLI/Qwen CLI (#1619)
* feat: add subscription-token auth for Claude Pro/ChatGPT Plus/Gemini CLI/Qwen CLI (fixes #1618)

- Add subscription auth protocol infrastructure in praisonaiagents/auth/subscription/
- Implement provider-specific auth for Claude Code, Codex, Gemini CLI, and Qwen CLI
- Add auth= parameter to LLM and Agent classes with lazy credential resolution
- Include critical OAuth headers for Anthropic tokens (prevents 500s)
- Add comprehensive unit and integration tests
- Maintain backward compatibility - zero impact when auth= not used
- Follow protocol-driven design patterns from AGENTS.md

Co-authored-by: MervinPraison <[email protected]>

* fix: address review findings in subscription auth implementation

- Add get_subscription_provider() public helper to registry to avoid
  leaking _REGISTRY/_LOCK internals (used by _refresh_subscription_creds)
- Remove dead `import threading` from LLM._refresh_subscription_creds
- Fix _BUILTIN_REGISTERED not being reset in test registry fixture,
  preventing builtin providers re-registering after clear (test-order bug)
- Add pytest.importorskip('pydantic') to integration tests so CI skips
  them gracefully when LLM extras are not installed
- Correct _is_expiring type hint from int to Optional[int] (already
  handled None at runtime; type now matches)
- Expose get_subscription_provider via subscription/__init__.py and
  auth/__init__.py __all__ / __getattr__ lazy-loading chains

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/31dd06b1-5ae4-43b5-90ce-669cdc5c6db6

Co-authored-by: MervinPraison <[email protected]>

* fix: address critical bugs in subscription auth implementation

- Fix dead refresh code: Wire auth error detection to credential refresh in retry loops
- Fix race condition: Move registry imports inside lock to prevent concurrent access issues
- Fix stale credentials: Add expiration checking to clear expired cached credentials
- Implement automatic 401 retry with refreshed OAuth tokens as advertised

Addresses review findings from Greptile, CodeRabbit, and Copilot.

Co-authored-by: Mervin Praison <[email protected]>

* fix: resolve critical auth bugs in subscription token handling

- Fix OAuth Bearer auth: detect auth_scheme=bearer and use Authorization header instead of x-api-key
- Fix refresh-on-401: trigger on attempt=0 and don't clear fresh credentials
- Fix function signature: use correct _build_completion_params(**kwargs) signature

Fixes critical P0 bugs that made subscription auth non-functional.

Co-authored-by: Mervin Praison <[email protected]>

* fix: resolve remaining critical auth bugs in subscription token handling

- Fix Bearer auth: Use litellm native sk-ant-oat-* detection instead of manual headers
- Update litellm dependency: Bump to >=1.83.14 to resolve CVE-2026-42208 and supply-chain vulns
- Gate experimental endpoints: Codex/Gemini now raise clear errors until proper transports available
- Add comprehensive smoke test: Tests real auth flows with mocked LLM calls
- Clean up minor issues: Remove __import__('time'), simplify header duplication

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>

* fix: add missing OAuth beta headers and fix smoke test bugs

- Add 3 missing OAuth beta headers to match Hermes reference:
  - claude-code-20250219 (Claude Code identity beta)
  - fine-grained-tool-streaming-2025-05-14 (tool streaming improvements)
  - context-1m-2025-08-07 (1M-token context window support)
- Implement dynamic Claude Code version detection with caching
- Fix smoke test bugs:
  - Patch LLM class instead of Agent for _classify_error_and_should_retry
  - Remove mocks that prevent real provider code execution
  - Fix fragile argument extraction using call_args.kwargs
  - Remove content assertion that depends on mocked response

This achieves full Hermes parity for the litellm path and ensures
Claude Pro/Max users get 1M context + Claude Code identity routing.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-05-08 09:20:12 +01:00
e33b4b4b39 fix: resolve architectural gaps in praisonai wrapper (fixes #1614) (#1615)
* fix: resolve architectural gaps in praisonai wrapper (fixes #1614)

- Fixed stubbed framework adapters: PraisonAI adapter now has working implementation instead of stub message, CrewAI adapter accepts full parameter signature
- Unified duplicate sandbox/compute hierarchies: Created SandboxToComputeAdapter to eliminate code duplication between sandbox/ and integrations/compute/ backends
- Removed import-time side effects: Moved load_dotenv() calls from module level to function level, moved crewai logger config to adapter, removed eager CLI backend registration

All changes maintain backward compatibility while following the protocol-driven architecture principles.

Co-authored-by: MervinPraison <[email protected]>

* fix: address review findings - restore sandbox APIs, fix _compat.py bugs, clean up load_dotenv wrappers

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/32fa524b-bf40-4e5a-b58d-156b5e103347

Co-authored-by: MervinPraison <[email protected]>

* fix: handle TIMEOUT exit code (124) in SandboxToComputeAdapter

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/32fa524b-bf40-4e5a-b58d-156b5e103347

Co-authored-by: MervinPraison <[email protected]>

* fix: wire tools and callbacks in framework adapters (resolves reviewer feedback)

- Add tools_dict wiring in PraisonAI and CrewAI adapters
- Fix PEP 484 type annotations (Dict[str, Any] = None -> Optional[Dict[str, Any]] = None)
- Ensure agent tools, agent_callback, and task_callback are properly applied
- Maintains backward compatibility while fixing functional regression

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-05 11:04:43 +01:00
MervinPraison c9bbb5aee7 docs: auto-update api.md [skip ci] 2026-05-05 06:48:55 +00:00
ed63d34ed7 feat: Linear integration — agentic control plane (closes #1612) (#1613)
* feat: add Linear integration — agentic control plane with tools + LinearBot + AgentSession webhooks

- Add linear_tools.py with 9 GraphQL tools (search, create, update issues, comments, cycles)
- Add LinearBot adapter with HMAC-SHA256 webhook verification and AgentSession handling
- Add Linear platform to bot registry, CLI commands, and gateway integration
- Support both LINEAR_OAUTH_TOKEN (OAuth) and LINEAR_API_KEY (personal) authentication
- Include example configurations (YAML and Python) for zero-code and programmatic usage
- Follow protocol-driven architecture: tools in core SDK, bot implementation in wrapper
- Zero import-time cost via lazy loading, full BotProtocol compliance

Closes #1612

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: MervinPraison <[email protected]>

* fix: correct four bugs in Linear integration found during review

- _handle_agent_message: replace non-existent get_session() context
  manager with correct _session_mgr.chat() call (matches Slack/Telegram
  pattern in _session.py)
- _gql_query: fix aiohttp timeout — bare int 30 → ClientTimeout(total=30)
- bot_linear CLI: fix web_provider= → web_search_provider= to match
  BotCapabilities dataclass field (same as all other bot commands)
- bot_linear CLI: add `or []` guards for tools/skills/knowledge_sources
  to prevent None being passed where list is expected (matches all other
  bot commands)

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/645d14d2-c605-41bf-b83a-839209e7e3b9

Co-authored-by: MervinPraison <[email protected]>

* fix: resolve 3 blocking Linear integration bugs

- Fix BotMessage constructor field names (id->message_id, text->content)
- Fix OAuth Bearer token handling in both tools and bot implementations
- Fix send_message signature to match BotProtocol specification

All fixes verified against the original bug reproduction cases.

Co-authored-by: Mervin Praison <[email protected]>

* refactor: relocate Linear tools to praisonai-tools (canonical home)

Linear GraphQL wrappers don't belong in the protocol-first core SDK
(per AGENTS.md §4.1). They duplicated an already-shipped LinearTool in
MervinPraison/PraisonAI-Tools and pulled third-party SaaS HTTP plumbing
into praisonaiagents/tools/.

This commit:
- Removes praisonaiagents/tools/linear_tools.py (the new file)
- Reverts the 9 TOOL_MAPPINGS additions and the 3 lookup-list entries
- Leaves LinearBot in praisonai/bots/linear.py unchanged (correct location:
  wrapper-side webhook adapter)

The full Linear tool surface (search/get/create/update/comment/list cycles/
list states/list teams) is being landed in MervinPraison/PraisonAI-Tools#31
with OAuth Bearer support and 36 unit tests. Users get it via:

    from praisonai_tools import LinearTool

praisonai-tools is already a runtime dep of praisonai (pyproject.toml:102),
so end-user UX is unchanged.

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praison <[email protected]>
2026-05-05 07:48:29 +01:00
Cascade d15c57d80c Release v4.6.37 2026-05-04 17:23:45 +01:00
Cascade 73eaacad35 chore: bump praisonaiagents to 1.6.37 2026-05-04 17:22:57 +01:00
Cascade 0cec9fd1c3 refactor: harden archive extraction and tool resolution boundary 2026-05-04 17:22:33 +01:00
Cascade 7a7df247c8 Release v4.6.36 2026-05-04 06:26:02 +01:00
Cascade cb96d03b58 chore: bump praisonaiagents to 1.6.36 2026-05-04 06:25:12 +01:00
Cascade d8a8a78691 Release v4.6.35 2026-05-03 07:35:13 +01:00
Cascade 120d299d85 chore: bump praisonaiagents to 1.6.35 2026-05-03 07:34:51 +01:00
praisonai-triage-agent[bot] 2738e31d1f fix: resolve persistence schema drift, factory registry patterns, and LLM registry thread safety (#1597)
Validated locally: 77/77 unit tests pass (LLM registry + persistence). Fixed incomplete schema drift in async_sqlite/async_postgres/async_mysql: table_prefix default, state column, tool_calls/tool_call_id columns across all CRUD operations. 25 pre-existing failures on main unrelated to this PR.
2026-05-03 06:41:10 +01:00
198 changed files with 11548 additions and 2592 deletions

No files matched your search

+43
View File
@@ -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
+5 -2
View File
@@ -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
+39 -2
View File
@@ -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
+34
View File
@@ -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
+26
View File
@@ -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
+4
View File
@@ -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>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.34" \
"praisonai>=4.6.40" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.34" \
"praisonai>=4.6.40" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.34" \
"praisonai>=4.6.40" \
"praisonai[ui]" \
"praisonai[crewai]"
+68
View File
@@ -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)
+40
View File
@@ -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)
+26 -5
View File
@@ -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}"
+35
View File
@@ -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
+57
View File
@@ -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.
+6 -4
View File
@@ -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]
+204 -171
View File
@@ -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
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.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]"
+36 -12
View File
@@ -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."""
+228
View File
@@ -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()))
+133 -93
View File
@@ -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
+14 -2
View File
@@ -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,
+47 -58
View File
@@ -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
+25 -1
View File
@@ -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},
+299
View File
@@ -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