hyperagents 1.2.3rc5.post3__py3-none-any.whl

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.
Files changed (361) hide show
  1. agents/__init__.py +31 -0
  2. agents/background_monitor.py +453 -0
  3. agents/learning.py +460 -0
  4. agents/planner.py +421 -0
  5. agents/transcriber.py +341 -0
  6. civilization/__init__.py +51 -0
  7. civilization/api/__init__.py +7 -0
  8. civilization/api/civilization_api.py +361 -0
  9. civilization/graph/__init__.py +19 -0
  10. civilization/graph/knowledge_linker.py +348 -0
  11. civilization/graph/org_graph.py +348 -0
  12. civilization/graph/process_graph.py +386 -0
  13. civilization/ingestion/__init__.py +20 -0
  14. civilization/ingestion/chunker.py +273 -0
  15. civilization/ingestion/document_ingestor.py +358 -0
  16. civilization/ingestion/embedder.py +262 -0
  17. civilization/ingestion/extractor.py +224 -0
  18. civilization/interview/__init__.py +19 -0
  19. civilization/interview/gap_detector.py +380 -0
  20. civilization/interview/interviewer.py +323 -0
  21. civilization/interview/questioner.py +363 -0
  22. civilization/retrieval/__init__.py +17 -0
  23. civilization/retrieval/civilization_rag.py +316 -0
  24. civilization/retrieval/context_injector.py +342 -0
  25. civilization/retrieval/relevance_ranker.py +287 -0
  26. civilization/schema.py +299 -0
  27. civilization/store.py +177 -0
  28. civilization/sync/__init__.py +16 -0
  29. civilization/sync/confluence_sync.py +317 -0
  30. civilization/sync/gdrive_sync.py +262 -0
  31. civilization/sync/notion_sync.py +237 -0
  32. civilization/versioning/__init__.py +19 -0
  33. civilization/versioning/diff_engine.py +351 -0
  34. civilization/versioning/staleness_detector.py +303 -0
  35. civilization/versioning/version_manager.py +293 -0
  36. cli/__init__.py +1 -0
  37. cli/doctor.py +334 -0
  38. cli/hyperclaw.py +1357 -0
  39. cli/onboarding.py +469 -0
  40. core/__init__.py +1 -0
  41. core/connectors/__init__.py +119 -0
  42. core/connectors/infrastructure.py +772 -0
  43. core/connectors/messaging.py +586 -0
  44. core/connectors/productivity.py +764 -0
  45. core/connectors/smarthome.py +528 -0
  46. core/connectors/social.py +730 -0
  47. core/hyperrouter/__init__.py +23 -0
  48. core/hyperrouter/bandit.py +218 -0
  49. core/hyperrouter/fast_loop.py +68 -0
  50. core/hyperrouter/slow_loop.py +143 -0
  51. core/hyperstate/__init__.py +24 -0
  52. core/hyperstate/certifier.py +154 -0
  53. core/hyperstate/schema.py +160 -0
  54. core/hyperstate/state_manager.py +103 -0
  55. core/hyperstate/store.py +163 -0
  56. core/providers/__init__.py +121 -0
  57. core/providers/embeddings.py +389 -0
  58. core/providers/image.py +534 -0
  59. core/providers/llm.py +1935 -0
  60. core/providers/search.py +608 -0
  61. core/providers/voice.py +474 -0
  62. hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
  63. hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
  64. hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
  65. hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
  66. hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
  67. hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
  68. hyperclaw/__init__.py +67 -0
  69. hyperclaw/__main__.py +220 -0
  70. hyperclaw/agent.py +775 -0
  71. hyperclaw/agent_coordinator.py +577 -0
  72. hyperclaw/anthropic_params.py +225 -0
  73. hyperclaw/api_utils.py +85 -0
  74. hyperclaw/approval_telegram.py +209 -0
  75. hyperclaw/approval_terminal.py +80 -0
  76. hyperclaw/attachments.py +177 -0
  77. hyperclaw/branding.py +55 -0
  78. hyperclaw/brief/__init__.py +11 -0
  79. hyperclaw/brief/cli.py +204 -0
  80. hyperclaw/brief/collect.py +146 -0
  81. hyperclaw/brief/compose.py +333 -0
  82. hyperclaw/brief/diff.py +110 -0
  83. hyperclaw/brief/errors.py +99 -0
  84. hyperclaw/brief/google.py +269 -0
  85. hyperclaw/brief/google_auth.py +294 -0
  86. hyperclaw/brief/links.py +124 -0
  87. hyperclaw/brief/service.py +424 -0
  88. hyperclaw/brief/store.py +205 -0
  89. hyperclaw/brief/tasks.py +200 -0
  90. hyperclaw/chat_scope.py +115 -0
  91. hyperclaw/cli_tui.py +497 -0
  92. hyperclaw/client_guard.py +717 -0
  93. hyperclaw/connection_check.py +113 -0
  94. hyperclaw/cost_guard.py +1728 -0
  95. hyperclaw/default_config/agents.yaml +534 -0
  96. hyperclaw/default_config/hyperclaw.yaml +27 -0
  97. hyperclaw/default_config/models.yaml +161 -0
  98. hyperclaw/deployment.py +39 -0
  99. hyperclaw/digest/__init__.py +9 -0
  100. hyperclaw/digest/cli.py +117 -0
  101. hyperclaw/digest/errors.py +100 -0
  102. hyperclaw/digest/gitlab.py +187 -0
  103. hyperclaw/digest/http.py +113 -0
  104. hyperclaw/digest/redact.py +32 -0
  105. hyperclaw/digest/service.py +271 -0
  106. hyperclaw/digest/slack.py +223 -0
  107. hyperclaw/digest/state.py +124 -0
  108. hyperclaw/digest/summarize.py +198 -0
  109. hyperclaw/digest/tokens.py +143 -0
  110. hyperclaw/distribution.py +46 -0
  111. hyperclaw/documents.py +351 -0
  112. hyperclaw/feeds.py +339 -0
  113. hyperclaw/google_workspace/__init__.py +5 -0
  114. hyperclaw/google_workspace/api.py +151 -0
  115. hyperclaw/google_workspace/auth.py +397 -0
  116. hyperclaw/google_workspace/calendar.py +157 -0
  117. hyperclaw/google_workspace/cli.py +72 -0
  118. hyperclaw/google_workspace/drive.py +236 -0
  119. hyperclaw/google_workspace/errors.py +58 -0
  120. hyperclaw/google_workspace/gmail.py +295 -0
  121. hyperclaw/google_workspace/meet.py +122 -0
  122. hyperclaw/google_workspace/tools.py +319 -0
  123. hyperclaw/hyperspeed.py +57 -0
  124. hyperclaw/integrations_layer.py +1427 -0
  125. hyperclaw/live_state.py +188 -0
  126. hyperclaw/managed_config.py +245 -0
  127. hyperclaw/media_hub.py +179 -0
  128. hyperclaw/memory_manager.py +584 -0
  129. hyperclaw/model_catalog.py +551 -0
  130. hyperclaw/model_policy.py +211 -0
  131. hyperclaw/model_router.py +595 -0
  132. hyperclaw/model_selector.py +72 -0
  133. hyperclaw/naming.py +284 -0
  134. hyperclaw/notices.py +65 -0
  135. hyperclaw/oai_stream.py +106 -0
  136. hyperclaw/orchestrator.py +591 -0
  137. hyperclaw/outbox.py +82 -0
  138. hyperclaw/persona.py +367 -0
  139. hyperclaw/prometheus.py +219 -0
  140. hyperclaw/prompt_assembly.py +157 -0
  141. hyperclaw/provider_setup.py +146 -0
  142. hyperclaw/providers.py +340 -0
  143. hyperclaw/qualification.py +341 -0
  144. hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
  145. hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
  146. hyperclaw/reasoning_display.py +35 -0
  147. hyperclaw/scheduler.py +454 -0
  148. hyperclaw/server.py +847 -0
  149. hyperclaw/setup.py +627 -0
  150. hyperclaw/skills.py +717 -0
  151. hyperclaw/solomon.py +442 -0
  152. hyperclaw/swarm.py +533 -0
  153. hyperclaw/taint.py +832 -0
  154. hyperclaw/team/__init__.py +28 -0
  155. hyperclaw/team/dispatcher.py +431 -0
  156. hyperclaw/team/genesis.py +106 -0
  157. hyperclaw/team/lead.py +110 -0
  158. hyperclaw/team/model.py +135 -0
  159. hyperclaw/team/morning.py +238 -0
  160. hyperclaw/team/repos.py +66 -0
  161. hyperclaw/team/roster.py +264 -0
  162. hyperclaw/team/specialist.py +235 -0
  163. hyperclaw/team/status.py +163 -0
  164. hyperclaw/team/watchers.py +112 -0
  165. hyperclaw/teamsetup/__init__.py +17 -0
  166. hyperclaw/teamsetup/checks.py +208 -0
  167. hyperclaw/teamsetup/item.py +391 -0
  168. hyperclaw/teamsetup/oauth_loopback.py +84 -0
  169. hyperclaw/teamsetup/onboard.py +550 -0
  170. hyperclaw/teamsetup/profile.py +82 -0
  171. hyperclaw/teamsetup/secrets.py +313 -0
  172. hyperclaw/teamsetup/selfcheck.py +295 -0
  173. hyperclaw/teamsetup/service.py +495 -0
  174. hyperclaw/teamsetup/slack_oauth.py +442 -0
  175. hyperclaw/teamsetup/tty.py +62 -0
  176. hyperclaw/telegram_bot.py +1106 -0
  177. hyperclaw/telegram_intake.py +126 -0
  178. hyperclaw/telegram_pairing.py +360 -0
  179. hyperclaw/terminal_provider.py +323 -0
  180. hyperclaw/tools/__init__.py +1 -0
  181. hyperclaw/tools/approval.py +949 -0
  182. hyperclaw/tools/args.py +212 -0
  183. hyperclaw/tools/catalog.py +244 -0
  184. hyperclaw/tools/classify.py +266 -0
  185. hyperclaw/tools/effects.py +724 -0
  186. hyperclaw/tools/egress.py +210 -0
  187. hyperclaw/tools/envelope.py +188 -0
  188. hyperclaw/tools/guard.py +367 -0
  189. hyperclaw/tools/ledger.py +339 -0
  190. hyperclaw/tools/receipts.py +70 -0
  191. hyperclaw/tools/registry.py +318 -0
  192. hyperclaw/tools/service_guard.py +151 -0
  193. hyperclaw/tools/surfaces.py +54 -0
  194. hyperclaw/tools/workspace.py +87 -0
  195. hyperclaw/trading/__init__.py +77 -0
  196. hyperclaw/truth_guard.py +791 -0
  197. hyperclaw/tts.py +124 -0
  198. hyperclaw/tui.py +4255 -0
  199. hyperclaw/tui_bridge.py +938 -0
  200. hyperclaw/unattended.py +60 -0
  201. hyperclaw/updates/__init__.py +54 -0
  202. hyperclaw/updates/check.py +131 -0
  203. hyperclaw/updates/index.py +178 -0
  204. hyperclaw/updates/install.py +110 -0
  205. hyperclaw/updates/managed.py +161 -0
  206. hyperclaw/updates/manifest.py +138 -0
  207. hyperclaw/updates/notices.py +86 -0
  208. hyperclaw/updates/paths.py +53 -0
  209. hyperclaw/updates/release.py +146 -0
  210. hyperclaw/updates/upgrade.py +253 -0
  211. hyperclaw/updates/versioning.py +104 -0
  212. hyperclaw/userconfig.py +119 -0
  213. hyperclaw/xai.py +83 -0
  214. integrations/__init__.py +31 -0
  215. integrations/automation/__init__.py +1 -0
  216. integrations/automation/make.py +87 -0
  217. integrations/automation/zapier.py +87 -0
  218. integrations/base.py +476 -0
  219. integrations/communication/__init__.py +1 -0
  220. integrations/communication/twilio.py +279 -0
  221. integrations/communication/zoom.py +290 -0
  222. integrations/data/__init__.py +1 -0
  223. integrations/data/google_analytics.py +135 -0
  224. integrations/data/postgres.py +182 -0
  225. integrations/data/supabase_connector.py +202 -0
  226. integrations/data/weather_feeds.py +450 -0
  227. integrations/developer/__init__.py +9 -0
  228. integrations/developer/github.py +406 -0
  229. integrations/developer/gitlab.py +298 -0
  230. integrations/enterprise/__init__.py +13 -0
  231. integrations/enterprise/confluence.py +266 -0
  232. integrations/enterprise/hubspot.py +293 -0
  233. integrations/enterprise/jira.py +314 -0
  234. integrations/enterprise/salesforce.py +286 -0
  235. integrations/finance/__init__.py +1 -0
  236. integrations/finance/polymarket_client.py +247 -0
  237. integrations/finance/quickbooks.py +281 -0
  238. integrations/finance/stripe.py +261 -0
  239. integrations/gateway.py +238 -0
  240. integrations/messaging/__init__.py +32 -0
  241. integrations/messaging/discord.py +159 -0
  242. integrations/messaging/email.py +401 -0
  243. integrations/messaging/imessage.py +443 -0
  244. integrations/messaging/signal.py +175 -0
  245. integrations/messaging/slack.py +217 -0
  246. integrations/messaging/sms.py +175 -0
  247. integrations/messaging/teams.py +208 -0
  248. integrations/messaging/telegram.py +260 -0
  249. integrations/messaging/whatsapp.py +203 -0
  250. integrations/productivity/__init__.py +17 -0
  251. integrations/productivity/airtable.py +217 -0
  252. integrations/productivity/asana.py +248 -0
  253. integrations/productivity/google/__init__.py +19 -0
  254. integrations/productivity/google/base.py +89 -0
  255. integrations/productivity/google/calendar.py +272 -0
  256. integrations/productivity/google/docs.py +202 -0
  257. integrations/productivity/google/drive.py +268 -0
  258. integrations/productivity/google/gmail.py +279 -0
  259. integrations/productivity/google/meet.py +218 -0
  260. integrations/productivity/google/sheets.py +222 -0
  261. integrations/productivity/google/tasks.py +199 -0
  262. integrations/productivity/linear.py +279 -0
  263. integrations/productivity/microsoft/__init__.py +13 -0
  264. integrations/productivity/microsoft/base.py +93 -0
  265. integrations/productivity/microsoft/calendar.py +207 -0
  266. integrations/productivity/microsoft/onedrive.py +222 -0
  267. integrations/productivity/microsoft/outlook.py +241 -0
  268. integrations/productivity/microsoft/sharepoint.py +211 -0
  269. integrations/productivity/notion.py +243 -0
  270. integrations/productivity/todoist.py +250 -0
  271. integrations/productivity/trello.py +244 -0
  272. integrations/storage/__init__.py +1 -0
  273. integrations/storage/box.py +279 -0
  274. integrations/storage/dropbox.py +215 -0
  275. integrations/telegram_inbound.py +179 -0
  276. integrations/telegram_poller.py +151 -0
  277. memory/__init__.py +1 -0
  278. memory/causal_graph.py +125 -0
  279. memory/impact_tracker.py +88 -0
  280. memory/vector_memory.py +436 -0
  281. models/__init__.py +12 -0
  282. models/chatjimmy_client.py +210 -0
  283. models/claude_client.py +235 -0
  284. models/claude_code_subagent.py +192 -0
  285. models/openai_client.py +120 -0
  286. models/router.py +162 -0
  287. recursive/discovery_loop.py +118 -0
  288. recursive/optimization_loop.py +135 -0
  289. recursive/validation_loop.py +38 -0
  290. security/__init__.py +1 -0
  291. security/audit_logger.py +106 -0
  292. security/filesystem_guard.py +100 -0
  293. security/hypershield.py +99 -0
  294. security/network_guard.py +97 -0
  295. security/policy_engine.py +198 -0
  296. swarm/__init__.py +1 -0
  297. swarm/agents/__init__.py +0 -0
  298. swarm/agents/base.py +230 -0
  299. swarm/agents/business/__init__.py +0 -0
  300. swarm/agents/business/counsel.py +31 -0
  301. swarm/agents/business/herald.py +32 -0
  302. swarm/agents/business/ledger.py +26 -0
  303. swarm/agents/business/nexus.py +30 -0
  304. swarm/agents/business/ops.py +30 -0
  305. swarm/agents/business/pipeline.py +28 -0
  306. swarm/agents/business/revenue.py +30 -0
  307. swarm/agents/business/sovereign.py +31 -0
  308. swarm/agents/business/strategos.py +27 -0
  309. swarm/agents/business/talent.py +27 -0
  310. swarm/agents/business/venture.py +32 -0
  311. swarm/agents/comms/__init__.py +1 -0
  312. swarm/agents/comms/cipher.py +32 -0
  313. swarm/agents/comms/echo_comms.py +31 -0
  314. swarm/agents/comms/envoy.py +31 -0
  315. swarm/agents/comms/herald_scheduler.py +33 -0
  316. swarm/agents/comms/pulse.py +29 -0
  317. swarm/agents/creative/__init__.py +0 -0
  318. swarm/agents/creative/author.py +27 -0
  319. swarm/agents/creative/lens.py +25 -0
  320. swarm/agents/executive/__init__.py +1 -0
  321. swarm/agents/intelligence/__init__.py +1 -0
  322. swarm/agents/intelligence/arbiter.py +30 -0
  323. swarm/agents/intelligence/sentinel.py +30 -0
  324. swarm/agents/media/__init__.py +0 -0
  325. swarm/agents/personal/__init__.py +0 -0
  326. swarm/agents/personal/atlas.py +27 -0
  327. swarm/agents/personal/hearth.py +26 -0
  328. swarm/agents/personal/midas.py +27 -0
  329. swarm/agents/personal/navigator.py +27 -0
  330. swarm/agents/personal/nourish.py +31 -0
  331. swarm/agents/personal/vitals.py +33 -0
  332. swarm/agents/recursive/__init__.py +0 -0
  333. swarm/agents/recursive/alchemist.py +73 -0
  334. swarm/agents/recursive/calibrator.py +45 -0
  335. swarm/agents/recursive/scout.py +97 -0
  336. swarm/agents/scientific/__init__.py +0 -0
  337. swarm/agents/scientific/cosmos.py +36 -0
  338. swarm/agents/scientific/gaia.py +26 -0
  339. swarm/agents/scientific/medicus.py +33 -0
  340. swarm/agents/scientific/oracle_agent.py +39 -0
  341. swarm/agents/scientific/scribe.py +39 -0
  342. swarm/agents/talent/__init__.py +0 -0
  343. swarm/agents/talent/deal.py +28 -0
  344. swarm/agents/talent/roster.py +29 -0
  345. swarm/agents/talent/scout.py +29 -0
  346. swarm/agents/talent/stage.py +27 -0
  347. swarm/agents/tech/__init__.py +0 -0
  348. swarm/agents/tech/aegis.py +28 -0
  349. swarm/agents/tech/bridge.py +28 -0
  350. swarm/agents/tech/forge.py +28 -0
  351. swarm/agents/trading/__init__.py +1 -0
  352. swarm/agents/trading/global_prediction_engine.py +611 -0
  353. swarm/agents/trading/polymarket_trader.py +165 -0
  354. swarm/agents/trading/prediction_strategist.py +218 -0
  355. swarm/autogen_bridge.py +68 -0
  356. swarm/bid_protocol.py +196 -0
  357. swarm/dispatcher.py +527 -0
  358. swarm/nexus.py +181 -0
  359. swarm/registry.py +112 -0
  360. telegram_listener.py +403 -0
  361. ui/tui/app.py +214 -0
@@ -0,0 +1,1728 @@
1
+ """
2
+ The cost guard (spec §9): daily and per-task caps on model spend, a spend
3
+ line in answers, and a stop-and-ask at the cap with a summary built from
4
+ evidence.
5
+
6
+ One choke point. Every model call in the package goes through ``reserve``
7
+ (before the request: check the caps and hold its estimated cost) and
8
+ ``record`` (after it: settle to the actual spend), by one of three adapters:
9
+
10
+ - ``guard_client(client)`` wraps an Anthropic / OpenAI SDK client (sync or
11
+ async): ``messages.create``, ``messages.stream``, ``beta.messages.*`` and
12
+ ``chat.completions.create`` (streamed or not) are metered;
13
+ - ``guard_provider_class(cls)`` wraps ``complete``/``stream`` of every
14
+ ``core.providers.llm.LLMProvider`` subclass (done by the base class);
15
+ - ``metered(provider, model, request)`` is a context manager for the few
16
+ raw-HTTP calls.
17
+
18
+ tests/unit/test_cost_guard.py scans the package and fails if a provider
19
+ client or a raw model endpoint call is not behind one of these.
20
+
21
+ At the cap the guard STOPS: ``preflight`` raises ``CostCapReached`` and no
22
+ further model call runs for that task (per-task cap) or for the rest of the
23
+ day (daily cap). It never picks a cheaper model and carries on. Callers turn
24
+ the exception into a stop-and-ask message (``stop_message``) that says what
25
+ was done (from the turn's tool results and the spend records), what remains,
26
+ the spend against the cap, and asks whether to continue. Raising a cap is the
27
+ user's explicit action (``raise_cap``: ``hyperclaw cost raise`` or ``/cost
28
+ raise`` in the terminal) and is written to the activity log. Unattended and
29
+ guest turns never see internal detail: the owner is notified instead.
30
+
31
+ Settings (``config.json`` key ``cost_guard`` wins over the environment):
32
+
33
+ daily_cap_usd HYPERCLAW_DAILY_CAP_USD (legacy DAILY_BUDGET_USD) default 10.00
34
+ task_cap_usd HYPERCLAW_TASK_CAP_USD default 2.00
35
+ spend_line_min_usd HYPERCLAW_SPEND_LINE_MIN_USD default 0.01
36
+ prices {"<model id>" | "provider:<name>":
37
+ {"input_per_1k": USD, "output_per_1k": USD}}
38
+ max_output_tokens {"<model id>" | "provider:<name>": tokens}
39
+ (the model's real output maximum; see max_output_for)
40
+ max_reasoning_tokens {"<model id>" | "provider:<name>": tokens}
41
+ (reasoning on top of the max output when thinking is
42
+ on; HyperSpeed V2 8192; see reasoning_tokens_for)
43
+ max_output_tokens_per_path
44
+ {"<path>": tokens}: the explicit max output every
45
+ model call on that path sends (see max_output and
46
+ DEFAULT_MAX_OUTPUT_PER_PATH)
47
+
48
+ Prices: config ``prices`` > models.yaml ``models.<id>.cost_per_1k_*`` >
49
+ the built-in table below (model families) > the built-in provider rate
50
+ (HyperSpeed V2: a placeholder until Hyper sets its internal rate in config)
51
+ > UNKNOWN_PRICE, a conservative rate (never 0).
52
+
53
+ Spend is persisted per local day in SQLite at
54
+ $HYPERCLAW_COST_DB or $HYPERCLAW_ROOT/ledger/cost.sqlite3, so caps survive
55
+ restarts.
56
+
57
+ Concurrency (review of !17): the cap check and a reservation of the call's
58
+ estimated cost happen in ONE ``BEGIN IMMEDIATE`` transaction (``reserve``),
59
+ so calls in flight at the same time count against the cap before any of
60
+ them finishes. ``record`` settles the reservation to the actual spend; a
61
+ call that failed before producing anything releases it. If settling fails,
62
+ the reserved amount stays counted (never dropped). An unreadable ledger
63
+ stops the task with a clear reason (scope ``ledger``); it never lets a call
64
+ through unchecked.
65
+ """
66
+ from __future__ import annotations
67
+
68
+ import contextlib
69
+ import contextvars
70
+ import functools
71
+ import inspect
72
+ import json
73
+ import logging
74
+ import os
75
+ import sqlite3
76
+ import threading
77
+ import time
78
+ import uuid
79
+ from dataclasses import dataclass, field
80
+ from pathlib import Path
81
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
82
+
83
+ log = logging.getLogger("hyperclaw.cost_guard")
84
+
85
+ DEFAULT_DAILY_CAP_USD = 10.0
86
+ DEFAULT_TASK_CAP_USD = 2.0
87
+ DEFAULT_SPEND_LINE_MIN_USD = 0.01
88
+
89
+ # USD per 1k tokens (input, output). Exact ids from models.yaml win; these are
90
+ # the fallbacks, matched by longest prefix after stripping vendor prefixes.
91
+ BUILTIN_PRICES: Dict[str, Tuple[float, float]] = {
92
+ "claude-fable": (0.010, 0.050),
93
+ "claude-opus-5": (0.005, 0.025),
94
+ "claude-opus": (0.015, 0.075), # older Opus generations: the high rate
95
+ "claude-sonnet": (0.003, 0.015),
96
+ "claude-haiku": (0.001, 0.005),
97
+ "claude-3-haiku": (0.00025, 0.00125),
98
+ "gpt-4o-mini": (0.00015, 0.0006),
99
+ "gpt-4o": (0.0025, 0.010),
100
+ "llama-3.1-8b": (0.00005, 0.00008),
101
+ }
102
+ # Provider-level rates for models with no entry of their own.
103
+ # HyperSpeed V2: PLACEHOLDER for Hyper's internal rate; a Hyper deployment
104
+ # sets the real figure in config.json cost_guard.prices["provider:hyperspeed"].
105
+ PROVIDER_PRICES: Dict[str, Tuple[float, float]] = {
106
+ "hyperspeed": (0.002, 0.008),
107
+ }
108
+ # Unknown model: a conservative (high) rate, never 0.
109
+ UNKNOWN_PRICE: Tuple[float, float] = (0.015, 0.075)
110
+
111
+ TASK = "task"
112
+ DAILY = "daily"
113
+ LEDGER = "ledger" # the ledger can't be read: stop, never call unchecked
114
+
115
+ # A call that sets no max output (no max_tokens / max_completion_tokens) is
116
+ # reserved against the model's REAL output maximum, so a long generation can't
117
+ # be under-reserved and let concurrent calls pass the cap (review of !27). The
118
+ # reservation is settled to the actual usage when the call ends.
119
+ #
120
+ # Resolution (``max_output_for``): config ``cost_guard.max_output_tokens``
121
+ # {"<model id>" | "provider:<name>": int} > models.yaml
122
+ # ``models.<id>.max_output_tokens`` > models.yaml
123
+ # ``providers.<name>.max_output_tokens`` (provider registry) > the built-in
124
+ # table below (longest id prefix after stripping vendor prefixes) > the
125
+ # built-in provider limit > UNKNOWN_MAX_OUTPUT_TOKENS (logged once per model).
126
+ BUILTIN_MAX_OUTPUT: Dict[str, int] = {
127
+ # Anthropic (published output caps)
128
+ "claude-fable": 128000,
129
+ "claude-mythos": 128000,
130
+ "claude-opus-5": 128000,
131
+ "claude-sonnet-5": 128000,
132
+ "claude-opus-4-8": 128000,
133
+ "claude-opus-4-7": 128000,
134
+ "claude-opus-4-6": 128000,
135
+ "claude-sonnet-4-6": 128000,
136
+ "claude-opus-4-5": 64000,
137
+ "claude-sonnet-4-5": 64000,
138
+ "claude-haiku-4-5": 64000,
139
+ "claude-opus-4-1": 32000,
140
+ "claude-opus-4-0": 32000,
141
+ "claude-opus-4-2025": 32000,
142
+ "claude-sonnet-4-0": 64000,
143
+ "claude-sonnet-4-2025": 64000,
144
+ "claude-3-7-sonnet": 64000,
145
+ "claude-3-5-sonnet": 8192,
146
+ "claude-3-5-haiku": 8192,
147
+ "claude-3-opus": 4096,
148
+ "claude-3-haiku": 4096,
149
+ "claude-opus": 128000, # other Opus / Sonnet / Haiku ids: the largest cap
150
+ "claude-sonnet": 128000,
151
+ "claude-haiku": 64000,
152
+ # OpenAI
153
+ "gpt-5": 128000,
154
+ "gpt-4.1": 32768,
155
+ "gpt-4o": 16384,
156
+ "o1": 100000,
157
+ "o3": 100000,
158
+ "o4": 100000,
159
+ # xAI: output is bounded by the context window, no separate published cap;
160
+ # reserve the same large figure as the biggest known caps.
161
+ "grok": 128000,
162
+ # Llama 3.1 8B (ChatJimmy)
163
+ "llama-3.1-8b": 8192,
164
+ }
165
+ # Provider-level limits for models with no entry of their own.
166
+ # HyperSpeed V2 (vLLM): max output 16384 tokens (max-model-len 65536), as
167
+ # documented by the V2 serving team on 13 Oct; not yet confirmed against a
168
+ # live GET /v1/models. A Hyper deployment can set the exact figure in
169
+ # models.yaml providers.hyperspeed.max_output_tokens or config.json
170
+ # cost_guard.max_output_tokens["provider:hyperspeed"].
171
+ PROVIDER_MAX_OUTPUT: Dict[str, int] = {
172
+ "hyperspeed": 16384,
173
+ }
174
+ # Reasoning tokens a provider can generate ON TOP of the max output when
175
+ # thinking/reasoning is on for the call. HyperSpeed V2 reasons by default
176
+ # (up to 8192 tokens, same source as above); a call that turns it off
177
+ # (extra_body.chat_template_kwargs.enable_thinking = false) reserves none.
178
+ # models.yaml providers.<name>.max_reasoning_tokens or config.json
179
+ # cost_guard.max_reasoning_tokens {"<model id>" | "provider:<name>": int}
180
+ # override it.
181
+ PROVIDER_REASONING_TOKENS: Dict[str, int] = {
182
+ "hyperspeed": 8192,
183
+ }
184
+ # Unknown model: a conservative (large) output maximum, never a small guess.
185
+ UNKNOWN_MAX_OUTPUT_TOKENS = 128000
186
+
187
+ # ── every path sets its own max output (Vaughn, 13 Oct) ──
188
+ # Every model call on every path passes an explicit max output, chosen per
189
+ # path by ``max_output(path, provider, model)``, so no reservation is made
190
+ # against a model's full output maximum (Opus 5.5 with no max would reserve
191
+ # about $3.20, above the $2 per-task cap). The fallback above (reserve
192
+ # against ``max_output_for`` when a call sets no max) stays for safety but is
193
+ # unreachable from shipped paths: tests/unit/test_max_output_every_path.py
194
+ # scans every call site, drives every path with fakes under
195
+ # HYPERCLAW_REQUIRE_MAX_OUTPUT=1 (set for the whole test suite), and fails if
196
+ # any call reaches the fallback.
197
+ #
198
+ # Settings: config.json ``cost_guard.max_output_tokens_per_path``
199
+ # {"<path>": tokens} overrides the defaults below. Whatever is configured,
200
+ # ``max_output`` never returns more than the model's real output maximum, nor
201
+ # more than keeps (the path's input allowance + the max output) at the
202
+ # model's price within the per-task cap.
203
+ DEFAULT_MAX_OUTPUT_PER_PATH: Dict[str, int] = {
204
+ "terminal": 16000, # tui.py chat turn on the Messages API (thinking + tools)
205
+ "terminal_openai": 4096, # tui.py chat turn on OpenAI-schema providers (V2, OpenAI, xAI)
206
+ "telegram": 4096, # solomon.py chat replies (Telegram, terminal chat-only)
207
+ "bridge": 8000, # tui_bridge.py tool turns (Telegram owner / guest)
208
+ "chat": 4096, # other chat replies (orchestrator, cli_tui, model clients)
209
+ "agent": 16000, # agent.py tool loop
210
+ "specialist": 2048, # team specialists (hyperclaw/team, one dispatcher)
211
+ "swarm_agent": 4096, # legacy swarm agents (hyperclaw/swarm.py), via the dispatcher
212
+ "router": 4096, # model_router calls (each model's own max_tokens, capped)
213
+ "provider": 4096, # core.providers LLMProvider.complete/stream with no max given
214
+ "vision": 1024, # image analysis (terminal vision tool, vision providers)
215
+ "search": 1024, # LLM-answered web search (Perplexity)
216
+ "brief": 1200, # daily brief (brief/compose.py)
217
+ "digest": 900, # watcher digests (digest/summarize.py)
218
+ "memory": 4096, # memory consolidation (prometheus.py)
219
+ "genesis": 2000, # skills GENESIS discovery (skills.py)
220
+ "naming": 120, # name suggestions (naming.py)
221
+ "connection_check": 1, # V2 key check (connection_check.py): one token
222
+ "doctor": 8, # `hyperclaw doctor` provider pings
223
+ "listener": 2000, # legacy telegram_listener.py
224
+ }
225
+ # The largest prompt (in tokens) each path is expected to send. Used to keep
226
+ # (input + max output) within the per-task cap: ``max_output`` caps the output
227
+ # so that this input plus the output, at the model's price, fits the cap.
228
+ INPUT_ALLOWANCE_PER_PATH: Dict[str, int] = {
229
+ "terminal": 40000, "terminal_openai": 40000, "telegram": 20000, "bridge": 40000,
230
+ "chat": 20000, "agent": 40000, "specialist": 20000, "swarm_agent": 20000, "router": 20000, "provider": 20000,
231
+ "vision": 4000, "search": 2000, "brief": 20000, "digest": 20000, "memory": 20000,
232
+ "genesis": 4000, "naming": 2000, "connection_check": 50, "doctor": 50, "listener": 20000,
233
+ }
234
+ DEFAULT_PATH = "chat"
235
+ # Never cap a path's output below this (or below its own default, when that is
236
+ # smaller) because of a very low per-task cap: an answer still has room.
237
+ MIN_CAPPED_OUTPUT_TOKENS = 256
238
+ REQUIRE_MAX_ENV = "HYPERCLAW_REQUIRE_MAX_OUTPUT"
239
+
240
+
241
+ class MissingMaxOutput(RuntimeError):
242
+ """A metered model call set no max output while HYPERCLAW_REQUIRE_MAX_OUTPUT
243
+ is on (the test suite). Shipped paths never get here."""
244
+
245
+
246
+ # ───────────────────────────── settings ─────────────────────────────
247
+
248
+ def _cfg() -> dict:
249
+ try:
250
+ from hyperclaw.userconfig import load_config
251
+ c = load_config().get("cost_guard")
252
+ return c if isinstance(c, dict) else {}
253
+ except Exception:
254
+ return {}
255
+
256
+
257
+ def _num(v) -> Optional[float]:
258
+ try:
259
+ f = float(v)
260
+ except (TypeError, ValueError):
261
+ return None
262
+ return f if f == f and f >= 0 else None # not NaN, not negative
263
+
264
+
265
+ def _setting(key: str, envs: Iterable[str], default: float) -> float:
266
+ v = _num(_cfg().get(key))
267
+ if v is not None:
268
+ return v
269
+ for e in envs:
270
+ v = _num(os.environ.get(e))
271
+ if v is not None:
272
+ return v
273
+ return default
274
+
275
+
276
+ def daily_cap() -> float:
277
+ return _setting("daily_cap_usd", ("HYPERCLAW_DAILY_CAP_USD", "DAILY_BUDGET_USD"),
278
+ DEFAULT_DAILY_CAP_USD)
279
+
280
+
281
+ def task_cap() -> float:
282
+ return _setting("task_cap_usd", ("HYPERCLAW_TASK_CAP_USD",), DEFAULT_TASK_CAP_USD)
283
+
284
+
285
+ def spend_line_min() -> float:
286
+ return _setting("spend_line_min_usd", ("HYPERCLAW_SPEND_LINE_MIN_USD",),
287
+ DEFAULT_SPEND_LINE_MIN_USD)
288
+
289
+
290
+ # ───────────────────────────── prices ─────────────────────────────
291
+
292
+ def _price_entry(v) -> Optional[Tuple[float, float]]:
293
+ if isinstance(v, dict):
294
+ i = _num(v.get("input_per_1k", v.get("cost_per_1k_input")))
295
+ o = _num(v.get("output_per_1k", v.get("cost_per_1k_output")))
296
+ if i is not None and o is not None:
297
+ return (i, o)
298
+ return None
299
+
300
+
301
+ _YAML_CACHE: Dict[str, Any] = {}
302
+
303
+
304
+ def _yaml_prices() -> Dict[str, Tuple[float, float]]:
305
+ try:
306
+ import yaml
307
+ from hyperclaw.api_utils import find_config
308
+ p = find_config("models.yaml")
309
+ if not p.exists():
310
+ return {}
311
+ key = (str(p), p.stat().st_mtime_ns)
312
+ if _YAML_CACHE.get("key") == key:
313
+ return _YAML_CACHE["prices"]
314
+ data = yaml.safe_load(p.read_text()) or {}
315
+ except Exception:
316
+ return {}
317
+ out = {}
318
+ for mid, entry in ((data or {}).get("models") or {}).items():
319
+ e = _price_entry(entry)
320
+ if e is not None:
321
+ out[str(mid)] = e
322
+ _YAML_CACHE.update(key=key, prices=out)
323
+ return out
324
+
325
+
326
+ def _bare(model: str) -> str:
327
+ m = (model or "").strip().lower()
328
+ for pre in ("us.anthropic.", "eu.anthropic.", "apac.anthropic.", "anthropic.", "anthropic/",
329
+ "openai/", "meta-llama/"):
330
+ if m.startswith(pre):
331
+ m = m[len(pre):]
332
+ return m
333
+
334
+
335
+ def price_for(provider: str, model: str) -> Tuple[float, float]:
336
+ """(input, output) USD per 1k tokens for this provider/model. Never (0, 0)
337
+ for an unknown model."""
338
+ cfg = _cfg().get("prices") or {}
339
+ if isinstance(cfg, dict):
340
+ for k in (model, f"provider:{provider}"):
341
+ e = _price_entry(cfg.get(k)) if k else None
342
+ if e is not None:
343
+ return e
344
+ yp = _yaml_prices()
345
+ if model in yp:
346
+ return yp[model]
347
+ bare = _bare(model)
348
+ best = ""
349
+ for k in BUILTIN_PRICES:
350
+ if bare.startswith(k) and len(k) > len(best):
351
+ best = k
352
+ if best:
353
+ return BUILTIN_PRICES[best]
354
+ if provider in PROVIDER_PRICES:
355
+ return PROVIDER_PRICES[provider]
356
+ return UNKNOWN_PRICE
357
+
358
+
359
+ # ───────────────────────────── output maxima ─────────────────────────────
360
+
361
+ def _pos_int(v) -> Optional[int]:
362
+ if isinstance(v, bool):
363
+ return None
364
+ try:
365
+ n = int(v)
366
+ except (TypeError, ValueError):
367
+ return None
368
+ return n if n > 0 else None
369
+
370
+
371
+ def _yaml_limits() -> Tuple[Dict[str, int], Dict[str, int], Dict[str, int]]:
372
+ """(models.<id>.max_output_tokens, providers.<name>.max_output_tokens,
373
+ providers.<name>.max_reasoning_tokens) from models.yaml."""
374
+ try:
375
+ import yaml
376
+ from hyperclaw.api_utils import find_config
377
+ p = find_config("models.yaml")
378
+ if not p.exists():
379
+ return {}, {}, {}
380
+ key = (str(p), p.stat().st_mtime_ns)
381
+ if _YAML_MAX_CACHE.get("key") == key:
382
+ return (_YAML_MAX_CACHE["models"], _YAML_MAX_CACHE["providers"],
383
+ _YAML_MAX_CACHE["reasoning"])
384
+ data = yaml.safe_load(p.read_text()) or {}
385
+ except Exception:
386
+ return {}, {}, {}
387
+ models, providers, reasoning = {}, {}, {}
388
+ for section, out in (("models", models), ("providers", providers)):
389
+ for name, entry in ((data or {}).get(section) or {}).items():
390
+ n = _pos_int(entry.get("max_output_tokens")) if isinstance(entry, dict) else None
391
+ if n is not None:
392
+ out[str(name)] = n
393
+ for name, entry in ((data or {}).get("providers") or {}).items():
394
+ if isinstance(entry, dict) and "max_reasoning_tokens" in entry:
395
+ v = entry.get("max_reasoning_tokens")
396
+ n = 0 if v in (0, "0") else _pos_int(v)
397
+ if n is not None:
398
+ reasoning[str(name)] = n
399
+ _YAML_MAX_CACHE.update(key=key, models=models, providers=providers, reasoning=reasoning)
400
+ return models, providers, reasoning
401
+
402
+
403
+ def _yaml_max_outputs() -> Tuple[Dict[str, int], Dict[str, int]]:
404
+ """(models.<id>.max_output_tokens, providers.<name>.max_output_tokens)."""
405
+ m, p, _r = _yaml_limits()
406
+ return m, p
407
+
408
+
409
+ def reasoning_tokens_for(provider: str, model: str) -> int:
410
+ """Reasoning tokens this provider/model can generate on top of the max
411
+ output when thinking is on for the call (0 when it has no separate
412
+ reasoning budget)."""
413
+ cfg = _cfg().get("max_reasoning_tokens") or {}
414
+ if isinstance(cfg, dict):
415
+ for k in (model, f"provider:{provider}"):
416
+ v = cfg.get(k) if k else None
417
+ if v in (0, "0"):
418
+ return 0
419
+ n = _pos_int(v)
420
+ if n is not None:
421
+ return n
422
+ yr = _yaml_limits()[2]
423
+ if provider in yr:
424
+ return yr[provider]
425
+ return PROVIDER_REASONING_TOKENS.get(provider or "", 0)
426
+
427
+
428
+ def thinking_on(req: Any) -> bool:
429
+ """False when the request turns reasoning off (vLLM chat-template switch
430
+ ``enable_thinking: false``, in the body or in ``extra_body``); else True
431
+ (V2 reasons by default)."""
432
+ if not isinstance(req, dict):
433
+ return True
434
+ for d in (req, req.get("extra_body") if isinstance(req.get("extra_body"), dict) else {}):
435
+ ctk = d.get("chat_template_kwargs")
436
+ if isinstance(ctk, dict) and ctk.get("enable_thinking") is False:
437
+ return False
438
+ return True
439
+
440
+
441
+ _YAML_MAX_CACHE: Dict[str, Any] = {}
442
+ _UNKNOWN_MAX_LOGGED: set = set()
443
+ _UNKNOWN_MAX_LOCK = threading.Lock()
444
+
445
+
446
+ def max_output_for(provider: str, model: str) -> int:
447
+ """The model's real output maximum in tokens: what one call that sets no
448
+ max output can generate at most. Unknown → UNKNOWN_MAX_OUTPUT_TOKENS
449
+ (conservative), logged once per provider/model."""
450
+ n = _known_max_output(provider, model)
451
+ if n is not None:
452
+ return n
453
+ key = (provider or "", model or "")
454
+ with _UNKNOWN_MAX_LOCK:
455
+ first = key not in _UNKNOWN_MAX_LOGGED
456
+ _UNKNOWN_MAX_LOGGED.add(key)
457
+ if first:
458
+ log.warning("cost guard: no known output maximum for model %r (provider %r); "
459
+ "reserving the conservative default of %d output tokens for calls "
460
+ "that set no max", model or "", provider or "", UNKNOWN_MAX_OUTPUT_TOKENS)
461
+ return UNKNOWN_MAX_OUTPUT_TOKENS
462
+
463
+
464
+ def _known_max_output(provider: str, model: str) -> Optional[int]:
465
+ """The model's real output maximum if any source knows it, else None."""
466
+ cfg = _cfg().get("max_output_tokens") or {}
467
+ if isinstance(cfg, dict):
468
+ for k in (model, f"provider:{provider}"):
469
+ n = _pos_int(cfg.get(k)) if k else None
470
+ if n is not None:
471
+ return n
472
+ ym, yp = _yaml_max_outputs()
473
+ if model in ym:
474
+ return ym[model]
475
+ bare = _bare(model)
476
+ best = ""
477
+ for k in BUILTIN_MAX_OUTPUT:
478
+ if bare.startswith(k) and len(k) > len(best):
479
+ best = k
480
+ if best:
481
+ return BUILTIN_MAX_OUTPUT[best]
482
+ if provider in yp:
483
+ return yp[provider]
484
+ if provider in PROVIDER_MAX_OUTPUT:
485
+ return PROVIDER_MAX_OUTPUT[provider]
486
+ return None
487
+
488
+
489
+ def path_max_output(path: str) -> int:
490
+ """The configured (or default) max output for ``path``, before capping."""
491
+ cfg = _cfg().get("max_output_tokens_per_path") or {}
492
+ if isinstance(cfg, dict):
493
+ n = _pos_int(cfg.get(path))
494
+ if n is not None:
495
+ return n
496
+ if path in DEFAULT_MAX_OUTPUT_PER_PATH:
497
+ return DEFAULT_MAX_OUTPUT_PER_PATH[path]
498
+ log.warning("cost guard: unknown model-call path %r; using the %r max output", path,
499
+ DEFAULT_PATH)
500
+ return DEFAULT_MAX_OUTPUT_PER_PATH[DEFAULT_PATH]
501
+
502
+
503
+ def max_output(path: str, provider: str = "", model: str = "",
504
+ requested: Optional[int] = None) -> int:
505
+ """The explicit max output (max_tokens / max_completion_tokens) for one
506
+ model call on ``path``: the path's setting (``requested`` replaces it when
507
+ a caller has its own figure, e.g. a router model's max_tokens), never
508
+ above the model's real output maximum, and never so large that the path's
509
+ input allowance plus this output, at the model's price, exceeds the
510
+ per-task cap. Every shipped model call passes this value."""
511
+ n = _pos_int(requested) or path_max_output(path)
512
+ real = _known_max_output(provider, model)
513
+ if real is not None:
514
+ n = min(n, real)
515
+ _pi, po = price_for(provider, model)
516
+ if po > 0:
517
+ pin = INPUT_ALLOWANCE_PER_PATH.get(path, INPUT_ALLOWANCE_PER_PATH[DEFAULT_PATH])
518
+ budget = (task_cap() - (pin / 1000.0) * _pi
519
+ - (reasoning_tokens_for(provider, model) / 1000.0) * po)
520
+ fit = int(budget / po * 1000) if budget > 0 else 0
521
+ if fit < n:
522
+ n = max(fit, min(n, MIN_CAPPED_OUTPUT_TOKENS))
523
+ return max(1, n)
524
+
525
+
526
+ def cost_of(provider: str, model: str, input_tokens: int, output_tokens: int) -> float:
527
+ pi, po = price_for(provider, model)
528
+ return (max(0, int(input_tokens)) / 1000.0) * pi + (max(0, int(output_tokens)) / 1000.0) * po
529
+
530
+
531
+ def estimate_tokens(obj: Any) -> int:
532
+ """Rough token count (chars / 4) for a request or text when the provider
533
+ reports no usage. Inline base64 media count as ~1600 tokens each."""
534
+ def walk(o):
535
+ if o is None:
536
+ return 0
537
+ if isinstance(o, str):
538
+ return 1600 * 4 if len(o) > 20000 else len(o)
539
+ if isinstance(o, dict):
540
+ return sum(walk(v) for v in o.values())
541
+ if isinstance(o, (list, tuple)):
542
+ return sum(walk(v) for v in o)
543
+ return len(str(o))
544
+ return max(1, walk(obj) // 4)
545
+
546
+
547
+ # ───────────────────────────── store ─────────────────────────────
548
+
549
+ _SCHEMA = """
550
+ CREATE TABLE IF NOT EXISTS spend (
551
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
552
+ ts REAL NOT NULL,
553
+ day TEXT NOT NULL,
554
+ task_id TEXT,
555
+ surface TEXT,
556
+ provider TEXT,
557
+ model TEXT,
558
+ input_tokens INTEGER NOT NULL,
559
+ output_tokens INTEGER NOT NULL,
560
+ cost_usd REAL NOT NULL,
561
+ estimated INTEGER NOT NULL DEFAULT 0,
562
+ status TEXT NOT NULL DEFAULT 'settled' -- 'reserved' while the call is in flight
563
+ );
564
+ CREATE INDEX IF NOT EXISTS spend_day ON spend (day);
565
+ CREATE INDEX IF NOT EXISTS spend_task ON spend (task_id);
566
+ CREATE TABLE IF NOT EXISTS owner_notices (
567
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
568
+ ts REAL NOT NULL,
569
+ day TEXT NOT NULL,
570
+ dedupe TEXT,
571
+ text TEXT NOT NULL,
572
+ delivered INTEGER NOT NULL DEFAULT 0
573
+ );
574
+ CREATE UNIQUE INDEX IF NOT EXISTS owner_notices_dedupe ON owner_notices (dedupe);
575
+ """
576
+
577
+
578
+ def default_path() -> Path:
579
+ explicit = os.environ.get("HYPERCLAW_COST_DB")
580
+ if explicit:
581
+ return Path(explicit).expanduser()
582
+ root = Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw")).expanduser()
583
+ return root / "ledger" / "cost.sqlite3"
584
+
585
+
586
+ def today() -> str:
587
+ return time.strftime("%Y-%m-%d", time.localtime())
588
+
589
+
590
+ class CostStore:
591
+ def __init__(self, path: Optional[os.PathLike] = None):
592
+ self.path = Path(path) if path else default_path()
593
+ self._lock = threading.Lock()
594
+ self._ready = False
595
+
596
+ def _connect(self) -> sqlite3.Connection:
597
+ created = False
598
+ if not self._ready:
599
+ with self._lock:
600
+ if not self._ready:
601
+ self.path.parent.mkdir(parents=True, exist_ok=True)
602
+ try:
603
+ os.chmod(self.path.parent, 0o700)
604
+ except OSError:
605
+ pass
606
+ created = not self.path.exists()
607
+ con = sqlite3.connect(self.path, timeout=10, isolation_level=None)
608
+ try:
609
+ con.execute("PRAGMA journal_mode=WAL")
610
+ con.executescript(_SCHEMA)
611
+ cols = {r[1] for r in con.execute("PRAGMA table_info(spend)")}
612
+ if "status" not in cols: # ledger from before reservations
613
+ con.execute("ALTER TABLE spend ADD COLUMN status TEXT NOT NULL "
614
+ "DEFAULT 'settled'")
615
+ finally:
616
+ con.close()
617
+ try:
618
+ os.chmod(self.path, 0o600)
619
+ except OSError:
620
+ pass
621
+ self._ready = True
622
+ if created:
623
+ # A missing ledger means today's spend restarts at $0: never silently.
624
+ msg = (f"A new cost ledger was created at {self.path}; spend recorded before "
625
+ f"this point (if any) is not counted against today's cap.")
626
+ log.warning("cost guard: %s", msg)
627
+ _activity("cost_guard", "cost_ledger", "ok", msg)
628
+ else:
629
+ log.info("cost guard: cost ledger opened at %s", self.path)
630
+ con = sqlite3.connect(self.path, timeout=10, isolation_level=None)
631
+ con.row_factory = sqlite3.Row
632
+ return con
633
+
634
+ def add(self, *, task_id: Optional[str], surface: str, provider: str, model: str,
635
+ input_tokens: int, output_tokens: int, cost_usd: float, estimated: bool) -> None:
636
+ con = self._connect()
637
+ try:
638
+ con.execute(
639
+ "INSERT INTO spend (ts, day, task_id, surface, provider, model, input_tokens, "
640
+ "output_tokens, cost_usd, estimated) VALUES (?,?,?,?,?,?,?,?,?,?)",
641
+ (time.time(), today(), task_id, surface, provider, model, int(input_tokens),
642
+ int(output_tokens), float(cost_usd), 1 if estimated else 0))
643
+ finally:
644
+ con.close()
645
+
646
+ def reserve(self, *, task_id: Optional[str], surface: str, provider: str, model: str,
647
+ cost_usd: float, daily_cap: float, task_cap: float):
648
+ """Check the caps and reserve ``cost_usd`` in ONE transaction.
649
+ Returns (scope_or_None, day_spent, task_spent, calls, reservation_id).
650
+ A scope means the cap is reached and nothing was reserved."""
651
+ con = self._connect()
652
+ try:
653
+ con.execute("BEGIN IMMEDIATE")
654
+ day = today()
655
+ dspent = float(con.execute("SELECT COALESCE(SUM(cost_usd), 0) FROM spend WHERE day=?",
656
+ (day,)).fetchone()[0])
657
+ tspent, calls = 0.0, 0
658
+ if task_id is not None:
659
+ r = con.execute("SELECT COALESCE(SUM(cost_usd), 0), COUNT(*) FROM spend "
660
+ "WHERE task_id=?", (task_id,)).fetchone()
661
+ tspent, calls = float(r[0]), int(r[1])
662
+ scope = DAILY if dspent >= daily_cap else (
663
+ TASK if task_id is not None and tspent >= task_cap else None)
664
+ if scope is not None:
665
+ con.execute("ROLLBACK")
666
+ return scope, dspent, tspent, calls, None
667
+ cur = con.execute(
668
+ "INSERT INTO spend (ts, day, task_id, surface, provider, model, input_tokens, "
669
+ "output_tokens, cost_usd, estimated, status) VALUES (?,?,?,?,?,?,0,0,?,1,'reserved')",
670
+ (time.time(), day, task_id, surface, provider, model, float(cost_usd)))
671
+ con.execute("COMMIT")
672
+ return None, dspent, tspent, calls, cur.lastrowid
673
+ except BaseException:
674
+ try:
675
+ con.execute("ROLLBACK")
676
+ except sqlite3.Error:
677
+ pass
678
+ raise
679
+ finally:
680
+ con.close()
681
+
682
+ def settle(self, reservation: int, *, input_tokens: int, output_tokens: int,
683
+ cost_usd: float, estimated: bool) -> None:
684
+ con = self._connect()
685
+ try:
686
+ con.execute("UPDATE spend SET input_tokens=?, output_tokens=?, cost_usd=?, "
687
+ "estimated=?, status='settled' WHERE id=?",
688
+ (int(input_tokens), int(output_tokens), float(cost_usd),
689
+ 1 if estimated else 0, reservation))
690
+ finally:
691
+ con.close()
692
+
693
+ def release(self, reservation: int) -> None:
694
+ con = self._connect()
695
+ try:
696
+ con.execute("DELETE FROM spend WHERE id=? AND status='reserved'", (reservation,))
697
+ finally:
698
+ con.close()
699
+
700
+ def day_total(self, day: Optional[str] = None) -> float:
701
+ con = self._connect()
702
+ try:
703
+ r = con.execute("SELECT COALESCE(SUM(cost_usd), 0) FROM spend WHERE day=?",
704
+ (day or today(),)).fetchone()
705
+ return float(r[0])
706
+ finally:
707
+ con.close()
708
+
709
+ def task_total(self, task_id: str) -> Tuple[float, int]:
710
+ """(spend, model calls) recorded for this task, all days."""
711
+ con = self._connect()
712
+ try:
713
+ r = con.execute("SELECT COALESCE(SUM(cost_usd), 0), COUNT(*) FROM spend "
714
+ "WHERE task_id=?", (task_id,)).fetchone()
715
+ return float(r[0]), int(r[1])
716
+ finally:
717
+ con.close()
718
+
719
+ def rows(self, day: Optional[str] = None) -> List[Dict[str, Any]]:
720
+ con = self._connect()
721
+ try:
722
+ return [dict(r) for r in con.execute(
723
+ "SELECT * FROM spend WHERE day=? ORDER BY id", (day or today(),)).fetchall()]
724
+ finally:
725
+ con.close()
726
+
727
+ # ── owner notices (unattended / guest stops) ──
728
+ def add_notice(self, text: str, dedupe: Optional[str]) -> bool:
729
+ con = self._connect()
730
+ try:
731
+ con.execute("INSERT INTO owner_notices (ts, day, dedupe, text) VALUES (?,?,?,?)",
732
+ (time.time(), today(), dedupe, text))
733
+ return True
734
+ except sqlite3.IntegrityError:
735
+ return False # already notified for this dedupe key
736
+ finally:
737
+ con.close()
738
+
739
+ def take_notices(self) -> List[str]:
740
+ con = self._connect()
741
+ try:
742
+ con.execute("BEGIN IMMEDIATE")
743
+ rows = con.execute("SELECT id, text FROM owner_notices WHERE delivered=0 "
744
+ "ORDER BY id").fetchall()
745
+ for r in rows:
746
+ con.execute("UPDATE owner_notices SET delivered=1 WHERE id=?", (r["id"],))
747
+ con.execute("COMMIT")
748
+ return [r["text"] for r in rows]
749
+ except Exception:
750
+ try:
751
+ con.execute("ROLLBACK")
752
+ except sqlite3.Error:
753
+ pass
754
+ raise
755
+ finally:
756
+ con.close()
757
+
758
+
759
+ _STORES: Dict[str, CostStore] = {}
760
+ _STORES_LOCK = threading.Lock()
761
+
762
+
763
+ def get_store() -> CostStore:
764
+ """The store for the current HYPERCLAW_ROOT / HYPERCLAW_COST_DB (read at call time)."""
765
+ p = str(default_path())
766
+ with _STORES_LOCK:
767
+ if p not in _STORES:
768
+ _STORES[p] = CostStore(p)
769
+ return _STORES[p]
770
+
771
+
772
+ # ───────────────────────────── tasks ─────────────────────────────
773
+
774
+ @dataclass
775
+ class Task:
776
+ """One unit of work under the per-task cap: a user message with all its
777
+ model rounds and tool calls, or one scheduled job."""
778
+ id: str
779
+ surface: str
780
+ request: str = ""
781
+ unattended: bool = False
782
+ owner: bool = True
783
+ conversation: str = ""
784
+ evidence: Any = None # the turn's tools.guard.TurnGuard (its .results)
785
+ stopped: Optional["CostCapReached"] = None
786
+ started: float = field(default_factory=time.time)
787
+ # Restricted-client guard (hyperclaw.client_guard): the providers this
788
+ # task's model calls went to, and what was withheld from them per source.
789
+ model_providers: set = field(default_factory=set)
790
+ withheld: Dict[str, int] = field(default_factory=dict)
791
+
792
+
793
+ _CURRENT: contextvars.ContextVar = contextvars.ContextVar("hyperclaw_cost_task", default=None)
794
+
795
+
796
+ def current_task() -> Optional[Task]:
797
+ return _CURRENT.get()
798
+
799
+
800
+ @contextlib.contextmanager
801
+ def task(*, surface: str, request: str = "", unattended: bool = False, owner: bool = True,
802
+ conversation: str = "", evidence: Any = None, task_id: Optional[str] = None):
803
+ """Run the body as one task. Inside an existing task the existing one is
804
+ reused (a sub-agent or nested call belongs to the task that started it).
805
+ A fixed ``task_id`` (e.g. a scheduled job's id) keeps its spend across
806
+ restarts."""
807
+ existing = _CURRENT.get()
808
+ if existing is not None:
809
+ if evidence is not None and existing.evidence is None:
810
+ existing.evidence = evidence
811
+ yield existing
812
+ return
813
+ t = Task(id=task_id or uuid.uuid4().hex[:16], surface=surface, request=request or "",
814
+ unattended=unattended, owner=owner, conversation=conversation, evidence=evidence)
815
+ _CURRENT.set(t)
816
+ try:
817
+ yield t
818
+ finally:
819
+ _CURRENT.set(None)
820
+
821
+
822
+ # ───────────────────────────── the stop ─────────────────────────────
823
+
824
+ def usd(x: float) -> str:
825
+ """$1.23; amounts under a cent keep their significant digits ($0.00063)."""
826
+ x = float(x or 0)
827
+ if x == 0 or abs(x) >= 0.01:
828
+ return f"${x:,.2f}"
829
+ s = f"{x:.6f}".rstrip("0")
830
+ return "$" + (s if len(s.split(".")[1]) >= 2 else f"{x:.2f}")
831
+
832
+
833
+ class CostCapReached(Exception):
834
+ """Raised by the guard instead of making a model call at the cap. Its
835
+ text is safe to show the owner (no internal detail)."""
836
+
837
+ def __init__(self, scope: str, *, task: Optional[Task], task_spent: float, task_cap: float,
838
+ day_spent: float, daily_cap: float, calls: int):
839
+ self.scope = scope
840
+ self.task = task
841
+ self.task_spent = task_spent
842
+ self.task_cap = task_cap
843
+ self.day_spent = day_spent
844
+ self.daily_cap = daily_cap
845
+ self.calls = calls
846
+ self.owner_notified = False # True when this stop sent the owner a new notice
847
+ super().__init__(self.headline())
848
+
849
+ def headline(self) -> str:
850
+ if self.scope == LEDGER:
851
+ return ("Stopped: HyperClaw can't read its cost ledger, so it can't check spend "
852
+ "against the cap. No model calls run until the ledger can be read again. "
853
+ "The technical detail is in the HyperClaw log.")
854
+ if self.scope == DAILY:
855
+ return (f"Stopped at today's cost cap: {usd(self.day_spent)} spent of the "
856
+ f"{usd(self.daily_cap)} daily cap. No further model calls run today until "
857
+ f"the cap is raised.")
858
+ return (f"Stopped at the per-task cost cap: {usd(self.task_spent)} spent of the "
859
+ f"{usd(self.task_cap)} cap for one task. No further model calls run for this task.")
860
+
861
+
862
+ _UNTASKED_STOPS: set = set()
863
+ _UNTASKED_LOCK = threading.Lock()
864
+
865
+
866
+ def _activity(tool: str, target: str, status: str, message: str, conversation: str = "",
867
+ surface: str = "", turn: str = "") -> None:
868
+ """One row in the activity log (tools/ledger.py, spec rule 6)."""
869
+ try:
870
+ from hyperclaw.tools.ledger import get_ledger
871
+ get_ledger().record(conversation=conversation or "cost_guard", turn=turn or None,
872
+ surface=surface or "cost_guard", tool=tool, effect="system",
873
+ target=target, key=None, status=status, message=message)
874
+ except Exception as e:
875
+ log.warning("cost guard: could not write the activity log: %s", e)
876
+
877
+
878
+ def _stop(scope: str, t: Optional[Task], tspent: float, tcap: float, dspent: float,
879
+ dcap: float, calls: int) -> CostCapReached:
880
+ exc = CostCapReached(scope, task=t, task_spent=tspent, task_cap=tcap, day_spent=dspent,
881
+ daily_cap=dcap, calls=calls)
882
+ if t is not None:
883
+ t.stopped = exc
884
+ _activity("cost_guard", f"cost_cap:{scope}", "budget_exhausted",
885
+ exc.headline() + f" Task {t.id} ({t.surface}).",
886
+ conversation=t.conversation, surface=t.surface, turn=t.id)
887
+ if t.unattended or not t.owner:
888
+ try:
889
+ exc.owner_notified = notify_owner(owner_notice(exc), dedupe=f"{today()}:{scope}:"
890
+ + (t.id if scope == TASK else "day"))
891
+ except Exception as e: # the ledger itself may be what failed
892
+ log.error("cost guard: could not queue the owner notice: %s", e)
893
+ else:
894
+ key = (today(), scope)
895
+ with _UNTASKED_LOCK:
896
+ first = key not in _UNTASKED_STOPS
897
+ _UNTASKED_STOPS.add(key)
898
+ if first:
899
+ _activity("cost_guard", f"cost_cap:{scope}", "budget_exhausted", exc.headline())
900
+ log.info("cost guard: %s", exc.headline())
901
+ return exc
902
+
903
+
904
+ def _ledger_stop(t: Optional[Task], err: Exception) -> CostCapReached:
905
+ log.error("cost guard: the cost ledger can't be read: %s: %s", type(err).__name__, err)
906
+ return _stop(LEDGER, t, 0.0, task_cap(), 0.0, daily_cap(), 0)
907
+
908
+
909
+ def preflight(provider: str, model: str) -> None:
910
+ """Check only (no reservation). Raises CostCapReached at the cap. The
911
+ model-call adapters use ``reserve``, which checks and reserves atomically."""
912
+ t = _CURRENT.get()
913
+ if t is not None and t.stopped is not None:
914
+ raise t.stopped
915
+ store = get_store()
916
+ try:
917
+ dspent, dcap, tcap = store.day_total(), daily_cap(), task_cap()
918
+ tspent, calls = store.task_total(t.id) if t is not None else (0.0, 0)
919
+ except sqlite3.DatabaseError as e:
920
+ raise _ledger_stop(t, e) from None
921
+ if dspent >= dcap:
922
+ raise _stop(DAILY, t, tspent, tcap, dspent, dcap, calls)
923
+ if t is not None and tspent >= tcap:
924
+ raise _stop(TASK, t, tspent, tcap, dspent, dcap, calls)
925
+
926
+
927
+ def reserve_estimate(provider: str, model: str, request: Any = None,
928
+ max_output_tokens: Optional[int] = None, reasoning: bool = True) -> float:
929
+ """The amount held for one call while it is in flight: its estimated
930
+ input plus its output limit — the call's own max when it sets one, else
931
+ the model's real output maximum (``max_output_for``) — plus the
932
+ provider's reasoning allowance when thinking is on for the call
933
+ (HyperSpeed V2: 8192)."""
934
+ out = _pos_int(max_output_tokens) or unset_max_fallback(provider, model)
935
+ if reasoning:
936
+ out += reasoning_tokens_for(provider, model)
937
+ return cost_of(provider, model, estimate_tokens(request) if request else 0, out)
938
+
939
+
940
+ def unset_max_fallback(provider: str, model: str) -> int:
941
+ """The reservation for a call that set NO max output (!32): the model's
942
+ real output maximum. Kept as a safety net only; every shipped path sets
943
+ an explicit max (``max_output``), so this is unreachable from them. With
944
+ HYPERCLAW_REQUIRE_MAX_OUTPUT on (the test suite) it raises instead, so a
945
+ path that forgets its max fails its test."""
946
+ if (os.environ.get(REQUIRE_MAX_ENV) or "").strip().lower() in ("1", "true", "yes", "on"):
947
+ raise MissingMaxOutput(
948
+ f"model call without an explicit max output (provider {provider!r}, model "
949
+ f"{model!r}): every path must pass cost_guard.max_output(<path>, ...)")
950
+ return max_output_for(provider, model)
951
+
952
+
953
+ def reserve(provider: str, model: str, request: Any = None,
954
+ max_output_tokens: Optional[int] = None, reasoning: bool = True) -> Optional[int]:
955
+ """Called before every model request: checks the caps and reserves the
956
+ call's estimated cost in one transaction, so concurrent calls can't all
957
+ pass the check before any of them records. Raises CostCapReached at the
958
+ cap (the request is NOT made: no cheaper model, no retry). Returns the
959
+ reservation id for ``record`` / ``release``."""
960
+ t = _CURRENT.get()
961
+ if t is not None and t.stopped is not None:
962
+ raise t.stopped
963
+ # Restricted-client guard: the last check on the outgoing request body
964
+ # (raises RestrictedContentBlocked; nothing is sent), and the record of
965
+ # which provider this task's model calls go to.
966
+ from hyperclaw import client_guard
967
+ client_guard.note_model(provider)
968
+ client_guard.final_check(provider, request)
969
+ dcap, tcap = daily_cap(), task_cap()
970
+ est = reserve_estimate(provider, model, request, max_output_tokens, reasoning)
971
+ try:
972
+ scope, dspent, tspent, calls, rid = get_store().reserve(
973
+ task_id=t.id if t else None, surface=t.surface if t else "",
974
+ provider=provider or "", model=model or "", cost_usd=est,
975
+ daily_cap=dcap, task_cap=tcap)
976
+ except sqlite3.DatabaseError as e:
977
+ raise _ledger_stop(t, e) from None
978
+ if scope is not None:
979
+ raise _stop(scope, t, tspent, tcap, dspent, dcap, calls)
980
+ return rid
981
+
982
+
983
+ def release(reservation: Optional[int]) -> None:
984
+ """A call that failed before producing anything: drop its reservation.
985
+ If that fails, the reserved amount stays counted (conservative)."""
986
+ if reservation is None:
987
+ return
988
+ try:
989
+ get_store().release(reservation)
990
+ except Exception as e:
991
+ log.error("cost guard: could not release reservation %s (it stays counted): %s",
992
+ reservation, e)
993
+
994
+
995
+ def record(provider: str, model: str, input_tokens: int, output_tokens: int,
996
+ estimated: bool = False, reservation: Optional[int] = None) -> float:
997
+ """Persist one model call's spend against today and the current task:
998
+ settles its reservation, or adds a row when there is none. If the write
999
+ fails, a reservation keeps its reserved amount counted (never dropped)."""
1000
+ cost = cost_of(provider, model, input_tokens, output_tokens)
1001
+ t = _CURRENT.get()
1002
+ try:
1003
+ if reservation is not None:
1004
+ get_store().settle(reservation, input_tokens=input_tokens,
1005
+ output_tokens=output_tokens, cost_usd=cost, estimated=estimated)
1006
+ else:
1007
+ get_store().add(task_id=t.id if t else None, surface=t.surface if t else "",
1008
+ provider=provider or "", model=model or "",
1009
+ input_tokens=input_tokens, output_tokens=output_tokens,
1010
+ cost_usd=cost, estimated=estimated)
1011
+ except Exception as e:
1012
+ if reservation is not None:
1013
+ log.error("cost guard: could not settle spend of %s (reservation %s stays counted "
1014
+ "at its reserved amount): %s", usd(cost), reservation, e)
1015
+ else:
1016
+ log.error("cost guard: could not record spend of %s: %s", usd(cost), e)
1017
+ return cost
1018
+
1019
+
1020
+ def status(t: Optional[Task] = None) -> Dict[str, Any]:
1021
+ store = get_store()
1022
+ tspent, calls = store.task_total(t.id) if t is not None else (0.0, 0)
1023
+ return {"day": today(), "day_spent_usd": store.day_total(), "daily_cap_usd": daily_cap(),
1024
+ "task_cap_usd": task_cap(), "task_spent_usd": tspent, "task_calls": calls,
1025
+ "spend_line_min_usd": spend_line_min()}
1026
+
1027
+
1028
+ # ───────────────────────────── raising a cap ─────────────────────────────
1029
+
1030
+ def raise_cap(scope: str, amount_usd: float, *, by: str, surface: str = "") -> float:
1031
+ """Set the daily or per-task cap. This is the ONLY way a cap changes, and
1032
+ it is the user's explicit action (CLI ``hyperclaw cost raise`` or
1033
+ ``/cost raise`` in the terminal): written to config.json and to the
1034
+ activity log. The model has no tool for it."""
1035
+ if scope not in (DAILY, TASK):
1036
+ raise ValueError("scope must be 'daily' or 'task'")
1037
+ v = _num(amount_usd)
1038
+ if v is None:
1039
+ raise ValueError("amount must be a non-negative number of US dollars")
1040
+ old = daily_cap() if scope == DAILY else task_cap()
1041
+ from hyperclaw.userconfig import load_config, update_config
1042
+ cg = dict(load_config().get("cost_guard") or {})
1043
+ cg["daily_cap_usd" if scope == DAILY else "task_cap_usd"] = v
1044
+ update_config(cost_guard=cg)
1045
+ _activity("cost_cap", f"cost_cap:{scope}", "ok",
1046
+ f"{scope} cost cap {usd(old)} -> {usd(v)} by {by}", surface=surface or by)
1047
+ if scope == DAILY:
1048
+ with _UNTASKED_LOCK:
1049
+ _UNTASKED_STOPS.discard((today(), DAILY))
1050
+ return v
1051
+
1052
+
1053
+ # ───────────────────────────── messages ─────────────────────────────
1054
+
1055
+ def _evidence_lines(t: Optional[Task]) -> List[str]:
1056
+ res = list(getattr(getattr(t, "evidence", None), "results", None) or [])
1057
+ lines = []
1058
+ for r in res[-12:]:
1059
+ s = f"- {r.get('tool')}: {r.get('status')}"
1060
+ if r.get("target"):
1061
+ s += f" ({r['target']})"
1062
+ if r.get("receipt_id"):
1063
+ s += f", receipt {r['receipt_id']}"
1064
+ lines.append(s)
1065
+ if len(res) > 12:
1066
+ lines.insert(0, f"- ({len(res) - 12} earlier tool results not shown)")
1067
+ return lines
1068
+
1069
+
1070
+ def _clip(s: str, n: int = 160) -> str:
1071
+ s = " ".join((s or "").split())
1072
+ return s if len(s) <= n else s[:n - 1] + "…"
1073
+
1074
+
1075
+ def stop_message(exc: CostCapReached, *, owner: bool = True) -> str:
1076
+ """The stop-and-ask message. Built only from records: the spend table
1077
+ and the turn's tool results (each with its real status). It claims no
1078
+ action that has no record."""
1079
+ if not owner:
1080
+ return ("I can't continue with this request right now. The owner of this assistant "
1081
+ "has been notified.")
1082
+ t = exc.task
1083
+ if exc.scope == LEDGER:
1084
+ req = f" \"{_clip(t.request)}\"" if t is not None and t.request else " This task"
1085
+ return "\n".join([exc.headline(), "",
1086
+ f"Not finished:{req} has no final answer yet.",
1087
+ "Run `hyperclaw doctor` to check the ledger, then reply \"continue\"."])
1088
+ tspent, calls = (get_store().task_total(t.id) if t is not None else (exc.task_spent, exc.calls))
1089
+ dspent = get_store().day_total()
1090
+ parts = [exc.headline(), ""]
1091
+ parts.append(f"Spend: {usd(tspent)} on this task ({calls} model call"
1092
+ f"{'' if calls == 1 else 's'}; per-task cap {usd(exc.task_cap)}) · "
1093
+ f"{usd(dspent)} today (daily cap {usd(exc.daily_cap)}).")
1094
+ ev = _evidence_lines(t)
1095
+ if ev:
1096
+ parts.append("Tool results recorded for this task (status as returned):")
1097
+ parts.extend(ev)
1098
+ else:
1099
+ parts.append("No tool actions were recorded for this task.")
1100
+ if t is not None and t.request:
1101
+ parts.append(f"Not finished: \"{_clip(t.request)}\" has no final answer yet.")
1102
+ else:
1103
+ parts.append("Not finished: this task has no final answer yet.")
1104
+ parts.append("")
1105
+ if exc.scope == TASK:
1106
+ parts.append(f"Continue? Reply \"continue\" to give it another task budget of "
1107
+ f"{usd(exc.task_cap)}, or raise the per-task cap first with "
1108
+ f"`hyperclaw cost raise --task <USD>` (terminal: /cost raise task <USD>).")
1109
+ else:
1110
+ parts.append("Continue? Raising today's cap is your call: run "
1111
+ "`hyperclaw cost raise --daily <USD>` (terminal: /cost raise daily <USD>), "
1112
+ "then reply \"continue\". The change is recorded in the activity log.")
1113
+ return "\n".join(parts)
1114
+
1115
+
1116
+ def owner_notice(exc: CostCapReached) -> str:
1117
+ t = exc.task
1118
+ who = "a scheduled job" if (t is not None and t.unattended) else "a guest chat"
1119
+ what = f" (\"{_clip(t.request, 80)}\")" if (t is not None and t.request and t.unattended) else ""
1120
+ return (f"HyperClaw cost guard: {who}{what} was stopped. {exc.headline()} "
1121
+ f"Raise a cap with `hyperclaw cost raise --daily <USD>` or "
1122
+ f"`--task <USD>`; nothing else changes until you do.")
1123
+
1124
+
1125
+ def unattended_message(exc: CostCapReached) -> str:
1126
+ """What an unattended (scheduled) job returns at the cap. It goes only to
1127
+ the owner's chat (scheduler output is owner-only). Once the owner has
1128
+ been told about this cap today, later stopped jobs return "" (nothing to
1129
+ send; the scheduler logs it) so a 10-minute heartbeat does not repeat it."""
1130
+ return owner_notice(exc) if exc.owner_notified else ""
1131
+
1132
+
1133
+ _NOTIFIERS: List[Any] = []
1134
+
1135
+
1136
+ def register_owner_notifier(fn) -> None:
1137
+ """fn(text) -> None, called (best effort) when an unattended or guest
1138
+ task is stopped. Every notice is also queued for the owner's next turn."""
1139
+ if fn not in _NOTIFIERS:
1140
+ _NOTIFIERS.append(fn)
1141
+
1142
+
1143
+ def notify_owner(text: str, dedupe: Optional[str] = None) -> bool:
1144
+ """Queue (and push, if a notifier is registered) a notice for the owner.
1145
+ False when this dedupe key was already notified."""
1146
+ try:
1147
+ fresh = get_store().add_notice(text, dedupe)
1148
+ except Exception as e:
1149
+ log.warning("cost guard: could not queue the owner notice: %s", e)
1150
+ fresh = True
1151
+ if not fresh:
1152
+ return False
1153
+ for fn in list(_NOTIFIERS):
1154
+ try:
1155
+ fn(text)
1156
+ except Exception as e:
1157
+ log.warning("cost guard: owner notifier failed: %s", e)
1158
+ return True
1159
+
1160
+
1161
+ def take_owner_notices() -> List[str]:
1162
+ """Pending notices for the owner (shown once, on the owner's next turn)."""
1163
+ try:
1164
+ return get_store().take_notices()
1165
+ except Exception:
1166
+ return []
1167
+
1168
+
1169
+ def spend_line(t: Optional[Task]) -> str:
1170
+ """A short spend line for an answer, or "" below the threshold."""
1171
+ if t is None:
1172
+ return ""
1173
+ try:
1174
+ tspent, _ = get_store().task_total(t.id)
1175
+ if tspent < spend_line_min() or tspent <= 0:
1176
+ return ""
1177
+ return (f"Spend: {usd(tspent)} this task · {usd(get_store().day_total())} of "
1178
+ f"{usd(daily_cap())} today")
1179
+ except Exception:
1180
+ return ""
1181
+
1182
+
1183
+ # ───────────────────────────── metering adapters ─────────────────────────────
1184
+
1185
+ def _usage_numbers(u) -> Optional[Tuple[int, int]]:
1186
+ """(input, output) tokens from an Anthropic / OpenAI / Gemini usage object or dict."""
1187
+ if u is None:
1188
+ return None
1189
+ g = (lambda k: u.get(k)) if isinstance(u, dict) else (lambda k: getattr(u, k, None))
1190
+ def n(k):
1191
+ v = g(k)
1192
+ return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
1193
+ i = n("input_tokens")
1194
+ o = n("output_tokens")
1195
+ if i is not None or o is not None:
1196
+ i = (i or 0) + (n("cache_creation_input_tokens") or 0) + (n("cache_read_input_tokens") or 0)
1197
+ return i, o or 0
1198
+ i, o = n("prompt_tokens"), n("completion_tokens")
1199
+ if i is not None or o is not None:
1200
+ return i or 0, o or 0
1201
+ i, o = n("promptTokenCount"), n("candidatesTokenCount")
1202
+ if i is not None or o is not None:
1203
+ return i or 0, o or 0
1204
+ return None
1205
+
1206
+
1207
+ class Meter:
1208
+ """Accumulates one call's usage; commit() records it once."""
1209
+
1210
+ def __init__(self, provider: str, model: str, request: Any = None,
1211
+ reservation: Optional[int] = None):
1212
+ self.provider, self.model, self.request = provider or "", model or "", request
1213
+ self.reservation = reservation
1214
+ self.input_tokens: Optional[int] = None
1215
+ self.output_tokens: Optional[int] = None
1216
+ self.text: List[str] = []
1217
+ self.committed = False
1218
+
1219
+ def usage(self, u) -> None:
1220
+ nums = _usage_numbers(u)
1221
+ if nums is None:
1222
+ return
1223
+ i, o = nums
1224
+ if i:
1225
+ self.input_tokens = max(self.input_tokens or 0, i)
1226
+ if o or self.output_tokens is None:
1227
+ self.output_tokens = max(self.output_tokens or 0, o)
1228
+
1229
+ def usage_from(self, data) -> None:
1230
+ """Usage from a raw JSON response (Anthropic / OpenAI / Gemini shapes)."""
1231
+ if isinstance(data, dict):
1232
+ self.usage(data.get("usage") or data.get("usageMetadata"))
1233
+
1234
+ def add_text(self, s) -> None:
1235
+ if isinstance(s, str) and s:
1236
+ self.text.append(s)
1237
+
1238
+ def commit(self, failed: bool = False) -> float:
1239
+ if self.committed:
1240
+ return 0.0
1241
+ self.committed = True
1242
+ if self.input_tokens is None and self.output_tokens is None:
1243
+ if failed and not self.text:
1244
+ release(self.reservation)
1245
+ return 0.0 # the request failed before producing anything
1246
+ i = estimate_tokens(self.request)
1247
+ o = estimate_tokens("".join(self.text)) if self.text else 0
1248
+ return record(self.provider, self.model, i, o, estimated=True,
1249
+ reservation=self.reservation)
1250
+ i = self.input_tokens if self.input_tokens is not None else estimate_tokens(self.request)
1251
+ o = self.output_tokens if self.output_tokens is not None else (
1252
+ estimate_tokens("".join(self.text)) if self.text else 0)
1253
+ return record(self.provider, self.model, i, o,
1254
+ estimated=self.input_tokens is None or self.output_tokens is None,
1255
+ reservation=self.reservation)
1256
+
1257
+
1258
+ @contextlib.contextmanager
1259
+ def metered(provider: str, model: str, request: Any = None,
1260
+ max_output_tokens: Optional[int] = None, reasoning: Optional[bool] = None):
1261
+ """For raw-HTTP model calls: preflight, then record the usage the body
1262
+ reports via ``m.usage_from(json)`` / ``m.usage(...)`` (estimated from the
1263
+ request and ``m.add_text`` output when the provider reports none). The
1264
+ output limit is ``max_output_tokens``, else the request body's own
1265
+ max_tokens / max_completion_tokens, else the model's real maximum.
1266
+ ``reasoning`` (default: from the request body, see ``thinking_on``) adds
1267
+ the provider's reasoning allowance."""
1268
+ if max_output_tokens is None:
1269
+ max_output_tokens = _request_max(request)
1270
+ if reasoning is None:
1271
+ reasoning = thinking_on(request)
1272
+ rid = reserve(provider, model, request, max_output_tokens, reasoning)
1273
+ m = Meter(provider, model, request, reservation=rid)
1274
+ try:
1275
+ yield m
1276
+ except BaseException:
1277
+ m.commit(failed=True)
1278
+ raise
1279
+ else:
1280
+ m.commit()
1281
+
1282
+
1283
+ def _request_max(req: Any) -> Optional[int]:
1284
+ """The max output a request sets (max_tokens / max_completion_tokens /
1285
+ max_output_tokens), or None."""
1286
+ if not isinstance(req, dict):
1287
+ return None
1288
+ for k in ("max_tokens", "max_completion_tokens", "max_output_tokens"):
1289
+ n = _pos_int(req.get(k))
1290
+ if n is not None:
1291
+ return n
1292
+ return None
1293
+
1294
+
1295
+ def _request_of(kw: dict) -> dict:
1296
+ return {k: kw.get(k) for k in ("system", "messages", "tools", "prompt", "input",
1297
+ "instructions") if kw.get(k)}
1298
+
1299
+
1300
+ def _observe_event(m: Meter, ev) -> None:
1301
+ """Usage/text from one streamed event: Anthropic raw events or OpenAI chunks."""
1302
+ try:
1303
+ typ = getattr(ev, "type", None)
1304
+ if typ == "message_start":
1305
+ m.usage(getattr(getattr(ev, "message", None), "usage", None))
1306
+ elif typ == "message_delta":
1307
+ m.usage(getattr(ev, "usage", None))
1308
+ elif typ == "content_block_delta":
1309
+ d = getattr(ev, "delta", None)
1310
+ m.add_text(getattr(d, "text", None) or getattr(d, "thinking", None))
1311
+ else:
1312
+ u = getattr(ev, "usage", None)
1313
+ if u is not None:
1314
+ m.usage(u)
1315
+ for ch in (getattr(ev, "choices", None) or []):
1316
+ d = getattr(ch, "delta", None)
1317
+ for k in ("content", "reasoning_content", "reasoning"):
1318
+ m.add_text(getattr(d, k, None))
1319
+ tcs = getattr(d, "tool_calls", None) or []
1320
+ for tc in tcs:
1321
+ fn = getattr(tc, "function", None)
1322
+ m.add_text(getattr(fn, "arguments", None))
1323
+ except Exception:
1324
+ pass
1325
+
1326
+
1327
+ class _SyncStream:
1328
+ """A streamed response (create(stream=True)); usage recorded when it ends."""
1329
+
1330
+ def __init__(self, inner, meter: Meter):
1331
+ self._inner, self._m = inner, meter
1332
+
1333
+ def __iter__(self):
1334
+ try:
1335
+ for ev in self._inner:
1336
+ _observe_event(self._m, ev)
1337
+ yield ev
1338
+ finally:
1339
+ self._m.commit()
1340
+
1341
+ def __enter__(self):
1342
+ return self
1343
+
1344
+ def __exit__(self, *a):
1345
+ self._m.commit()
1346
+ close = getattr(self._inner, "close", None)
1347
+ if callable(close):
1348
+ close()
1349
+ return False
1350
+
1351
+ def close(self):
1352
+ self._m.commit()
1353
+ close = getattr(self._inner, "close", None)
1354
+ if callable(close):
1355
+ close()
1356
+
1357
+ def __getattr__(self, name):
1358
+ return getattr(self._inner, name)
1359
+
1360
+ def __del__(self):
1361
+ try:
1362
+ self._m.commit()
1363
+ except Exception:
1364
+ pass
1365
+
1366
+
1367
+ class _AsyncStream:
1368
+ def __init__(self, inner, meter: Meter):
1369
+ self._inner, self._m = inner, meter
1370
+
1371
+ async def __aiter__(self):
1372
+ try:
1373
+ async for ev in self._inner:
1374
+ _observe_event(self._m, ev)
1375
+ yield ev
1376
+ finally:
1377
+ self._m.commit()
1378
+
1379
+ async def __aenter__(self):
1380
+ return self
1381
+
1382
+ async def __aexit__(self, *a):
1383
+ self._m.commit()
1384
+ close = getattr(self._inner, "close", None)
1385
+ if callable(close):
1386
+ r = close()
1387
+ if inspect.isawaitable(r):
1388
+ await r
1389
+ return False
1390
+
1391
+ async def close(self):
1392
+ self._m.commit()
1393
+ close = getattr(self._inner, "close", None)
1394
+ if callable(close):
1395
+ r = close()
1396
+ if inspect.isawaitable(r):
1397
+ await r
1398
+
1399
+ def __getattr__(self, name):
1400
+ return getattr(self._inner, name)
1401
+
1402
+ def __del__(self):
1403
+ try:
1404
+ self._m.commit()
1405
+ except Exception:
1406
+ pass
1407
+
1408
+
1409
+ def _usage_from_stream_obj(m: Meter, s) -> None:
1410
+ """Usage from an Anthropic MessageStream after (or during) consumption."""
1411
+ try:
1412
+ snap = getattr(s, "current_message_snapshot", None)
1413
+ except Exception:
1414
+ snap = None
1415
+ if snap is not None:
1416
+ m.usage(getattr(snap, "usage", None))
1417
+ try:
1418
+ for b in getattr(snap, "content", None) or []:
1419
+ m.add_text(getattr(b, "text", None))
1420
+ except Exception:
1421
+ pass
1422
+
1423
+
1424
+ class _StreamManager:
1425
+ """messages.stream(...) wrapper: the request runs on enter; usage is
1426
+ recorded on exit from the stream's final snapshot."""
1427
+
1428
+ def __init__(self, inner, meter: Meter):
1429
+ self._inner, self._m, self._s = inner, meter, None
1430
+
1431
+ def __enter__(self):
1432
+ try:
1433
+ self._s = self._inner.__enter__()
1434
+ except BaseException:
1435
+ self._m.commit(failed=True)
1436
+ raise
1437
+ return self._s
1438
+
1439
+ def __exit__(self, et, ev, tb):
1440
+ try:
1441
+ _usage_from_stream_obj(self._m, self._s)
1442
+ finally:
1443
+ self._m.commit(failed=et is not None)
1444
+ return self._inner.__exit__(et, ev, tb)
1445
+
1446
+ async def __aenter__(self):
1447
+ try:
1448
+ self._s = await self._inner.__aenter__()
1449
+ except BaseException:
1450
+ self._m.commit(failed=True)
1451
+ raise
1452
+ return self._s
1453
+
1454
+ async def __aexit__(self, et, ev, tb):
1455
+ try:
1456
+ _usage_from_stream_obj(self._m, self._s)
1457
+ finally:
1458
+ self._m.commit(failed=et is not None)
1459
+ return await self._inner.__aexit__(et, ev, tb)
1460
+
1461
+ def __getattr__(self, name):
1462
+ return getattr(self._inner, name)
1463
+
1464
+
1465
+ def _finish(meter: Meter, res, streamed: bool, is_async: bool):
1466
+ if streamed:
1467
+ return _AsyncStream(res, meter) if is_async else _SyncStream(res, meter)
1468
+ meter.usage(getattr(res, "usage", None) if not isinstance(res, dict) else res.get("usage"))
1469
+ if meter.input_tokens is None and meter.output_tokens is None:
1470
+ try:
1471
+ from hyperclaw.api_utils import extract_text
1472
+ meter.add_text(extract_text(res))
1473
+ for ch in getattr(res, "choices", None) or []:
1474
+ meter.add_text(getattr(getattr(ch, "message", None), "content", None))
1475
+ except Exception:
1476
+ pass
1477
+ meter.commit()
1478
+ return res
1479
+
1480
+
1481
+ def _guard_method(inner_method, provider: str, kind: str):
1482
+ """Wrap one SDK method (create / stream). kind: "create" | "stream"."""
1483
+
1484
+ @functools.wraps(inner_method)
1485
+ def wrapper(*a, **kw):
1486
+ model = str(kw.get("model") or "")
1487
+ from hyperclaw import client_guard # outside models: restricted content withheld
1488
+ kw = client_guard.redact_request(provider, kw)
1489
+ req = _request_of(kw)
1490
+ rid = reserve(provider, model, req, _request_max(kw), thinking_on(kw))
1491
+ meter = Meter(provider, model, req, reservation=rid)
1492
+ try:
1493
+ res = inner_method(*a, **kw)
1494
+ except BaseException:
1495
+ meter.commit(failed=True)
1496
+ raise
1497
+ if kind == "stream":
1498
+ return _StreamManager(res, meter)
1499
+ streamed = bool(kw.get("stream"))
1500
+ if inspect.isawaitable(res):
1501
+ async def _await():
1502
+ try:
1503
+ r = await res
1504
+ except BaseException:
1505
+ meter.commit(failed=True)
1506
+ raise
1507
+ return _finish(meter, r, streamed, True)
1508
+ return _await()
1509
+ return _finish(meter, res, streamed, False)
1510
+
1511
+ wrapper.__cost_guarded__ = True
1512
+ return wrapper
1513
+
1514
+
1515
+ class _Namespace:
1516
+ """Proxy for client.messages / client.chat.completions / client.beta.messages."""
1517
+
1518
+ def __init__(self, inner, provider: str, methods: Dict[str, str], children=None):
1519
+ object.__setattr__(self, "_inner", inner)
1520
+ object.__setattr__(self, "_provider", provider)
1521
+ object.__setattr__(self, "_methods", methods)
1522
+ object.__setattr__(self, "_children", children or {})
1523
+
1524
+ def __getattr__(self, name):
1525
+ inner_attr = getattr(self._inner, name)
1526
+ if name in self._methods:
1527
+ return _guard_method(inner_attr, self._provider, self._methods[name])
1528
+ if name in self._children:
1529
+ methods, children = self._children[name]
1530
+ return _Namespace(inner_attr, self._provider, methods, children)
1531
+ return inner_attr
1532
+
1533
+ def __setattr__(self, name, value):
1534
+ setattr(self._inner, name, value)
1535
+
1536
+
1537
+ _MESSAGES = ({"create": "create", "stream": "stream"}, {})
1538
+ _CLIENT_TREE = {
1539
+ "messages": _MESSAGES,
1540
+ "chat": ({}, {"completions": ({"create": "create"}, {})}),
1541
+ "completions": ({"create": "create"}, {}),
1542
+ "responses": ({"create": "create"}, {}),
1543
+ "beta": ({}, {"messages": _MESSAGES, "chat": ({}, {"completions": ({"create": "create"}, {})})}),
1544
+ }
1545
+
1546
+
1547
+ def _provider_of(client, provider: Optional[str]) -> str:
1548
+ if provider:
1549
+ return provider
1550
+ mod = type(client).__module__ or ""
1551
+ base = str(getattr(client, "base_url", "") or "")
1552
+ try:
1553
+ from hyperclaw import hyperspeed
1554
+ hs = {hyperspeed.DEFAULT_BASE_URL.rstrip("/"),
1555
+ (os.environ.get("HYPERSPEED_BASE_URL") or "").strip().rstrip("/")} - {""}
1556
+ if base.rstrip("/") in hs:
1557
+ return "hyperspeed"
1558
+ except Exception:
1559
+ pass
1560
+ if mod.startswith("anthropic") or "anthropic" in type(client).__name__.lower():
1561
+ return "anthropic"
1562
+ if mod.startswith("openai"):
1563
+ return "openai"
1564
+ return "unknown"
1565
+
1566
+
1567
+ class GuardedClient(_Namespace):
1568
+ """A provider SDK client whose model calls all go through the cost guard.
1569
+ Every other attribute passes through to the real client."""
1570
+
1571
+ __cost_guarded__ = True
1572
+
1573
+ def __init__(self, client, provider: Optional[str] = None):
1574
+ super().__init__(client, _provider_of(client, provider), {}, _CLIENT_TREE)
1575
+
1576
+ @property
1577
+ def unwrapped(self):
1578
+ return self._inner
1579
+
1580
+ def __enter__(self):
1581
+ enter = getattr(self._inner, "__enter__", None)
1582
+ if callable(enter):
1583
+ enter()
1584
+ return self
1585
+
1586
+ def __exit__(self, *a):
1587
+ ex = getattr(self._inner, "__exit__", None)
1588
+ return ex(*a) if callable(ex) else False
1589
+
1590
+ async def __aenter__(self):
1591
+ enter = getattr(self._inner, "__aenter__", None)
1592
+ if callable(enter):
1593
+ await enter()
1594
+ return self
1595
+
1596
+ async def __aexit__(self, *a):
1597
+ ex = getattr(self._inner, "__aexit__", None)
1598
+ return (await ex(*a)) if callable(ex) else False
1599
+
1600
+
1601
+ def guard_client(client, provider: Optional[str] = None):
1602
+ """Wrap a provider SDK client so every model call is guarded. Idempotent."""
1603
+ if client is None or getattr(client, "__cost_guarded__", False) is True:
1604
+ return client
1605
+ return GuardedClient(client, provider)
1606
+
1607
+
1608
+ def is_guarded(client) -> bool:
1609
+ return getattr(client, "__cost_guarded__", False) is True
1610
+
1611
+
1612
+ # ── provider classes (core.providers.llm.LLMProvider, core.providers.image.VisionProvider) ──
1613
+
1614
+ def _call_args(fn, self, a, kw) -> Dict[str, Any]:
1615
+ try:
1616
+ b = inspect.signature(fn).bind_partial(self, *a, **kw)
1617
+ b.apply_defaults()
1618
+ args = dict(b.arguments)
1619
+ args.update(args.pop("kwargs", None) or {})
1620
+ return args
1621
+ except (TypeError, ValueError):
1622
+ return dict(kw)
1623
+
1624
+
1625
+ def _model_request_max(fn, self, a, kw) -> Tuple[str, dict, Optional[int]]:
1626
+ """(model, request, max output the call sets or None)."""
1627
+ args = _call_args(fn, self, a, kw)
1628
+ model = str(args.get("model") or getattr(self, "default_model", "") or "")
1629
+ msgs = args.get("messages")
1630
+ try:
1631
+ msgs = [getattr(m, "content", m) for m in (msgs or [])]
1632
+ except TypeError:
1633
+ pass
1634
+ req = {k: v for k, v in (("messages", msgs), ("system", args.get("system")),
1635
+ ("prompt", args.get("prompt")), ("query", args.get("query")))
1636
+ if v}
1637
+ return model, req, _request_max(args)
1638
+
1639
+
1640
+ def _accepts_max(fn) -> bool:
1641
+ try:
1642
+ ps = inspect.signature(fn).parameters.values()
1643
+ except (TypeError, ValueError):
1644
+ return False
1645
+ return any(p.name == "max_tokens" or p.kind is inspect.Parameter.VAR_KEYWORD for p in ps)
1646
+
1647
+
1648
+ def _explicit_max(fn, self, a, kw) -> Optional[int]:
1649
+ """The max output the CALLER passed (defaults not applied), or None."""
1650
+ try:
1651
+ b = inspect.signature(fn).bind_partial(self, *a, **kw)
1652
+ args = dict(b.arguments)
1653
+ args.update(args.pop("kwargs", None) or {})
1654
+ except (TypeError, ValueError):
1655
+ args = dict(kw)
1656
+ return _request_max(args)
1657
+
1658
+
1659
+ def _with_max(fn, self, a, kw, provider: str, path: str):
1660
+ """(model, request, max output, kwargs): a call that passes no max gets
1661
+ the path's ``max_output`` injected as ``max_tokens``, so the provider
1662
+ sends it and the reservation is made against it."""
1663
+ model, req, _ = _model_request_max(fn, self, a, kw)
1664
+ mx = _explicit_max(fn, self, a, kw)
1665
+ if mx is None and _accepts_max(fn):
1666
+ mx = max_output(path, provider, model)
1667
+ kw = dict(kw, max_tokens=mx)
1668
+ return model, req, mx, kw
1669
+
1670
+
1671
+ def _guard_coroutine(fn, pname: str, path: str = "provider"):
1672
+ @functools.wraps(fn)
1673
+ async def wrapper(self, *a, **kw):
1674
+ prov = getattr(self, "name", "") or pname
1675
+ model, req, mx, kw = _with_max(fn, self, a, kw, prov, path)
1676
+ with metered(prov, model, req, mx) as m:
1677
+ res = await fn(self, *a, **kw)
1678
+ m.usage({"input_tokens": getattr(res, "input_tokens", None) or None,
1679
+ "output_tokens": getattr(res, "output_tokens", None) or None})
1680
+ raw = getattr(res, "raw", None)
1681
+ if m.input_tokens is None and m.output_tokens is None and isinstance(raw, dict):
1682
+ m.usage_from(raw)
1683
+ if m.input_tokens is None and m.output_tokens is None:
1684
+ m.add_text(getattr(res, "content", None))
1685
+ return res
1686
+ wrapper.__cost_guarded__ = True
1687
+ wrapper.__max_output_path__ = path
1688
+ return wrapper
1689
+
1690
+
1691
+ def _guard_async_gen(fn, pname: str, path: str = "provider"):
1692
+ @functools.wraps(fn)
1693
+ async def wrapper(self, *a, **kw):
1694
+ prov = getattr(self, "name", "") or pname
1695
+ model, req, mx, kw = _with_max(fn, self, a, kw, prov, path)
1696
+ with metered(prov, model, req, mx) as m:
1697
+ async for piece in fn(self, *a, **kw):
1698
+ m.add_text(piece if isinstance(piece, str) else None)
1699
+ yield piece
1700
+ wrapper.__cost_guarded__ = True
1701
+ wrapper.__max_output_path__ = path
1702
+ return wrapper
1703
+
1704
+
1705
+ def guard_provider_class(cls, coroutines: Iterable[str] = ("complete",),
1706
+ streams: Iterable[str] = ("stream",), path: str = "provider") -> None:
1707
+ """Wrap a provider class's model-call methods (coroutines returning a
1708
+ response, async generators yielding text). Called by the provider base
1709
+ classes from __init_subclass__, so no subclass can skip the guard. A call
1710
+ that passes no max output gets ``max_output(path, ...)`` injected."""
1711
+ pname = getattr(cls, "name", "") or cls.__name__
1712
+ for name in coroutines:
1713
+ fn = cls.__dict__.get(name)
1714
+ if fn is not None and not getattr(fn, "__cost_guarded__", False):
1715
+ setattr(cls, name, _guard_coroutine(fn, pname, path))
1716
+ for name in streams:
1717
+ fn = cls.__dict__.get(name)
1718
+ if fn is not None and not getattr(fn, "__cost_guarded__", False):
1719
+ setattr(cls, name, _guard_async_gen(fn, pname, path))
1720
+
1721
+
1722
+ def guarded_async(provider: str, path: str = "provider"):
1723
+ """Decorator for a single async model-call method (e.g. an LLM-backed
1724
+ search). The method must accept ``max_tokens`` (or ``**kwargs``) and send
1725
+ it: a call that passes none gets ``max_output(path, ...)`` injected."""
1726
+ def deco(fn):
1727
+ return _guard_coroutine(fn, provider, path)
1728
+ return deco