Compare commits

...
21 Commits
Author SHA1 Message Date
MervinPraison 17aa27739e Release v0.2.46 2026-10-31 20:34:10 +00:00
praisonai-triage-agent[bot] fb6fb48816 Merge pull request #118 from MervinPraison/claude/issue-6168-20261031
feat: add You.com tools (migrated from praisonaiagents core)
2026-10-31 20:33:07 +00:00
MervinPraisonandClaude Opus 5.5 0fac8a0aa5 fix: honour explicit YouTools api_key, expose ydc alias, tighten missing-key test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:21:52 +00:00
MervinPraisonandClaude Opus 5.5 072585870e feat: add You.com tools migrated from praisonaiagents core (fixes MervinPraison/PraisonAI#6168)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:17:30 +00:00
Mervin PraisonandClaude Opus 5.5 436a66e163 ci: bump github-automation-template to v1.1.20 (#117)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-29 02:38:18 +01:00
Mervin PraisonandClaude Opus 5.5 e18de78873 ci: bump github-automation-template to v1.1.19 (#116)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 11:42:24 +01:00
Mervin PraisonandClaude Opus 5.5 b8ee7dc5b2 chore(ci): github-automation-template v1.1.18 (#115)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 09:46:30 +01:00
Mervin PraisonandClaude Opus 5.5 edb4a66564 fix(ci): github-automation-template v1.1.17 (permission ceiling) (#114)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:34:43 +01:00
Mervin PraisonandClaude Opus 5.5 d332f6e28d chore(ci): github-automation-template v1.1.16 (#113)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:27:20 +01:00
d3de3800a2 chore(ci): bump github-automation-template to v1.1.15 (#112)
* chore(ci): bump github-automation-template to v1.1.15

Writers-only auto-Claude, least-privilege tokens for Claude on fork PRs and
in the merge-gate assessor, merge-gate held-out BLOCKs (no repeat verdicts on
an unchanged HEAD), portable prompts. auto-pr-comment sweep moves to hourly so
stale FINAL reviews are re-posted once a push settles.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: sync automation.meta.json hubRef to v1.1.15

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:55:55 +01:00
Mervin PraisonandClaude Opus 5.5 7dbfbdf5ae fix(ci): github-automation-template v1.1.14 (fork PR isolation) (#111)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:58:50 +01:00
Mervin PraisonandClaude Opus 5.5 47c30e3c1d fix(ci): require write access to invoke @claude (template v1.1.13) (#110)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:30:31 +01:00
MervinPraisonandCursor c16bd22c6c chore(ci): bump github-automation-template hub to v1.1.12 (Opus/Sonnet 5.5)
Co-authored-by: Cursor <[email protected]>
2026-10-11 22:33:21 +01:00
MervinPraison 0237c6f4ce Release v0.2.45 2026-10-08 05:19:04 +00:00
aa8e7079f3 feat: add Keenable keyless web search tool (#105)
* feat: add Keenable keyless web search tool

Keenable is a web-search service (api.keenable.ai). Per AGENTS.md routing,
vendor search services belong in PraisonAI-Tools as standalone tool modules
(mirroring tavily_tool.py / brave_search_tool.py), not inline in the core
praisonaiagents web_search.py aggregator.

Keyless by default via the public endpoint (stdlib urllib, no install);
KEENABLE_API_KEY switches to the authenticated endpoint for higher limits.
Redirects are refused so X-API-Key is never resent to another host; results
are bounded (50 max, 1 MB body cap, title/URL/snippet length caps).

Auto-discovered by praisonai_tools.tools (no manual registration). 13 unit
tests cover endpoint selection, result mapping/bounds, and error handling.

Relates to MervinPraison/PraisonAI#5671

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

* test: cover Keenable redirect refusal and bound limits

Address Greptile P2: the protective limits had no focused coverage and
build_opener was fully mocked, so _NoRedirect, the result cap, the
oversized title/url/snippet caps, and the response read limit could be
removed with the suite staying green.

- Lift _NoRedirect to module level so the redirect refusal is importable
  and unit-testable (was a closure inside search()).
- _FakeResponse.read now honours the requested byte count, so the
  oversized-response test exercises the actual read cap.
- Add TestProtections: result-count cap in the request body, title and
  snippet truncation, oversized-URL skip, read-limit enforcement, and
  _NoRedirect dropping a 302 (no follow-up request).
- Add the missing __init__ docstring.

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-10-08 06:18:03 +01:00
Mervin PraisonandCursor 4e6a092548 chore: bump github-automation-template hub_ref to v1.1.11 (#103)
Picks up the 403 label fix in the hub callable workflows.

Co-authored-by: Cursor <[email protected]>
2026-09-30 08:38:13 +01:00
MervinPraison 363eaeafb1 Release v0.2.44 2026-09-28 03:39:50 +00:00
praisonai-triage-agent[bot] a9f01306da Merge pull request #95 from MervinPraison/claude/issue-37-20260902-1212
feat: add StripeTool (read paths + guarded payment link create)
2026-09-22 18:07:00 +00:00
MervinPraisonandClaude Opus 4.8 68f161f136 fix: drop redundant mode key in list_payment_links (banner adds it)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 13:01:03 +00:00
MervinPraison 6ac9056331 fix: align stripe extra with requests, map catalogue extra, harden HTTP status
- pyproject: [stripe] extra installs requests (the actual runtime dep) instead
  of the unused stripe SDK, matching sibling requests-based tools (swarmscore)
- catalogue: map stripe_tool -> ('stripe',) in _MODULE_EXTRAS so discovery
  reports the correct install extra
- stripe_tool: reject non-2xx responses lacking Stripe's error object instead
  of treating decoded JSON as success
- tests: give mock responses a real status_code; add non-2xx failure test
2026-09-07 12:57:49 +00:00
MervinPraisonandMervin Praison 361b1e70b9 feat: add StripeTool (read paths + guarded payment link create) (fixes #37)
Adds a narrow, audited StripeTool following existing tool patterns
(lazy optional requests import, clear errors). Covers Phase 1 reads
(get_customer, get_payment_intent, list_payment_links) and a Phase 2
guarded write (create_payment_link) requiring a correlation_id and
supporting an optional Idempotency-Key. Includes test/live key prefix
detection with a mode banner, audit-only logging (no payloads), the
[stripe] pyproject extra, .env.example, and unit tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 12:14:56 +00:00
17 changed files with 1635 additions and 19 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# PraisonAI-Tools environment variables (examples)
#
# Copy to `.env` and fill in the values for the tools you use.
# Never commit real secrets.
# ── Stripe (StripeTool) ─────────────────────────────────────────────
# Stripe secret key. Prefer least-privilege *restricted* keys (rk_...).
# Use test keys (sk_test_/rk_test_) for development and live keys
# (sk_live_/rk_live_) only in production. STRIPE_API_KEY is accepted as a
# fallback if STRIPE_SECRET_KEY is unset.
STRIPE_SECRET_KEY=sk_test_your_key_here
# STRIPE_API_KEY=sk_test_your_key_here
+1 -1
View File
@@ -3,7 +3,7 @@
"stack": "claude",
"profile": "tools",
"mode": "hub",
"hubRef": "v1.1.10",
"hubRef": "v1.1.15",
"templateVersion": "1.1.6",
"installedAt": "2026-09-07T12:54:25Z"
}
+3 -3
View File
@@ -10,7 +10,7 @@ on:
pull_request_target:
types: [opened, synchronize]
schedule:
- cron: '30 3,9,15,21 * * *'
- cron: '30 * * * *'
workflow_dispatch:
env:
@@ -27,7 +27,7 @@ jobs:
permissions:
issues: write
pull-requests: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.20
+2 -2
View File
@@ -16,8 +16,8 @@ permissions:
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || '' }}
+2 -2
View File
@@ -25,8 +25,8 @@ permissions:
jobs:
detect-and-trigger:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || '' }}
+5 -4
View File
@@ -43,9 +43,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
with:
hub_ref: v1.1.10
hub_ref: v1.1.20
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
@@ -87,9 +87,10 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
statuses: write # the assess job records security/outsider-review
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.20
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
with:
hub_ref: v1.1.10
hub_ref: v1.1.20
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
+2 -2
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
sync:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.20
+1
View File
@@ -55,6 +55,7 @@ _MODULE_EXTRAS = {
"langextract_tool": ("langextract",),
"swarmscore_tool": ("swarmscore",),
"composio_tool": ("composio",),
"stripe_tool": ("stripe",),
}
@@ -0,0 +1,175 @@
"""Keenable Search Tool for PraisonAI Agents.
Keyless (or keyed) web search using Keenable.
Keenable works with no API key and no extra install: it calls the keyless
public endpoint with the Python standard library (``urllib``). Setting
``KEENABLE_API_KEY`` switches to the authenticated endpoint for higher limits.
Usage:
from praisonai_tools import KeenableSearchTool
keenable = KeenableSearchTool()
results = keenable.search("AI news 2024")
Environment Variables:
KEENABLE_API_KEY: Optional. When set, the authenticated endpoint is used.
"""
import json
import logging
import os
from typing import Any, Dict, List, Optional, Union
from urllib.request import HTTPRedirectHandler
from praisonai_tools.tools.base import BaseTool
logger = logging.getLogger(__name__)
KEENABLE_SEARCH_URL = "https://api.keenable.ai/v1/search"
KEENABLE_PUBLIC_SEARCH_URL = "https://api.keenable.ai/v1/search/public"
KEENABLE_APP_TITLE = "PraisonAI"
KEENABLE_SEARCH_TIMEOUT_SECONDS = 30
KEENABLE_MAX_RESPONSE_BYTES = 1_000_000
KEENABLE_MAX_RESULTS = 50
KEENABLE_MAX_TITLE_CHARS = 256
KEENABLE_MAX_URL_CHARS = 2048
KEENABLE_MAX_SNIPPET_CHARS = 1200
def _truncate_text(value: Any, limit: int) -> str:
"""Trim whitespace and cap ``value`` to ``limit`` characters."""
text = str(value or "").strip()
if len(text) > limit:
return text[:limit - 1] + "\u2026"
return text
class _NoRedirect(HTTPRedirectHandler):
"""Redirect handler that refuses to follow any redirect.
Keenable never redirects. Following a 3xx would resend ``X-API-Key`` to
whatever host it points at, so the redirect is dropped (urllib then raises
the 3xx as an error, surfaced to the caller) and no second request is sent.
"""
def redirect_request(self, *args, **kwargs):
"""Return ``None`` so urllib does not issue a follow-up request."""
return None
class KeenableSearchTool(BaseTool):
"""Tool for keyless (or keyed) web search using Keenable."""
name = "keenable_search"
description = "Search the web using Keenable (keyless; an API key raises limits)."
def __init__(self, api_key: Optional[str] = None):
"""Create the tool, resolving the API key from the argument or env.
Args:
api_key: Optional Keenable API key. Falls back to the
``KEENABLE_API_KEY`` environment variable; keyless if unset.
"""
self.api_key = (api_key or os.getenv("KEENABLE_API_KEY") or "").strip()
super().__init__()
def run(
self,
action: str = "search",
query: Optional[str] = None,
max_results: int = 5,
**kwargs,
) -> Union[str, Dict[str, Any], List[Dict[str, Any]]]:
"""Dispatch a Keenable action; only ``search`` is supported."""
action = action.lower().replace("-", "_")
if action == "search":
return self.search(query=query, max_results=max_results)
return {"error": f"Unknown action: {action}"}
def search(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
"""Search the web with Keenable.
Args:
query: Search query string.
max_results: Maximum results to return (capped at 50).
Returns:
List of dicts with ``title``, ``url``, ``snippet`` and ``provider``,
or a single-item list with an ``error`` key on failure.
"""
if not query:
return [{"error": "query is required"}]
result_limit = max(0, min(int(max_results), KEENABLE_MAX_RESULTS))
if result_limit == 0:
return []
from urllib.error import URLError
from urllib.request import Request, build_opener
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "praisonai-tools",
# Required on keyless calls; names the app, carries no user identifier.
"X-Keenable-Title": KEENABLE_APP_TITLE,
}
if self.api_key:
headers["X-API-Key"] = self.api_key
body = json.dumps({
"query": query,
"max_results": result_limit,
"snippet_max_length": KEENABLE_MAX_SNIPPET_CHARS,
}).encode("utf-8")
request = Request(
KEENABLE_SEARCH_URL if self.api_key else KEENABLE_PUBLIC_SEARCH_URL,
data=body,
headers=headers,
method="POST",
)
try:
opener = build_opener(_NoRedirect)
with opener.open(request, timeout=KEENABLE_SEARCH_TIMEOUT_SECONDS) as response:
raw = response.read(KEENABLE_MAX_RESPONSE_BYTES + 1)
except URLError as exc:
logger.error(f"Keenable search error: {exc}")
return [{"error": str(exc)}]
except Exception as exc:
logger.error(f"Keenable search error: {exc}")
return [{"error": str(exc)}]
if len(raw) > KEENABLE_MAX_RESPONSE_BYTES:
return [{"error": "Keenable returned an oversized response"}]
try:
payload = json.loads(raw)
except ValueError as exc:
return [{"error": f"Keenable returned invalid JSON: {exc}"}]
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
return [{"error": "Keenable returned an invalid response shape"}]
results = []
for result in payload["results"]:
if not isinstance(result, dict):
continue
url = result.get("url")
if not isinstance(url, str) or not url or len(url) > KEENABLE_MAX_URL_CHARS:
continue
# The page text is in `snippet`; `description` is usually empty.
snippet = " ".join(str(result.get("snippet") or result.get("description") or "").split())
results.append({
"title": _truncate_text(result.get("title", ""), KEENABLE_MAX_TITLE_CHARS),
"url": url,
"snippet": _truncate_text(snippet, KEENABLE_MAX_SNIPPET_CHARS),
"provider": "keenable",
})
if len(results) >= result_limit:
break
return results
def keenable_search(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
"""Search the web with Keenable (keyless; KEENABLE_API_KEY raises limits)."""
return KeenableSearchTool().search(query=query, max_results=max_results)
+338
View File
@@ -0,0 +1,338 @@
"""Stripe Tool for PraisonAI Agents.
Narrow, audited Stripe operations — retrieve customers/payment intents,
list payment links, and create payment links with required correlation
metadata and optional idempotency keys.
Design constraint: prefer narrow, audited operations over a "god tool"
that dumps the entire Stripe surface. One verb per action.
Usage:
from praisonai_tools import StripeTool
stripe = StripeTool() # reads STRIPE_SECRET_KEY (or STRIPE_API_KEY)
customer = stripe.run(action="get_customer", customer_id="cus_123")
link = stripe.run(
action="create_payment_link",
price_id="price_123",
quantity=1,
correlation_id="order-42",
)
Environment Variables:
STRIPE_SECRET_KEY: Stripe secret key. Restricted keys are recommended.
Accepts test (``sk_test_``/``rk_test_``) or live
(``sk_live_``/``rk_live_``) keys. STRIPE_API_KEY is also accepted
as a fallback.
Security notes:
* Secrets are read from the environment only and never logged.
* Responses include a ``mode`` banner (``test``/``live``) derived from
the key prefix so live operations are visible to callers.
* Structured logs record only the operation, mode, idempotency key, and
the Stripe object id — never full request/response payloads.
"""
import os
import logging
from typing import Any, Dict, List, Optional, Union
from praisonai_tools.tools.base import BaseTool
logger = logging.getLogger(__name__)
API_BASE = "https://api.stripe.com/v1"
_TEST_PREFIXES = ("sk_test_", "rk_test_")
_LIVE_PREFIXES = ("sk_live_", "rk_live_")
class StripeTool(BaseTool):
"""Tool for narrow, audited Stripe operations."""
name = "stripe"
description = (
"Retrieve Stripe customers and payment intents, list payment links, "
"and create payment links with required correlation metadata."
)
def __init__(self, api_key: Optional[str] = None):
self.api_key = (
api_key
or os.getenv("STRIPE_SECRET_KEY")
or os.getenv("STRIPE_API_KEY")
)
super().__init__()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _mode(self) -> str:
"""Return ``test``, ``live``, or ``unknown`` from the key prefix."""
key = self.api_key or ""
if key.startswith(_LIVE_PREFIXES):
return "live"
if key.startswith(_TEST_PREFIXES):
return "test"
return "unknown"
def _require_key(self) -> Optional[Dict[str, str]]:
if not self.api_key:
return {"error": "STRIPE_SECRET_KEY (or STRIPE_API_KEY) is required"}
return None
@staticmethod
def _import_requests():
try:
import requests
return requests
except ImportError:
return None
def _request(
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
idempotency_key: Optional[str] = None,
) -> Dict[str, Any]:
"""Make a request to the Stripe REST API.
Centralises key validation, the optional ``requests`` import, the
HTTP call, auditable logging, and error handling.
"""
err = self._require_key()
if err:
return err
requests = self._import_requests()
if requests is None:
return {"error": "requests package is not installed"}
headers = {"Authorization": f"Bearer {self.api_key}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
try:
resp = requests.request(
method,
f"{API_BASE}/{endpoint}",
headers=headers,
data=data,
timeout=30,
)
body = resp.json()
except requests.exceptions.RequestException as e:
logger.error("Stripe request error: %s", e)
return {"error": f"API request failed: {e}"}
except ValueError as e:
logger.error("Stripe JSON decode error: %s", e)
return {"error": "Failed to decode API response"}
if isinstance(body, dict) and "error" in body:
stripe_err = body["error"]
message = stripe_err.get("message", "Stripe API error")
logger.error(
"Stripe API error: op=%s mode=%s type=%s",
endpoint, self._mode(), stripe_err.get("type"),
)
return {"error": message}
# Defensive: a non-2xx status without Stripe's usual ``error`` object
# must not be treated as a successful payload.
if resp.status_code >= 400:
logger.error(
"Stripe HTTP error: op=%s mode=%s status=%s",
endpoint, self._mode(), resp.status_code,
)
return {"error": f"Stripe API returned HTTP {resp.status_code}"}
obj_id = body.get("id") if isinstance(body, dict) else None
logger.info(
"Stripe op=%s mode=%s idempotency_key=%s object_id=%s",
endpoint, self._mode(), idempotency_key, obj_id,
)
return body
def _banner(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Attach a live/test mode banner to a successful response."""
if isinstance(payload, dict) and "error" not in payload:
payload = {"mode": self._mode(), **payload}
return payload
# ------------------------------------------------------------------
# Dispatcher
# ------------------------------------------------------------------
def run(
self,
action: str = "get_customer",
customer_id: Optional[str] = None,
payment_intent_id: Optional[str] = None,
price_id: Optional[str] = None,
quantity: int = 1,
correlation_id: Optional[str] = None,
idempotency_key: Optional[str] = None,
limit: int = 10,
**kwargs,
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
"""Dispatch to the appropriate Stripe action.
Actions:
get_customer — Retrieve a customer by id (read).
get_payment_intent — Retrieve a payment intent by id (read).
list_payment_links — List payment links (read).
create_payment_link — Create a payment link (write, guarded).
"""
action = action.lower().replace("-", "_")
if action == "get_customer":
return self.get_customer(customer_id=customer_id)
elif action == "get_payment_intent":
return self.get_payment_intent(payment_intent_id=payment_intent_id)
elif action == "list_payment_links":
return self.list_payment_links(limit=limit)
elif action == "create_payment_link":
return self.create_payment_link(
price_id=price_id,
quantity=quantity,
correlation_id=correlation_id,
idempotency_key=idempotency_key,
)
else:
return {"error": f"Unknown action: {action}"}
# ------------------------------------------------------------------
# Phase 1 — Read-mostly safety
# ------------------------------------------------------------------
def get_customer(self, customer_id: Optional[str] = None) -> Dict[str, Any]:
"""Retrieve a customer by id.
Args:
customer_id: The Stripe customer id (``cus_...``).
"""
if not customer_id:
return {"error": "customer_id is required"}
result = self._request("get", f"customers/{customer_id}")
if "error" in result:
return result
return self._banner({
"id": result.get("id"),
"email": result.get("email"),
"name": result.get("name"),
"created": result.get("created"),
"currency": result.get("currency"),
"delinquent": result.get("delinquent"),
})
def get_payment_intent(
self, payment_intent_id: Optional[str] = None
) -> Dict[str, Any]:
"""Retrieve a payment intent by id.
Args:
payment_intent_id: The Stripe payment intent id (``pi_...``).
"""
if not payment_intent_id:
return {"error": "payment_intent_id is required"}
result = self._request("get", f"payment_intents/{payment_intent_id}")
if "error" in result:
return result
return self._banner({
"id": result.get("id"),
"amount": result.get("amount"),
"currency": result.get("currency"),
"status": result.get("status"),
"customer": result.get("customer"),
"created": result.get("created"),
})
def list_payment_links(self, limit: int = 10) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
"""List payment links.
Args:
limit: Maximum number of payment links to return (1–100).
"""
limit = max(1, min(int(limit), 100))
result = self._request("get", f"payment_links?limit={limit}")
if "error" in result:
return result
links = [
{
"id": link.get("id"),
"url": link.get("url"),
"active": link.get("active"),
"metadata": link.get("metadata", {}),
}
for link in result.get("data", [])
]
return self._banner({"payment_links": links})
# ------------------------------------------------------------------
# Phase 2 — Mutations with guardrails
# ------------------------------------------------------------------
def create_payment_link(
self,
price_id: Optional[str] = None,
quantity: int = 1,
correlation_id: Optional[str] = None,
idempotency_key: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a payment link.
A ``correlation_id`` is required and stored as payment-link metadata
so every created link is traceable back to the request that made it.
An optional ``idempotency_key`` is surfaced to make retries safe.
Args:
price_id: An existing Stripe Price id (``price_...``).
quantity: Quantity for the line item (>= 1).
correlation_id: Required correlation id stored as metadata.
idempotency_key: Optional Stripe ``Idempotency-Key`` header value.
"""
if not price_id:
return {"error": "price_id is required"}
if not correlation_id:
return {"error": "correlation_id is required (stored as metadata)"}
try:
quantity = int(quantity)
except (TypeError, ValueError):
return {"error": "quantity must be an integer"}
if quantity < 1:
return {"error": "quantity must be >= 1"}
data = {
"line_items[0][price]": price_id,
"line_items[0][quantity]": quantity,
"metadata[correlation_id]": correlation_id,
}
result = self._request(
"post",
"payment_links",
data=data,
idempotency_key=idempotency_key,
)
if "error" in result:
return result
return self._banner({
"id": result.get("id"),
"url": result.get("url"),
"active": result.get("active"),
"metadata": result.get("metadata", {}),
})
def get_stripe_customer(
customer_id: str, api_key: Optional[str] = None
) -> Dict[str, Any]:
"""Retrieve a Stripe customer by id."""
return StripeTool(api_key=api_key).get_customer(customer_id=customer_id)
+484
View File
@@ -0,0 +1,484 @@
"""You.com search and content tools for PraisonAI Agents.
Migrated from ``praisonaiagents.tools.youdotcom_tools`` (MervinPraison/PraisonAI#6168).
You.com provides AI-powered web search, news search, content extraction, and image search.
This module requires the YDC_API_KEY environment variable to be set.
Usage:
from praisonai_tools.tools.youdotcom_tool import ydc_search, ydc_news, ydc_contents
# Search the web (unified web + news)
results = ydc_search("AI news 2024")
# Get live news
news = ydc_news("technology trends")
# Extract content from URLs
content = ydc_contents(["https://example.com"])
# Or use the class directly
from praisonai_tools.tools.youdotcom_tool import YouTools
you = YouTools()
results = you.search("AI news")
"""
from typing import List, Dict, Any, Optional, Union
import logging
import os
from importlib import util
class YouTools:
"""Comprehensive tools for web search and content using You.com API.
You.com provides AI-powered search capabilities optimized for LLM applications.
Features:
- Unified web and news search with LLM-ready snippets
- Live news search from authoritative sources
- Content extraction from URLs (HTML/Markdown)
- Image search
- Advanced search operators support
- Geographic and language targeting
- Freshness controls
Requires:
- youdotcom package: pip install youdotcom
- YDC_API_KEY environment variable
"""
def __init__(self, api_key: Optional[str] = None):
"""Initialize YouTools.
Args:
api_key: Optional API key. If not provided, uses YDC_API_KEY env var.
"""
self._api_key = api_key
self._client = None
def _get_api_key(self) -> Optional[str]:
"""Get API key from instance or environment."""
return self._api_key or os.environ.get("YDC_API_KEY")
def _get_client(self):
"""Get or create You.com client."""
if self._client is None:
if util.find_spec("youdotcom") is None:
return None, "youdotcom package is not installed. Please install it using: pip install youdotcom"
api_key = self._get_api_key()
if not api_key:
return None, "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
from youdotcom import You
self._client = You(api_key_auth=api_key)
return self._client, None
def search(
self,
query: str,
count: int = 10,
freshness: Optional[str] = None,
country: Optional[str] = None,
language: Optional[str] = None,
offset: int = 0,
safesearch: str = "moderate",
livecrawl: Optional[str] = None,
livecrawl_formats: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using You.com's unified search API.
Returns both web and news results in a single call with LLM-ready snippets.
Args:
query: Search query (supports operators like site:, filetype:, +term, -term)
count: Max results per section (default 10, max 100)
freshness: Filter by recency - "day", "week", "month", "year" or "YYYY-MM-DDtoYYYY-MM-DD"
country: ISO country code (e.g., "US", "GB", "FR")
language: BCP 47 language code (e.g., "EN", "JP", "DE")
offset: Pagination offset (0-9), results = offset * count
safesearch: Content filter - "off", "moderate", "strict"
livecrawl: Fetch full content - "web", "news", or "all"
livecrawl_formats: Content format - "html" or "markdown"
Returns:
Dict containing:
- results: {web: [...], news: [...]}
- metadata: {query, search_uuid, latency}
Search Operators:
- site:domain.com - Search within specific domain
- filetype:pdf - Filter by file type
- +term - Must include term
- -term - Exclude term
- AND, OR, NOT - Boolean operators
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {"query": query, "count": count, "offset": offset}
if freshness:
kwargs["freshness"] = freshness
if country:
kwargs["country"] = country
if language:
kwargs["language"] = language
if safesearch:
kwargs["safesearch"] = safesearch
if livecrawl:
kwargs["livecrawl"] = livecrawl
if livecrawl_formats:
kwargs["livecrawl_formats"] = livecrawl_formats
response = client.search.unified(**kwargs)
# Convert response to dict if it's a model object
if hasattr(response, 'model_dump'):
return response.model_dump()
elif hasattr(response, 'to_dict'):
return response.to_dict()
elif hasattr(response, '__dict__'):
return self._convert_response(response)
return response
except Exception as e:
error_msg = f"You.com search error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def _convert_response(self, response) -> Dict[str, Any]:
"""Convert SDK response object to dictionary."""
result = {}
if hasattr(response, 'results'):
results = response.results
result['results'] = {}
if hasattr(results, 'web') and results.web:
result['results']['web'] = [
{
'url': getattr(r, 'url', ''),
'title': getattr(r, 'title', ''),
'description': getattr(r, 'description', ''),
'snippets': getattr(r, 'snippets', []),
'thumbnail_url': getattr(r, 'thumbnail_url', None),
'page_age': getattr(r, 'page_age', None),
'favicon_url': getattr(r, 'favicon_url', None),
'authors': getattr(r, 'authors', []),
}
for r in results.web
]
if hasattr(results, 'news') and results.news:
result['results']['news'] = [
{
'url': getattr(r, 'url', ''),
'title': getattr(r, 'title', ''),
'description': getattr(r, 'description', ''),
'thumbnail_url': getattr(r, 'thumbnail_url', None),
'page_age': getattr(r, 'page_age', None),
}
for r in results.news
]
if hasattr(response, 'metadata'):
meta = response.metadata
result['metadata'] = {
'query': getattr(meta, 'query', ''),
'search_uuid': getattr(meta, 'search_uuid', ''),
'latency': getattr(meta, 'latency', 0),
}
return result
def get_contents(
self,
urls: Union[str, List[str]],
format: str = "markdown",
) -> Dict[str, Any]:
"""Fetch content from URLs using You.com's content extraction API.
Args:
urls: Single URL or list of URLs to fetch content from
format: Content format - "html" or "markdown" (default: markdown)
Returns:
Dict containing array of results with:
- url: The fetched URL
- title: Page title
- html: HTML content (if format="html")
- markdown: Markdown content (if format="markdown")
- metadata: {site_name, favicon_url}
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
if isinstance(urls, str):
urls = [urls]
# Use the contents endpoint
response = client.search.contents(urls=urls, format=format)
# Convert response
if hasattr(response, 'model_dump'):
return {"results": response.model_dump()}
elif isinstance(response, list):
return {"results": [
{
'url': getattr(r, 'url', ''),
'title': getattr(r, 'title', ''),
'html': getattr(r, 'html', None),
'markdown': getattr(r, 'markdown', None),
'metadata': {
'site_name': getattr(getattr(r, 'metadata', None), 'site_name', None) if hasattr(r, 'metadata') else None,
'favicon_url': getattr(getattr(r, 'metadata', None), 'favicon_url', None) if hasattr(r, 'metadata') else None,
}
}
for r in response
]}
return {"results": response}
except Exception as e:
error_msg = f"You.com contents error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def live_news(
self,
query: str,
count: Optional[int] = None,
) -> Dict[str, Any]:
"""Search for live news using You.com's news API.
Note: This API may require early access. Contact [email protected] for access.
Args:
query: News search query
count: Maximum number of news results to return
Returns:
Dict containing:
- news: {query, results: [...], metadata}
- Each result has: title, description, url, page_age, source_name, thumbnail
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {"q": query}
if count:
kwargs["count"] = count
# Try to access live news endpoint
if hasattr(client, 'livenews'):
response = client.livenews.get(**kwargs)
else:
# Fallback: use unified search with news focus
return self.search(query=query, count=count or 10)
if hasattr(response, 'model_dump'):
return response.model_dump()
elif hasattr(response, 'news'):
news = response.news
return {
'news': {
'query': getattr(getattr(news, 'query', None), 'original', query) if hasattr(news, 'query') else query,
'results': [
{
'title': getattr(r, 'title', ''),
'description': getattr(r, 'description', ''),
'url': getattr(r, 'url', ''),
'page_age': getattr(r, 'page_age', None),
'age': getattr(r, 'age', None),
'source_name': getattr(r, 'source_name', None),
'thumbnail': getattr(getattr(r, 'thumbnail', None), 'src', None) if hasattr(r, 'thumbnail') else None,
}
for r in getattr(news, 'results', [])
],
'metadata': {
'request_uuid': getattr(getattr(news, 'metadata', None), 'request_uuid', None) if hasattr(news, 'metadata') else None,
}
}
}
return response
except Exception as e:
error_msg = f"You.com live news error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def images(
self,
query: str,
) -> Dict[str, Any]:
"""Search for images using You.com's image search API.
Note: This API may require early access. Contact [email protected] for access.
Args:
query: Image search query
Returns:
Dict containing:
- images: {results: [...]}
- metadata: {query, search_uuid}
- Each result has: title, page_url, image_url
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
if hasattr(client, 'images'):
response = client.images.get(q=query)
else:
return {"error": "Image search not available in this SDK version"}
if hasattr(response, 'model_dump'):
return response.model_dump()
elif hasattr(response, 'images'):
images = response.images
return {
'images': {
'results': [
{
'title': getattr(r, 'title', ''),
'page_url': getattr(r, 'page_url', ''),
'image_url': getattr(r, 'image_url', ''),
}
for r in getattr(images, 'results', [])
]
},
'metadata': {
'query': getattr(getattr(response, 'metadata', None), 'query', query) if hasattr(response, 'metadata') else query,
'search_uuid': getattr(getattr(response, 'metadata', None), 'search_uuid', None) if hasattr(response, 'metadata') else None,
}
}
return response
except Exception as e:
error_msg = f"You.com images error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
if self._client is not None:
# Close client if it has a close method
if hasattr(self._client, 'close'):
self._client.close()
elif hasattr(self._client, '__exit__'):
self._client.__exit__(exc_type, exc_val, exc_tb)
return False
# Standalone functions for direct import
def ydc_search(
query: str,
count: int = 10,
freshness: Optional[str] = None,
country: Optional[str] = None,
language: Optional[str] = None,
offset: int = 0,
safesearch: str = "moderate",
livecrawl: Optional[str] = None,
livecrawl_formats: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using You.com's unified search API.
This is a convenience function that creates a YouTools instance.
For repeated calls, consider using YouTools class directly.
Args:
query: Search query (supports operators like site:, filetype:, +term, -term)
count: Max results per section (default 10, max 100)
freshness: Filter by recency - "day", "week", "month", "year"
country: ISO country code (e.g., "US", "GB")
language: BCP 47 language code (e.g., "EN", "JP")
offset: Pagination offset (0-9)
safesearch: Content filter - "off", "moderate", "strict"
livecrawl: Fetch full content - "web", "news", or "all"
livecrawl_formats: Content format - "html" or "markdown"
Returns:
Dict containing search results with web and news sections
"""
tools = YouTools()
return tools.search(
query=query,
count=count,
freshness=freshness,
country=country,
language=language,
offset=offset,
safesearch=safesearch,
livecrawl=livecrawl,
livecrawl_formats=livecrawl_formats,
)
def ydc_contents(
urls: Union[str, List[str]],
format: str = "markdown",
) -> Dict[str, Any]:
"""Fetch content from URLs using You.com's content extraction API.
Args:
urls: Single URL or list of URLs to fetch content from
format: Content format - "html" or "markdown"
Returns:
Dict containing extracted content for each URL
"""
tools = YouTools()
return tools.get_contents(urls=urls, format=format)
def ydc_news(
query: str,
count: Optional[int] = None,
) -> Dict[str, Any]:
"""Search for live news using You.com's news API.
Args:
query: News search query
count: Maximum number of results
Returns:
Dict containing news results
"""
tools = YouTools()
return tools.live_news(query=query, count=count)
def ydc_images(
query: str,
) -> Dict[str, Any]:
"""Search for images using You.com's image search API.
Args:
query: Image search query
Returns:
Dict containing image results
"""
tools = YouTools()
return tools.images(query=query)
# Alias for simple usage
ydc = ydc_search
__all__ = ["YouTools", "ydc_search", "ydc_contents", "ydc_news", "ydc_images", "ydc"]
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-tools"
version = "0.2.43"
version = "0.2.46"
description = "Extended tools for PraisonAI Agents"
authors = [
{name = "Mervin Praison"}
@@ -85,6 +85,9 @@ meeting = [
"openai>=1.12.0",
"chromadb>=0.4.0",
]
stripe = [
"requests>=2.28.0",
]
[project.urls]
Homepage = "https://docs.praison.ai"
@@ -0,0 +1,273 @@
"""Unit tests for KeenableSearchTool."""
import json
import os
from unittest.mock import MagicMock, patch
from praisonai_tools.tools.keenable_search_tool import (
KEENABLE_MAX_RESULTS,
KEENABLE_MAX_SNIPPET_CHARS,
KEENABLE_MAX_TITLE_CHARS,
KEENABLE_MAX_URL_CHARS,
KEENABLE_PUBLIC_SEARCH_URL,
KEENABLE_SEARCH_URL,
KeenableSearchTool,
_NoRedirect,
keenable_search,
)
class _FakeResponse:
"""Minimal stand-in for an ``http.client.HTTPResponse`` context manager."""
def __init__(self, raw: bytes):
self._raw = raw
def read(self, amt=None):
"""Return at most ``amt`` bytes, honouring the caller's read cap."""
if amt is None:
return self._raw
return self._raw[:amt]
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def _payload(results):
return json.dumps({"results": results}).encode("utf-8")
# ── Import + schema (CI smoke) ──────────────────────────────────────
class TestImportAndSchema:
def test_import_via_package(self):
from praisonai_tools import KeenableSearchTool as PkgTool
assert PkgTool is KeenableSearchTool
def test_schema_generation(self):
tool = KeenableSearchTool()
schema = tool.get_schema()
assert schema["function"]["name"] == "keenable_search"
assert "description" in schema["function"]
assert "action" in schema["function"]["parameters"]["properties"]
# ── Endpoint selection ──────────────────────────────────────────────
class TestEndpointSelection:
def test_public_endpoint_without_key(self):
with patch.dict(os.environ, {}, clear=True):
tool = KeenableSearchTool()
assert tool.api_key == ""
captured = {}
def fake_open(request, timeout=None):
captured["url"] = request.full_url
captured["headers"] = dict(request.header_items())
return _FakeResponse(_payload([]))
opener = MagicMock()
opener.open.side_effect = fake_open
with patch("urllib.request.build_opener", return_value=opener):
tool.search("python", max_results=3)
assert captured["url"] == KEENABLE_PUBLIC_SEARCH_URL
header_names = {k.lower() for k in captured["headers"]}
assert "x-api-key" not in header_names
assert "X-keenable-title" in captured["headers"] or "X-Keenable-Title" in captured["headers"]
def test_authenticated_endpoint_with_key(self):
tool = KeenableSearchTool(api_key="secret-key")
captured = {}
def fake_open(request, timeout=None):
captured["url"] = request.full_url
captured["headers"] = dict(request.header_items())
return _FakeResponse(_payload([]))
opener = MagicMock()
opener.open.side_effect = fake_open
with patch("urllib.request.build_opener", return_value=opener):
tool.search("python", max_results=3)
assert captured["url"] == KEENABLE_SEARCH_URL
# urllib title-cases header keys.
assert captured["headers"].get("X-api-key") == "secret-key"
# ── Result mapping + bounds ─────────────────────────────────────────
class TestResultMapping:
def _run(self, tool, results):
opener = MagicMock()
opener.open.return_value = _FakeResponse(_payload(results))
with patch("urllib.request.build_opener", return_value=opener):
return tool.search("q", max_results=10)
def test_maps_fields(self):
out = self._run(
KeenableSearchTool(),
[{"title": "T", "url": "https://example.com", "snippet": "hello world"}],
)
assert out == [{
"title": "T",
"url": "https://example.com",
"snippet": "hello world",
"provider": "keenable",
}]
def test_skips_entries_without_url(self):
out = self._run(
KeenableSearchTool(),
[{"title": "no url"}, {"title": "ok", "url": "https://a.com"}],
)
assert len(out) == 1
assert out[0]["url"] == "https://a.com"
def test_respects_max_results(self):
tool = KeenableSearchTool()
many = [{"url": f"https://a.com/{i}"} for i in range(10)]
opener = MagicMock()
opener.open.return_value = _FakeResponse(_payload(many))
with patch("urllib.request.build_opener", return_value=opener):
out = tool.search("q", max_results=3)
assert len(out) == 3
def test_zero_max_results_short_circuits(self):
out = KeenableSearchTool().search("q", max_results=0)
assert out == []
def test_empty_query_errors(self):
out = KeenableSearchTool().search("", max_results=5)
assert out == [{"error": "query is required"}]
# ── Error handling ──────────────────────────────────────────────────
class TestErrorHandling:
def test_invalid_shape_errors(self):
tool = KeenableSearchTool()
opener = MagicMock()
opener.open.return_value = _FakeResponse(b'{"not_results": 1}')
with patch("urllib.request.build_opener", return_value=opener):
out = tool.search("q")
assert out and "error" in out[0]
def test_oversized_response_errors(self):
tool = KeenableSearchTool()
oversized = b"x" * (1_000_001 + 1)
opener = MagicMock()
opener.open.return_value = _FakeResponse(oversized)
with patch("urllib.request.build_opener", return_value=opener):
out = tool.search("q")
assert out == [{"error": "Keenable returned an oversized response"}]
def test_http_error_is_captured(self):
tool = KeenableSearchTool()
opener = MagicMock()
opener.open.side_effect = RuntimeError("boom")
with patch("urllib.request.build_opener", return_value=opener):
out = tool.search("q")
assert out == [{"error": "boom"}]
# ── Protective bounds + redirect refusal ────────────────────────────
class TestProtections:
def _capture_body(self, tool, requested):
"""Run a search and return the decoded JSON request body sent."""
captured = {}
def fake_open(request, timeout=None):
captured["body"] = json.loads(request.data.decode("utf-8"))
return _FakeResponse(_payload([]))
opener = MagicMock()
opener.open.side_effect = fake_open
with patch("urllib.request.build_opener", return_value=opener):
tool.search("q", max_results=requested)
return captured["body"]
def test_caps_requested_results_at_limit(self):
"""max_results above the hard cap is clamped before the request."""
body = self._capture_body(KeenableSearchTool(), requested=1000)
assert body["max_results"] == KEENABLE_MAX_RESULTS
def test_truncates_oversized_title(self):
"""Titles longer than the cap are truncated with an ellipsis."""
opener = MagicMock()
opener.open.return_value = _FakeResponse(
_payload([{"title": "T" * 5000, "url": "https://a.com"}])
)
with patch("urllib.request.build_opener", return_value=opener):
out = KeenableSearchTool().search("q")
assert len(out[0]["title"]) == KEENABLE_MAX_TITLE_CHARS
assert out[0]["title"].endswith("…")
def test_truncates_oversized_snippet(self):
"""Snippets longer than the cap are truncated with an ellipsis."""
opener = MagicMock()
opener.open.return_value = _FakeResponse(
_payload([{"snippet": "s " * 5000, "url": "https://a.com"}])
)
with patch("urllib.request.build_opener", return_value=opener):
out = KeenableSearchTool().search("q")
assert len(out[0]["snippet"]) == KEENABLE_MAX_SNIPPET_CHARS
assert out[0]["snippet"].endswith("…")
def test_skips_oversized_url(self):
"""Results whose URL exceeds the length cap are dropped."""
long_url = "https://a.com/" + "x" * KEENABLE_MAX_URL_CHARS
opener = MagicMock()
opener.open.return_value = _FakeResponse(
_payload([{"url": long_url}, {"url": "https://ok.com"}])
)
with patch("urllib.request.build_opener", return_value=opener):
out = KeenableSearchTool().search("q")
assert out == [{
"title": "",
"url": "https://ok.com",
"snippet": "",
"provider": "keenable",
}]
def test_read_is_capped_at_limit(self):
"""The body read is bounded, so an oversized stream still errors out."""
tool = KeenableSearchTool()
# Far larger than the cap; _FakeResponse.read honours the byte count,
# so this verifies the read limit rather than a pre-sized buffer.
opener = MagicMock()
opener.open.return_value = _FakeResponse(b"x" * 5_000_000)
with patch("urllib.request.build_opener", return_value=opener):
out = tool.search("q")
assert out == [{"error": "Keenable returned an oversized response"}]
def test_refuses_redirects(self):
"""The redirect handler drops 3xx responses so no follow-up is sent."""
handler = _NoRedirect()
# Returning None is urllib's signal to NOT build/send a second request.
assert handler.redirect_request(
MagicMock(), MagicMock(), 302, "Found", {}, "https://evil.example"
) is None
# ── Module-level convenience function ───────────────────────────────
class TestConvenienceFunction:
def test_keenable_search_function(self):
opener = MagicMock()
opener.open.return_value = _FakeResponse(_payload([{"url": "https://a.com"}]))
with patch("urllib.request.build_opener", return_value=opener):
out = keenable_search("q", max_results=2)
assert out[0]["provider"] == "keenable"
+248
View File
@@ -0,0 +1,248 @@
"""Unit tests for StripeTool."""
import os
from unittest.mock import MagicMock, patch
from praisonai_tools.tools.stripe_tool import StripeTool, get_stripe_customer
def _mock_response(payload, status_code=200):
resp = MagicMock()
resp.json.return_value = payload
resp.status_code = status_code
return resp
# ── Key handling / mode banner ──────────────────────────────────────
class TestKeyAndMode:
def test_env_secret_key_fallback(self):
with patch.dict(os.environ, {"STRIPE_SECRET_KEY": "sk_test_x"}, clear=True):
tool = StripeTool()
assert tool.api_key == "sk_test_x"
assert tool._mode() == "test"
def test_stripe_api_key_fallback(self):
with patch.dict(os.environ, {"STRIPE_API_KEY": "sk_live_x"}, clear=True):
tool = StripeTool()
assert tool.api_key == "sk_live_x"
assert tool._mode() == "live"
def test_restricted_key_prefixes(self):
assert StripeTool(api_key="rk_test_x")._mode() == "test"
assert StripeTool(api_key="rk_live_x")._mode() == "live"
def test_unknown_mode(self):
assert StripeTool(api_key="weird")._mode() == "unknown"
def test_missing_key_returns_error(self):
with patch.dict(os.environ, {}, clear=True):
tool = StripeTool()
assert tool.get_customer(customer_id="cus_1") == {
"error": "STRIPE_SECRET_KEY (or STRIPE_API_KEY) is required"
}
# ── get_customer ────────────────────────────────────────────────────
class TestGetCustomer:
def test_requires_customer_id(self):
tool = StripeTool(api_key="sk_test_x")
assert tool.get_customer(customer_id="") == {"error": "customer_id is required"}
def test_returns_normalised_customer_with_banner(self):
tool = StripeTool(api_key="sk_test_x")
payload = {
"id": "cus_1",
"email": "[email protected]",
"name": "Alice",
"created": 123,
"currency": "usd",
"delinquent": False,
}
with patch("requests.request", return_value=_mock_response(payload)) as req:
result = tool.get_customer(customer_id="cus_1")
args, kwargs = req.call_args
assert args[0] == "get"
assert args[1].endswith("/customers/cus_1")
assert kwargs["headers"]["Authorization"] == "Bearer sk_test_x"
assert result["mode"] == "test"
assert result["id"] == "cus_1"
assert result["email"] == "[email protected]"
def test_propagates_stripe_error(self):
tool = StripeTool(api_key="sk_test_x")
payload = {"error": {"message": "No such customer", "type": "invalid_request_error"}}
with patch("requests.request", return_value=_mock_response(payload)):
assert tool.get_customer(customer_id="cus_x") == {"error": "No such customer"}
def test_handles_request_exception(self):
import requests
tool = StripeTool(api_key="sk_test_x")
with patch(
"requests.request",
side_effect=requests.exceptions.RequestException("boom"),
):
result = tool.get_customer(customer_id="cus_1")
assert result["error"].startswith("API request failed")
def test_non_2xx_without_error_object_is_failure(self):
# A non-2xx response whose body lacks Stripe's usual ``error`` object
# must not be treated as a successful payload.
tool = StripeTool(api_key="sk_test_x")
with patch(
"requests.request",
return_value=_mock_response({"unexpected": "shape"}, status_code=502),
):
result = tool.get_customer(customer_id="cus_1")
assert result == {"error": "Stripe API returned HTTP 502"}
# ── get_payment_intent ──────────────────────────────────────────────
class TestGetPaymentIntent:
def test_requires_id(self):
tool = StripeTool(api_key="sk_test_x")
assert tool.get_payment_intent(payment_intent_id="") == {
"error": "payment_intent_id is required"
}
def test_returns_normalised_intent(self):
tool = StripeTool(api_key="sk_test_x")
payload = {
"id": "pi_1",
"amount": 500,
"currency": "usd",
"status": "succeeded",
"customer": "cus_1",
"created": 1,
}
with patch("requests.request", return_value=_mock_response(payload)):
result = tool.get_payment_intent(payment_intent_id="pi_1")
assert result["status"] == "succeeded"
assert result["amount"] == 500
assert result["mode"] == "test"
# ── list_payment_links ──────────────────────────────────────────────
class TestListPaymentLinks:
def test_returns_links(self):
tool = StripeTool(api_key="sk_test_x")
payload = {
"data": [
{"id": "plink_1", "url": "https://pay/1", "active": True, "metadata": {}},
{"id": "plink_2", "url": "https://pay/2", "active": False, "metadata": {}},
]
}
with patch("requests.request", return_value=_mock_response(payload)):
result = tool.list_payment_links(limit=2)
assert result["mode"] == "test"
assert len(result["payment_links"]) == 2
assert result["payment_links"][0]["id"] == "plink_1"
def test_clamps_limit(self):
tool = StripeTool(api_key="sk_test_x")
with patch("requests.request", return_value=_mock_response({"data": []})) as req:
tool.list_payment_links(limit=999)
assert "limit=100" in req.call_args[0][1]
def test_propagates_error(self):
tool = StripeTool(api_key="sk_test_x")
payload = {"error": {"message": "bad"}}
with patch("requests.request", return_value=_mock_response(payload)):
assert tool.list_payment_links() == {"error": "bad"}
# ── create_payment_link (guardrails) ────────────────────────────────
class TestCreatePaymentLink:
def test_requires_price_id(self):
tool = StripeTool(api_key="sk_test_x")
assert tool.create_payment_link(
price_id="", correlation_id="c1"
) == {"error": "price_id is required"}
def test_requires_correlation_id(self):
tool = StripeTool(api_key="sk_test_x")
assert tool.create_payment_link(price_id="price_1") == {
"error": "correlation_id is required (stored as metadata)"
}
def test_rejects_bad_quantity(self):
tool = StripeTool(api_key="sk_test_x")
assert tool.create_payment_link(
price_id="price_1", correlation_id="c1", quantity=0
) == {"error": "quantity must be >= 1"}
def test_success_sends_metadata_and_idempotency_key(self):
tool = StripeTool(api_key="sk_test_x")
payload = {
"id": "plink_1",
"url": "https://pay/1",
"active": True,
"metadata": {"correlation_id": "order-42"},
}
with patch("requests.request", return_value=_mock_response(payload)) as req:
result = tool.create_payment_link(
price_id="price_1",
quantity=2,
correlation_id="order-42",
idempotency_key="idem-1",
)
args, kwargs = req.call_args
assert args[0] == "post"
assert args[1].endswith("/payment_links")
assert kwargs["data"]["line_items[0][price]"] == "price_1"
assert kwargs["data"]["line_items[0][quantity]"] == 2
assert kwargs["data"]["metadata[correlation_id]"] == "order-42"
assert kwargs["headers"]["Idempotency-Key"] == "idem-1"
assert result["id"] == "plink_1"
assert result["mode"] == "test"
# ── run() dispatcher ────────────────────────────────────────────────
class TestRunDispatcher:
def test_unknown_action(self):
tool = StripeTool(api_key="sk_test_x")
assert tool.run(action="bogus") == {"error": "Unknown action: bogus"}
def test_routes_get_customer(self):
tool = StripeTool(api_key="sk_test_x")
with patch.object(tool, "get_customer", return_value={"ok": True}) as m:
tool.run(action="get_customer", customer_id="cus_1")
m.assert_called_once_with(customer_id="cus_1")
def test_routes_create_payment_link(self):
tool = StripeTool(api_key="sk_test_x")
with patch.object(tool, "create_payment_link", return_value={"ok": True}) as m:
tool.run(
action="create-payment-link",
price_id="price_1",
quantity=1,
correlation_id="c1",
idempotency_key="i1",
)
m.assert_called_once_with(
price_id="price_1", quantity=1, correlation_id="c1", idempotency_key="i1"
)
# ── Module-level helper ─────────────────────────────────────────────
class TestHelper:
def test_delegates_to_tool(self):
with patch.object(StripeTool, "get_customer", return_value={"id": "cus_1"}) as m:
assert get_stripe_customer("cus_1", api_key="sk_test_x") == {"id": "cus_1"}
m.assert_called_once_with(customer_id="cus_1")
+81
View File
@@ -0,0 +1,81 @@
"""Unit tests for You.com tools (no network, no API key required)."""
from unittest.mock import MagicMock, patch
from praisonai_tools.tools.youdotcom_tool import (
YouTools,
ydc,
ydc_search,
ydc_contents,
ydc_news,
ydc_images,
)
_FIND_SPEC = "praisonai_tools.tools.youdotcom_tool.util.find_spec"
def test_missing_api_key_returns_error(monkeypatch):
monkeypatch.delenv("YDC_API_KEY", raising=False)
with patch(_FIND_SPEC, return_value=object()):
for fn, arg in (
(ydc_search, "q"),
(ydc_contents, "https://example.com"),
(ydc_news, "q"),
(ydc_images, "q"),
):
result = fn(arg)
assert result == {
"error": "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
}
def test_explicit_api_key_used_without_env(monkeypatch):
monkeypatch.delenv("YDC_API_KEY", raising=False)
fake_module = MagicMock()
with patch(_FIND_SPEC, return_value=object()), patch.dict(
"sys.modules", {"youdotcom": fake_module}
):
client, error = YouTools(api_key="k")._get_client()
assert error is None
assert client is fake_module.You.return_value
fake_module.You.assert_called_once_with(api_key_auth="k")
def test_ydc_alias():
assert ydc is ydc_search
def test_search_uses_client_and_returns_dict():
response = MagicMock()
response.model_dump.return_value = {"results": {"web": [], "news": []}}
client = MagicMock()
client.search.unified.return_value = response
with patch.object(YouTools, "_get_client", return_value=(client, None)):
result = YouTools().search("ai", count=3, freshness="week")
assert result == {"results": {"web": [], "news": []}}
kwargs = client.search.unified.call_args.kwargs
assert kwargs["query"] == "ai"
assert kwargs["count"] == 3
assert kwargs["freshness"] == "week"
def test_search_exception_is_reported_as_error():
client = MagicMock()
client.search.unified.side_effect = RuntimeError("boom")
with patch.object(YouTools, "_get_client", return_value=(client, None)):
result = YouTools().search("ai")
assert "boom" in result["error"]
def test_discovered_via_package():
import praisonai_tools.tools as tools_pkg
assert tools_pkg.YouTools is YouTools
assert tools_pkg.ydc_search is ydc_search
assert tools_pkg.ydc is ydc_search