solvent-agent 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {solvent_agent-0.2.0/solvent_agent.egg-info → solvent_agent-0.2.2}/PKG-INFO +2 -2
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/README.md +1 -1
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/__init__.py +1 -1
- solvent_agent-0.2.2/solvent/access.py +128 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/agent.py +15 -4
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/chat.py +2 -1
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/cli.py +4 -3
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/dashboard.py +55 -40
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/export.py +44 -2
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/finance.py +2 -1
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/gateway.py +27 -9
- solvent_agent-0.2.2/solvent/money.py +36 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/server.py +223 -31
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/stages.py +49 -28
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/stripe_client.py +55 -4
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/treasury.py +39 -22
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/webhook_log.py +67 -11
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/worker.py +2 -1
- {solvent_agent-0.2.0 → solvent_agent-0.2.2/solvent_agent.egg-info}/PKG-INFO +2 -2
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/SOURCES.txt +5 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_dashboard_ops_xss.py +48 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_export.py +48 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_gateway.py +30 -0
- solvent_agent-0.2.2/tests/test_money.py +78 -0
- solvent_agent-0.2.2/tests/test_route_security.py +519 -0
- solvent_agent-0.2.2/tests/test_server_job_submission.py +152 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_stages.py +14 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_treasury.py +9 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_webhook_log.py +43 -4
- solvent_agent-0.2.2/tests/test_webhook_security.py +329 -0
- solvent_agent-0.2.0/tests/test_server_job_submission.py +0 -76
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/LICENSE +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/pyproject.toml +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/setup.cfg +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/__main__.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/_filelock.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/alerts.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/backlog.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/calibration.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/capacity.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/channels/__init__.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/channels/telegram.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/checkout.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/config.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/config_cmd.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/customers.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/dashboard_chat.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/delivery.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/doctor.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/event_hub.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/guardrail_cmd.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/guardrails.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/init.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/intake.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/job_cmd.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/jobs.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/logs.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/memory.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/nemotron.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/notifications.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/observability.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/onboarding.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/optimize.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/pairing.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/paths.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/pricing.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/products.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/py.typed +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/qr.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/quality.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/queue.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/quote_cmd.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/rate_limit.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/receipt.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/reconcile.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/review.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/security.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/service.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/simulate.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/status.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/templates/workspace/AGENTS.md +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/templates/workspace/BRAIN.md +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/templates/workspace/SOUL.md +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/templates/workspace/skills/commission-research/SKILL.md +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/tools.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/upgrade.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/workspace.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/dependency_links.txt +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/entry_points.txt +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/requires.txt +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/top_level.txt +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_agent.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_alerts.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_backlog.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_calibration.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_capacity.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_chat_tools.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_checkout.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_cli_routing.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_cogs_reconciliation.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_config.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_config_cmd.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_counter_offer.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_customers.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_dashboard_chat.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_dashboard_financials.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_dashboard_security.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_delivery.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_doctor.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_event_hub.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_filelock.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_finance.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_guardrails.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_init.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_intake.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_job_cmd.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_jobs.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_logs.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_memory.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_metrics_block.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_nemotron_parse.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_notifications.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_observability.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_onboarding.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_optimize.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_packaging.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_paths.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_pricing.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_products.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_qr.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_quality.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_queue.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_rate_limit.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_receipt.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_reconcile.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_research_brief.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_retry.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_review.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_runtime_config_paths.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_security.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_server.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_simulate.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_spend_policy.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_status.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_stripe_client.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_telegram_pairing.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_tools.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_upgrade.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_upgrade_hint.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_worker.py +0 -0
- {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: solvent-agent
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A self-funding analyst agent: earns on Stripe, fulfils with NVIDIA Nemotron, spends within guardrails.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/ianalloway/solvent-agent
|
|
@@ -602,7 +602,7 @@ With both keys set:
|
|
|
602
602
|
| `SOLVENT_HOME` | Where runtime data and `.solvent` configuration (treasury DB, policies, reports, dashboard, logs) are stored. Defaults to the repo when run from a checkout, else `~/.solvent` |
|
|
603
603
|
| `NVIDIA_API_KEY` | Live Nemotron inference (`nvapi-...`) |
|
|
604
604
|
| `STRIPE_API_KEY` | Stripe test key (`sk_test_...`) |
|
|
605
|
-
| `STRIPE_WEBHOOK_SECRET` |
|
|
605
|
+
| `STRIPE_WEBHOOK_SECRET` | Required to accept `/webhooks/stripe` events (signature is verified before anything is stored) |
|
|
606
606
|
| `STRIPE_PAYMENT_POLL_TIMEOUT` | Seconds to wait for payment (default `120`) |
|
|
607
607
|
| `STRIPE_PAYMENT_POLL_INTERVAL` | Poll interval in seconds (default `2`) |
|
|
608
608
|
| `SOLVENT_FORCE_STRIPE_SIMULATE` | Force offline simulate mode even with a key |
|
|
@@ -561,7 +561,7 @@ With both keys set:
|
|
|
561
561
|
| `SOLVENT_HOME` | Where runtime data and `.solvent` configuration (treasury DB, policies, reports, dashboard, logs) are stored. Defaults to the repo when run from a checkout, else `~/.solvent` |
|
|
562
562
|
| `NVIDIA_API_KEY` | Live Nemotron inference (`nvapi-...`) |
|
|
563
563
|
| `STRIPE_API_KEY` | Stripe test key (`sk_test_...`) |
|
|
564
|
-
| `STRIPE_WEBHOOK_SECRET` |
|
|
564
|
+
| `STRIPE_WEBHOOK_SECRET` | Required to accept `/webhooks/stripe` events (signature is verified before anything is stored) |
|
|
565
565
|
| `STRIPE_PAYMENT_POLL_TIMEOUT` | Seconds to wait for payment (default `120`) |
|
|
566
566
|
| `STRIPE_PAYMENT_POLL_INTERVAL` | Poll interval in seconds (default `2`) |
|
|
567
567
|
| `SOLVENT_FORCE_STRIPE_SIMULATE` | Force offline simulate mode even with a key |
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Request-origin and abuse-limiting helpers for the HTTP server.
|
|
2
|
+
|
|
3
|
+
Two small, framework-free pieces that ``server.py`` builds its gates on:
|
|
4
|
+
|
|
5
|
+
* :func:`is_local_request` -- the "is this really the operator on this machine"
|
|
6
|
+
check behind the local-only routes. Trusting the peer address alone is wrong
|
|
7
|
+
behind a reverse proxy (every proxied request arrives from 127.0.0.1), so a
|
|
8
|
+
request that carries proxy headers is never treated as local.
|
|
9
|
+
* :class:`FailureLimiter` -- a tiny in-memory failed-attempt limiter used to slow
|
|
10
|
+
down guessing of single-use pairing tokens.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ipaddress
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections import deque
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
#: Environment variable that controls how "local" is decided.
|
|
24
|
+
#:
|
|
25
|
+
#: * ``auto`` (default): the peer address is loopback **and** the request carries
|
|
26
|
+
#: no proxy/forwarding header.
|
|
27
|
+
#: * ``never``: nothing is local. Local-only routes then need a delivery token
|
|
28
|
+
#: (or the dashboard token). Use this when a reverse proxy fronts the server.
|
|
29
|
+
#: * ``peer``: legacy behaviour -- trust the peer address only. Only safe when
|
|
30
|
+
#: the server is *not* behind a proxy.
|
|
31
|
+
LOCAL_ACCESS_ENV = "SOLVENT_LOCAL_ACCESS"
|
|
32
|
+
|
|
33
|
+
#: Headers that a reverse proxy / CDN adds. If any is present the request did not
|
|
34
|
+
#: originate on this machine, whatever the peer address says.
|
|
35
|
+
PROXY_HEADERS = (
|
|
36
|
+
"forwarded",
|
|
37
|
+
"x-forwarded-for",
|
|
38
|
+
"x-forwarded-host",
|
|
39
|
+
"x-forwarded-proto",
|
|
40
|
+
"x-forwarded-port",
|
|
41
|
+
"x-forwarded-server",
|
|
42
|
+
"x-real-ip",
|
|
43
|
+
"x-client-ip",
|
|
44
|
+
"x-cluster-client-ip",
|
|
45
|
+
"cf-connecting-ip",
|
|
46
|
+
"true-client-ip",
|
|
47
|
+
"via",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# "testclient" is the peer name Starlette's TestClient reports.
|
|
51
|
+
_LOCAL_NAMES = frozenset({"localhost", "testclient"})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_loopback(host: str) -> bool:
|
|
55
|
+
if host.lower() in _LOCAL_NAMES:
|
|
56
|
+
return True
|
|
57
|
+
try:
|
|
58
|
+
ip = ipaddress.ip_address(host.split("%", 1)[0])
|
|
59
|
+
except ValueError:
|
|
60
|
+
return False
|
|
61
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
62
|
+
ip = ip.ipv4_mapped
|
|
63
|
+
return ip.is_loopback
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def local_access_mode(env: Mapping[str, str] | None = None) -> str:
|
|
67
|
+
"""The configured local-access mode: ``auto`` (default), ``never`` or ``peer``."""
|
|
68
|
+
raw = (env if env is not None else os.environ).get(LOCAL_ACCESS_ENV, "auto")
|
|
69
|
+
mode = raw.strip().lower()
|
|
70
|
+
return mode if mode in ("auto", "never", "peer") else "auto"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def peer_host(request: Any) -> str:
|
|
74
|
+
"""The peer (socket) address of a request, or ``""``."""
|
|
75
|
+
client = getattr(request, "client", None)
|
|
76
|
+
return str(getattr(client, "host", "") or "") if client else ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def has_proxy_headers(request: Any) -> bool:
|
|
80
|
+
headers = getattr(request, "headers", None) or {}
|
|
81
|
+
return any(name in headers for name in PROXY_HEADERS)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_local_request(request: Any, env: Mapping[str, str] | None = None) -> bool:
|
|
85
|
+
"""True only for a request that genuinely originates on this machine."""
|
|
86
|
+
if request is None:
|
|
87
|
+
return False
|
|
88
|
+
mode = local_access_mode(env)
|
|
89
|
+
if mode == "never":
|
|
90
|
+
return False
|
|
91
|
+
if not _is_loopback(peer_host(request)):
|
|
92
|
+
return False
|
|
93
|
+
if mode == "peer":
|
|
94
|
+
return True
|
|
95
|
+
return not has_proxy_headers(request)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class FailureLimiter:
|
|
99
|
+
"""Block a key after ``max_failures`` failures inside a sliding ``window`` (seconds)."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, max_failures: int, window: float, max_keys: int = 10_000):
|
|
102
|
+
self.max_failures = max_failures
|
|
103
|
+
self.window = window
|
|
104
|
+
self.max_keys = max_keys
|
|
105
|
+
self._hits: dict[str, deque[float]] = {}
|
|
106
|
+
self._lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
def _prune(self, key: str, now: float) -> deque[float]:
|
|
109
|
+
hits = self._hits.setdefault(key, deque())
|
|
110
|
+
while hits and now - hits[0] > self.window:
|
|
111
|
+
hits.popleft()
|
|
112
|
+
if not hits:
|
|
113
|
+
self._hits.pop(key, None)
|
|
114
|
+
return hits
|
|
115
|
+
|
|
116
|
+
def blocked(self, key: str, now: float | None = None) -> bool:
|
|
117
|
+
now = time.monotonic() if now is None else now
|
|
118
|
+
with self._lock:
|
|
119
|
+
return len(self._prune(key, now)) >= self.max_failures
|
|
120
|
+
|
|
121
|
+
def record_failure(self, key: str, now: float | None = None) -> None:
|
|
122
|
+
now = time.monotonic() if now is None else now
|
|
123
|
+
with self._lock:
|
|
124
|
+
if key not in self._hits and len(self._hits) >= self.max_keys:
|
|
125
|
+
# Bound memory: drop the stalest key.
|
|
126
|
+
oldest = min(self._hits, key=lambda k: self._hits[k][-1] if self._hits[k] else 0)
|
|
127
|
+
self._hits.pop(oldest, None)
|
|
128
|
+
self._hits.setdefault(key, deque()).append(now)
|
|
@@ -22,7 +22,7 @@ from collections.abc import Callable
|
|
|
22
22
|
from .calibration import calibration_factor
|
|
23
23
|
from .guardrails import Guardrails
|
|
24
24
|
from .pricing import PricingPolicy
|
|
25
|
-
from .stages import StageRunner, _job_id_of,
|
|
25
|
+
from .stages import StageRunner, _job_id_of, coerce_job, screen_and_record
|
|
26
26
|
from .stripe_client import StripeClient
|
|
27
27
|
from .treasury import Treasury
|
|
28
28
|
|
|
@@ -79,10 +79,21 @@ class Solvent:
|
|
|
79
79
|
return self._runner.advance_job(job_id)
|
|
80
80
|
|
|
81
81
|
def enqueue_job(self, job: dict) -> dict:
|
|
82
|
-
"""Validate and persist a job for async worker processing.
|
|
83
|
-
|
|
82
|
+
"""Validate and persist a new job for async worker processing.
|
|
83
|
+
|
|
84
|
+
Invalid input is declined before anything is written. A valid job then
|
|
85
|
+
claims its id with an atomic insert, so a submission can never
|
|
86
|
+
overwrite or restart an existing job.
|
|
87
|
+
"""
|
|
88
|
+
coerced, err = coerce_job(job)
|
|
89
|
+
if err:
|
|
90
|
+
return self._emit(stage="declined", job_id=_job_id_of(job, None), reason=err)
|
|
91
|
+
assert coerced is not None
|
|
92
|
+
if not self.t.insert_job(coerced["id"], "received"):
|
|
93
|
+
return {"stage": "declined", "job_id": coerced["id"], "reason": "job id already exists"}
|
|
94
|
+
validated, err = screen_and_record(coerced, self.t)
|
|
84
95
|
if err:
|
|
85
|
-
return self._emit(stage="declined", job_id=
|
|
96
|
+
return self._emit(stage="declined", job_id=coerced["id"], reason=err)
|
|
86
97
|
assert validated is not None
|
|
87
98
|
q = self._runner._stage_quote(validated)
|
|
88
99
|
if q.get("stage") == "declined" or not q.get("accept"):
|
|
@@ -11,6 +11,7 @@ import uuid
|
|
|
11
11
|
from . import nemotron, tools
|
|
12
12
|
from .agent import Solvent
|
|
13
13
|
from .memory import SessionMemory
|
|
14
|
+
from .money import dollars_to_cents
|
|
14
15
|
from .pricing import quote
|
|
15
16
|
from .security import InputValidationError, PromptInjectionError, sanitise_prompt_input
|
|
16
17
|
from .treasury import fmt
|
|
@@ -145,7 +146,7 @@ def _merge_commission_slots(agent: Solvent, session_id: str, text: str) -> dict:
|
|
|
145
146
|
pending.setdefault("topic", text.strip()[:200])
|
|
146
147
|
m = _BUDGET_RE.search(text)
|
|
147
148
|
if m:
|
|
148
|
-
pending["budget_cents"] =
|
|
149
|
+
pending["budget_cents"] = dollars_to_cents(m.group(1))
|
|
149
150
|
em = _EMAIL_RE.search(text)
|
|
150
151
|
if em:
|
|
151
152
|
pending["customer_email"] = em.group(0)
|
|
@@ -29,6 +29,7 @@ from solvent.config import (
|
|
|
29
29
|
load_config,
|
|
30
30
|
)
|
|
31
31
|
from solvent.jobs import SAMPLE_JOBS
|
|
32
|
+
from solvent.money import dollars_to_cents
|
|
32
33
|
from solvent.onboarding import run_wizard, should_skip_onboarding, wants_reconfigure
|
|
33
34
|
from solvent.treasury import fmt
|
|
34
35
|
|
|
@@ -205,7 +206,7 @@ def run_interactive_mode(seed_cents: int = 10_000, fresh: bool = True):
|
|
|
205
206
|
continue
|
|
206
207
|
try:
|
|
207
208
|
fund_amt = float(parts[1])
|
|
208
|
-
fund_cents =
|
|
209
|
+
fund_cents = dollars_to_cents(fund_amt)
|
|
209
210
|
if fund_cents <= 0:
|
|
210
211
|
print(f"{C_RED}Fund amount must be positive.{C_RESET}\n")
|
|
211
212
|
continue
|
|
@@ -233,7 +234,7 @@ def run_interactive_mode(seed_cents: int = 10_000, fresh: bool = True):
|
|
|
233
234
|
|
|
234
235
|
budget_str = input(f"{C_CYAN}Client Budget in USD (e.g. 50.00):{C_RESET} $").strip()
|
|
235
236
|
try:
|
|
236
|
-
budget_cents =
|
|
237
|
+
budget_cents = dollars_to_cents(budget_str)
|
|
237
238
|
if budget_cents <= 0:
|
|
238
239
|
print(f"{C_RED}Budget must be greater than 0.{C_RESET}\n")
|
|
239
240
|
continue
|
|
@@ -341,7 +342,7 @@ def main():
|
|
|
341
342
|
cfg = resolve_config(args)
|
|
342
343
|
apply_config(cfg)
|
|
343
344
|
|
|
344
|
-
seed_cents =
|
|
345
|
+
seed_cents = dollars_to_cents(args.seed)
|
|
345
346
|
fresh = not args.keep_balance
|
|
346
347
|
|
|
347
348
|
if args.interactive:
|
|
@@ -38,6 +38,8 @@ def _script_json(value: object) -> str:
|
|
|
38
38
|
.replace("<", "\\u003c")
|
|
39
39
|
.replace(">", "\\u003e")
|
|
40
40
|
.replace("&", "\\u0026")
|
|
41
|
+
.replace("\u2028", "\\u2028")
|
|
42
|
+
.replace("\u2029", "\\u2029")
|
|
41
43
|
)
|
|
42
44
|
|
|
43
45
|
|
|
@@ -1367,15 +1369,6 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
|
|
|
1367
1369
|
let jobsData = {_script_json(jobs_data)};
|
|
1368
1370
|
let currentOpenJobId = null;
|
|
1369
1371
|
|
|
1370
|
-
function esc(value) {{
|
|
1371
|
-
return String(value ?? "")
|
|
1372
|
-
.replace(/&/g, "&")
|
|
1373
|
-
.replace(/</g, "<")
|
|
1374
|
-
.replace(/>/g, ">")
|
|
1375
|
-
.replace(/"/g, """)
|
|
1376
|
-
.replace(/'/g, "'");
|
|
1377
|
-
}}
|
|
1378
|
-
|
|
1379
1372
|
function openBriefModal(jobId) {{
|
|
1380
1373
|
document.getElementById("modal-title").innerText = "Research Brief Preview: " + jobId;
|
|
1381
1374
|
const iframe = document.getElementById("brief-iframe");
|
|
@@ -1398,21 +1391,28 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
|
|
|
1398
1391
|
return;
|
|
1399
1392
|
}}
|
|
1400
1393
|
|
|
1401
|
-
|
|
1394
|
+
container.replaceChildren();
|
|
1402
1395
|
jobIds.forEach(jobId => {{
|
|
1403
1396
|
const job = jobsData[jobId] || {{}};
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
`;
|
|
1397
|
+
const row = document.createElement("div");
|
|
1398
|
+
row.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.02); border:1px solid var(--color-border); padding:10px 14px; border-radius:10px; margin-bottom: 8px;";
|
|
1399
|
+
const labels = document.createElement("div");
|
|
1400
|
+
labels.style.cssText = "display:flex; flex-direction:column; gap:2px; max-width:70%;";
|
|
1401
|
+
const idEl = document.createElement("span");
|
|
1402
|
+
idEl.style.cssText = "font-size:11px; font-family:var(--font-mono); color:var(--color-text-muted);";
|
|
1403
|
+
idEl.textContent = jobId;
|
|
1404
|
+
const titleEl = document.createElement("span");
|
|
1405
|
+
titleEl.style.cssText = "font-size:13px; font-weight:600; color:var(--color-text-primary); text-overflow:ellipsis; overflow:hidden; white-space:nowrap;";
|
|
1406
|
+
titleEl.textContent = job.title || `Research Brief (${{jobId}})`;
|
|
1407
|
+
labels.append(idEl, titleEl);
|
|
1408
|
+
const button = document.createElement("button");
|
|
1409
|
+
button.className = "btn btn-outline btn-sm";
|
|
1410
|
+
button.style.cssText = "padding:4px 8px; font-size:11px;";
|
|
1411
|
+
button.textContent = "Preview";
|
|
1412
|
+
button.addEventListener("click", () => openBriefModal(jobId));
|
|
1413
|
+
row.append(labels, button);
|
|
1414
|
+
container.appendChild(row);
|
|
1414
1415
|
}});
|
|
1415
|
-
container.innerHTML = html;
|
|
1416
1416
|
}}
|
|
1417
1417
|
|
|
1418
1418
|
// Auto-scroll terminal log to the bottom
|
|
@@ -1431,28 +1431,43 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
|
|
|
1431
1431
|
|
|
1432
1432
|
document.getElementById("drawer-title").innerText = "Job Details: " + jobId;
|
|
1433
1433
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1434
|
+
const meta = document.getElementById("drawer-metadata");
|
|
1435
|
+
meta.replaceChildren();
|
|
1436
|
+
const metaRow = (label, value, className) => {{
|
|
1437
|
+
const row = document.createElement("div");
|
|
1438
|
+
row.className = "meta-row";
|
|
1439
|
+
const strong = document.createElement("strong");
|
|
1440
|
+
strong.textContent = label;
|
|
1441
|
+
const span = document.createElement("span");
|
|
1442
|
+
if (className) span.className = className;
|
|
1443
|
+
span.textContent = value;
|
|
1444
|
+
row.append(strong, " ", span);
|
|
1445
|
+
meta.appendChild(row);
|
|
1446
|
+
}};
|
|
1447
|
+
const status = String(job.status);
|
|
1448
|
+
metaRow("Title:", job.title || "N/A");
|
|
1449
|
+
metaRow("Status:", status.toUpperCase(), "badge badge-" + status.replace(/[^a-z_]/gi, ""));
|
|
1450
|
+
metaRow("Client Budget:", formatCents(job.price));
|
|
1451
|
+
metaRow("Incurred COGS:", formatCents(getExpensesTotal(job.expenses)));
|
|
1452
|
+
metaRow("Net P&L:", (job.pnl >= 0 ? "+" : "") + formatCents(job.pnl), job.pnl >= 0 ? "green" : "red");
|
|
1453
|
+
metaRow("Est. Margin:", String(job.margin_pct) + "%");
|
|
1442
1454
|
|
|
1443
1455
|
if (job.expenses.length > 0) {{
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1456
|
+
const heading = document.createElement("h4");
|
|
1457
|
+
heading.style.cssText = "margin-top: 16px; margin-bottom: 8px; font-size:12px; color:var(--color-text-muted)";
|
|
1458
|
+
heading.textContent = "ITEMIZED EXPENSES";
|
|
1459
|
+
const list = document.createElement("ul");
|
|
1460
|
+
list.className = "drawer-expenses-list";
|
|
1448
1461
|
job.expenses.forEach(e => {{
|
|
1449
|
-
|
|
1462
|
+
const item = document.createElement("li");
|
|
1463
|
+
const vendor = document.createElement("strong");
|
|
1464
|
+
vendor.textContent = String(e.vendor) + ":";
|
|
1465
|
+
item.append(vendor, ` -${{formatCents(e.amount)}} (${{e.memo}})`);
|
|
1466
|
+
list.appendChild(item);
|
|
1450
1467
|
}});
|
|
1451
|
-
|
|
1468
|
+
meta.append(heading, list);
|
|
1452
1469
|
}}
|
|
1453
1470
|
|
|
1454
|
-
document.getElementById("drawer-metadata").innerHTML = metaHTML;
|
|
1455
|
-
|
|
1456
1471
|
const hasBrief = Object.prototype.hasOwnProperty.call(briefs || {{}}, jobId);
|
|
1457
1472
|
const reportText = hasBrief
|
|
1458
1473
|
? "_Brief generated. Use the hosted brief link with a delivery token to view full contents._"
|
|
@@ -1503,13 +1518,13 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
|
|
|
1503
1518
|
html = html.replace(/\\*(.*?)\\*/g, '<em>$1</em>');
|
|
1504
1519
|
|
|
1505
1520
|
// Double line breaks to paragraphs
|
|
1506
|
-
const paragraphs = html.split(
|
|
1521
|
+
const paragraphs = html.split(/\\n\\n+/);
|
|
1507
1522
|
html = paragraphs.map(p => {{
|
|
1508
1523
|
p = p.trim();
|
|
1509
1524
|
if (!p) return '';
|
|
1510
1525
|
if (p.startsWith('<h') || p.startsWith('<ul') || p.startsWith('<li')) return p;
|
|
1511
|
-
return `<p>${{p.replace(
|
|
1512
|
-
}}).join(
|
|
1526
|
+
return `<p>${{p.replace(/\\n/g, '<br>')}}</p>`;
|
|
1527
|
+
}}).join("\\n");
|
|
1513
1528
|
|
|
1514
1529
|
return html;
|
|
1515
1530
|
}}
|
|
@@ -228,8 +228,44 @@ _TABLES = {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
|
|
231
|
+
#: Free-text columns that can carry customer- or vendor-supplied strings. Only
|
|
232
|
+
#: these are neutralised, so numeric columns stay numeric for the spreadsheet.
|
|
233
|
+
_UNTRUSTED_TEXT_COLUMNS = {
|
|
234
|
+
"ledger": frozenset(
|
|
235
|
+
{"memo", "job_id", "vendor", "stripe_ref", "stripe_session_id", "stripe_link_id"}
|
|
236
|
+
),
|
|
237
|
+
"jobs": frozenset(
|
|
238
|
+
{"id", "status", "topic", "customer_email", "current_stage", "error_reason", "deliverable_url"}
|
|
239
|
+
),
|
|
240
|
+
"metrics": frozenset({"job_id", "decline_reason", "block_rule"}),
|
|
241
|
+
"customers": frozenset({"email"}),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
|
|
245
|
+
_LEADING_NOISE = re.compile(r"^[\s\x00-\x1f\x7f\u200b-\u200f\u2060\ufeff]*")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def neutralise_cell(value: Any) -> Any:
|
|
249
|
+
"""Stop a spreadsheet from evaluating a text cell as a formula.
|
|
250
|
+
|
|
251
|
+
A string whose first significant character (after any leading whitespace
|
|
252
|
+
or control characters) is ``= + - @``, or that starts with a tab or
|
|
253
|
+
carriage return, is prefixed with a single quote. Non-strings pass through.
|
|
254
|
+
"""
|
|
255
|
+
if not isinstance(value, str) or not value:
|
|
256
|
+
return value
|
|
257
|
+
significant = _LEADING_NOISE.sub("", value, count=1)
|
|
258
|
+
if value.startswith(_FORMULA_TRIGGERS) or significant.startswith(_FORMULA_TRIGGERS):
|
|
259
|
+
return "'" + value
|
|
260
|
+
return value
|
|
261
|
+
|
|
262
|
+
|
|
231
263
|
def write_csv(data: dict[str, Any], out_dir: Path) -> list[Path]:
|
|
232
|
-
"""Write one CSV per table; returns the files written.
|
|
264
|
+
"""Write one CSV per table; returns the files written.
|
|
265
|
+
|
|
266
|
+
Untrusted text columns are passed through `neutralise_cell` so a job topic
|
|
267
|
+
or memo like ``=HYPERLINK(...)`` is shown as text, not run as a formula.
|
|
268
|
+
"""
|
|
233
269
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
234
270
|
written = []
|
|
235
271
|
for table, columns in _TABLES.items():
|
|
@@ -237,8 +273,14 @@ def write_csv(data: dict[str, Any], out_dir: Path) -> list[Path]:
|
|
|
237
273
|
with path.open("w", encoding="utf-8", newline="") as fh:
|
|
238
274
|
writer = csv.DictWriter(fh, fieldnames=list(columns), extrasaction="ignore")
|
|
239
275
|
writer.writeheader()
|
|
276
|
+
text_columns = _UNTRUSTED_TEXT_COLUMNS.get(table, frozenset())
|
|
240
277
|
for row in data[table]:
|
|
241
|
-
writer.writerow(
|
|
278
|
+
writer.writerow(
|
|
279
|
+
{
|
|
280
|
+
key: neutralise_cell(value) if key in text_columns else value
|
|
281
|
+
for key, value in row.items()
|
|
282
|
+
}
|
|
283
|
+
)
|
|
242
284
|
written.append(path)
|
|
243
285
|
return written
|
|
244
286
|
|
|
@@ -23,6 +23,7 @@ import sys
|
|
|
23
23
|
from collections.abc import Iterable
|
|
24
24
|
|
|
25
25
|
from .treasury import LedgerEntry, Treasury, fmt
|
|
26
|
+
from .money import dollars_to_cents
|
|
26
27
|
|
|
27
28
|
_PERIODS = ("day", "week", "month")
|
|
28
29
|
|
|
@@ -387,7 +388,7 @@ def main() -> None:
|
|
|
387
388
|
)
|
|
388
389
|
if "--reserve" in args:
|
|
389
390
|
try:
|
|
390
|
-
reserve_cents =
|
|
391
|
+
reserve_cents = dollars_to_cents(args[args.index("--reserve") + 1])
|
|
391
392
|
except (ValueError, IndexError):
|
|
392
393
|
print(usage)
|
|
393
394
|
sys.exit(1)
|
|
@@ -10,6 +10,7 @@ from . import pairing
|
|
|
10
10
|
from .agent import Solvent
|
|
11
11
|
from .chat import format_job_notification, handle_message
|
|
12
12
|
from .memory import SessionMemory
|
|
13
|
+
from .money import dollars_to_cents
|
|
13
14
|
from .rate_limit import RateLimiter
|
|
14
15
|
from .treasury import fmt
|
|
15
16
|
|
|
@@ -37,6 +38,11 @@ def notify(channel: str, external_id: str, text: str) -> None:
|
|
|
37
38
|
pass
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
#: Commands that answer from local state without calling the model; only these
|
|
42
|
+
#: skip the per-user rate limit. Everything else, /quote included, is counted.
|
|
43
|
+
RATE_EXEMPT_COMMANDS = frozenset({"/help", "/status", "/jobs"})
|
|
44
|
+
|
|
45
|
+
|
|
40
46
|
def _rate_ok(user_key: str) -> bool:
|
|
41
47
|
allowed, _reason = _rate_limiter.check(user_key)
|
|
42
48
|
return allowed
|
|
@@ -55,10 +61,16 @@ class Gateway:
|
|
|
55
61
|
*,
|
|
56
62
|
user_label: str | None = None,
|
|
57
63
|
) -> str:
|
|
64
|
+
user_key = f"{channel}:{external_id}"
|
|
65
|
+
if _rate_limiter.is_banned(user_key):
|
|
66
|
+
return "You are temporarily blocked from messaging this bot."
|
|
67
|
+
|
|
58
68
|
session = self.memory.get_or_create(channel, external_id, user_label)
|
|
59
|
-
|
|
69
|
+
stripped = text.strip()
|
|
70
|
+
is_command = stripped.startswith("/")
|
|
71
|
+
command = stripped.split(maxsplit=1)[0].lower() if is_command else ""
|
|
60
72
|
|
|
61
|
-
if not
|
|
73
|
+
if command not in RATE_EXEMPT_COMMANDS and not _rate_ok(user_key):
|
|
62
74
|
return "Rate limit exceeded. Please wait before sending more messages."
|
|
63
75
|
|
|
64
76
|
if channel == "telegram" and not pairing.is_allowed(external_id, user_label):
|
|
@@ -82,7 +94,7 @@ class Gateway:
|
|
|
82
94
|
cmd = parts[0].lower()
|
|
83
95
|
arg = parts[1] if len(parts) > 1 else ""
|
|
84
96
|
|
|
85
|
-
if cmd
|
|
97
|
+
if cmd in ("/help", "/start"):
|
|
86
98
|
return (
|
|
87
99
|
"SOLVENT research business bot.\n"
|
|
88
100
|
"/status — treasury snapshot\n"
|
|
@@ -106,6 +118,13 @@ class Gateway:
|
|
|
106
118
|
f"{j['id']}: {j.get('status')} — {(j.get('topic') or '')[:40]}" for j in jobs
|
|
107
119
|
)
|
|
108
120
|
if cmd == "/pair" and arg.strip().lower() == "qr":
|
|
121
|
+
if channel == "telegram" and pairing.dm_policy() == "open":
|
|
122
|
+
# With an open DM policy any Telegram user reaches this command;
|
|
123
|
+
# minting a pairing token is an operator action.
|
|
124
|
+
return (
|
|
125
|
+
"Pairing QR codes are not available over an open Telegram DM policy. "
|
|
126
|
+
"Use /pair qr from the dashboard chat, or set SOLVENT_TELEGRAM_DM_POLICY=pairing."
|
|
127
|
+
)
|
|
109
128
|
token = self.agent.t.create_openclaw_token(ttl=600)
|
|
110
129
|
from . import qr as _qr
|
|
111
130
|
|
|
@@ -122,13 +141,14 @@ class Gateway:
|
|
|
122
141
|
port = 443
|
|
123
142
|
return _qr.render_token(token, host=host, port=port)
|
|
124
143
|
|
|
125
|
-
if cmd == "/quote"
|
|
144
|
+
if cmd == "/quote":
|
|
145
|
+
if "|" not in arg:
|
|
146
|
+
return "Usage: /quote topic | 50.00"
|
|
126
147
|
topic, budget_s = [x.strip() for x in arg.split("|", 1)]
|
|
127
148
|
try:
|
|
128
|
-
cents =
|
|
149
|
+
cents = dollars_to_cents(budget_s)
|
|
129
150
|
except ValueError:
|
|
130
151
|
return "Usage: /quote topic | 50.00"
|
|
131
|
-
from .chat import handle_message
|
|
132
152
|
|
|
133
153
|
prompt = f"Quote a brief on '{topic}' with budget_cents={cents}. Use quote_brief tool."
|
|
134
154
|
return handle_message(
|
|
@@ -139,9 +159,7 @@ class Gateway:
|
|
|
139
159
|
channel=channel,
|
|
140
160
|
)
|
|
141
161
|
|
|
142
|
-
return
|
|
143
|
-
session["id"], text, agent=self.agent, memory=self.memory, channel=channel
|
|
144
|
-
)
|
|
162
|
+
return f"Unknown command {cmd[:32]}. Send /help for the list of commands."
|
|
145
163
|
|
|
146
164
|
def on_job_event(self, event: dict) -> None:
|
|
147
165
|
"""Push job lifecycle updates to sessions watching a job."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""money.py — one shared dollars -> cents conversion.
|
|
2
|
+
|
|
3
|
+
``int(dollars * 100)`` truncates binary-float error toward zero, so
|
|
4
|
+
``int(19.99 * 100)`` is ``1998``, one cent short of what the user typed.
|
|
5
|
+
Every place that turns a user-supplied dollar amount into integer cents should
|
|
6
|
+
call :func:`dollars_to_cents` instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def dollars_to_cents(amount: int | float | str | Decimal) -> int:
|
|
15
|
+
"""Convert a dollar amount to integer cents, rounding half away from zero.
|
|
16
|
+
|
|
17
|
+
The value is read through its decimal string form (``Decimal(str(amount))``)
|
|
18
|
+
rather than its binary float value, so ``19.99`` is ``1999`` and ``0.29`` is
|
|
19
|
+
``29``. Sub-cent amounts round half up: ``1.005`` is ``101``. Negative
|
|
20
|
+
amounts mirror positive ones (``-1.005`` is ``-101``).
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ValueError: if *amount* is not a finite number (``nan``, ``inf``,
|
|
24
|
+
garbage text, ``None``, a bool). ``ValueError`` matches what
|
|
25
|
+
``float()`` raised for bad input, so existing ``except ValueError``
|
|
26
|
+
handlers keep working.
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(amount, bool) or amount is None:
|
|
29
|
+
raise ValueError(f"not a dollar amount: {amount!r}")
|
|
30
|
+
try:
|
|
31
|
+
value = amount if isinstance(amount, Decimal) else Decimal(str(amount).strip())
|
|
32
|
+
except InvalidOperation:
|
|
33
|
+
raise ValueError(f"not a dollar amount: {amount!r}") from None
|
|
34
|
+
if not value.is_finite():
|
|
35
|
+
raise ValueError(f"not a finite dollar amount: {amount!r}")
|
|
36
|
+
return int((value * 100).quantize(Decimal(1), rounding=ROUND_HALF_UP))
|