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.
- agents/__init__.py +31 -0
- agents/background_monitor.py +453 -0
- agents/learning.py +460 -0
- agents/planner.py +421 -0
- agents/transcriber.py +341 -0
- civilization/__init__.py +51 -0
- civilization/api/__init__.py +7 -0
- civilization/api/civilization_api.py +361 -0
- civilization/graph/__init__.py +19 -0
- civilization/graph/knowledge_linker.py +348 -0
- civilization/graph/org_graph.py +348 -0
- civilization/graph/process_graph.py +386 -0
- civilization/ingestion/__init__.py +20 -0
- civilization/ingestion/chunker.py +273 -0
- civilization/ingestion/document_ingestor.py +358 -0
- civilization/ingestion/embedder.py +262 -0
- civilization/ingestion/extractor.py +224 -0
- civilization/interview/__init__.py +19 -0
- civilization/interview/gap_detector.py +380 -0
- civilization/interview/interviewer.py +323 -0
- civilization/interview/questioner.py +363 -0
- civilization/retrieval/__init__.py +17 -0
- civilization/retrieval/civilization_rag.py +316 -0
- civilization/retrieval/context_injector.py +342 -0
- civilization/retrieval/relevance_ranker.py +287 -0
- civilization/schema.py +299 -0
- civilization/store.py +177 -0
- civilization/sync/__init__.py +16 -0
- civilization/sync/confluence_sync.py +317 -0
- civilization/sync/gdrive_sync.py +262 -0
- civilization/sync/notion_sync.py +237 -0
- civilization/versioning/__init__.py +19 -0
- civilization/versioning/diff_engine.py +351 -0
- civilization/versioning/staleness_detector.py +303 -0
- civilization/versioning/version_manager.py +293 -0
- cli/__init__.py +1 -0
- cli/doctor.py +334 -0
- cli/hyperclaw.py +1357 -0
- cli/onboarding.py +469 -0
- core/__init__.py +1 -0
- core/connectors/__init__.py +119 -0
- core/connectors/infrastructure.py +772 -0
- core/connectors/messaging.py +586 -0
- core/connectors/productivity.py +764 -0
- core/connectors/smarthome.py +528 -0
- core/connectors/social.py +730 -0
- core/hyperrouter/__init__.py +23 -0
- core/hyperrouter/bandit.py +218 -0
- core/hyperrouter/fast_loop.py +68 -0
- core/hyperrouter/slow_loop.py +143 -0
- core/hyperstate/__init__.py +24 -0
- core/hyperstate/certifier.py +154 -0
- core/hyperstate/schema.py +160 -0
- core/hyperstate/state_manager.py +103 -0
- core/hyperstate/store.py +163 -0
- core/providers/__init__.py +121 -0
- core/providers/embeddings.py +389 -0
- core/providers/image.py +534 -0
- core/providers/llm.py +1935 -0
- core/providers/search.py +608 -0
- core/providers/voice.py +474 -0
- hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
- hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
- hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
- hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
- hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
- hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
- hyperclaw/__init__.py +67 -0
- hyperclaw/__main__.py +220 -0
- hyperclaw/agent.py +775 -0
- hyperclaw/agent_coordinator.py +577 -0
- hyperclaw/anthropic_params.py +225 -0
- hyperclaw/api_utils.py +85 -0
- hyperclaw/approval_telegram.py +209 -0
- hyperclaw/approval_terminal.py +80 -0
- hyperclaw/attachments.py +177 -0
- hyperclaw/branding.py +55 -0
- hyperclaw/brief/__init__.py +11 -0
- hyperclaw/brief/cli.py +204 -0
- hyperclaw/brief/collect.py +146 -0
- hyperclaw/brief/compose.py +333 -0
- hyperclaw/brief/diff.py +110 -0
- hyperclaw/brief/errors.py +99 -0
- hyperclaw/brief/google.py +269 -0
- hyperclaw/brief/google_auth.py +294 -0
- hyperclaw/brief/links.py +124 -0
- hyperclaw/brief/service.py +424 -0
- hyperclaw/brief/store.py +205 -0
- hyperclaw/brief/tasks.py +200 -0
- hyperclaw/chat_scope.py +115 -0
- hyperclaw/cli_tui.py +497 -0
- hyperclaw/client_guard.py +717 -0
- hyperclaw/connection_check.py +113 -0
- hyperclaw/cost_guard.py +1728 -0
- hyperclaw/default_config/agents.yaml +534 -0
- hyperclaw/default_config/hyperclaw.yaml +27 -0
- hyperclaw/default_config/models.yaml +161 -0
- hyperclaw/deployment.py +39 -0
- hyperclaw/digest/__init__.py +9 -0
- hyperclaw/digest/cli.py +117 -0
- hyperclaw/digest/errors.py +100 -0
- hyperclaw/digest/gitlab.py +187 -0
- hyperclaw/digest/http.py +113 -0
- hyperclaw/digest/redact.py +32 -0
- hyperclaw/digest/service.py +271 -0
- hyperclaw/digest/slack.py +223 -0
- hyperclaw/digest/state.py +124 -0
- hyperclaw/digest/summarize.py +198 -0
- hyperclaw/digest/tokens.py +143 -0
- hyperclaw/distribution.py +46 -0
- hyperclaw/documents.py +351 -0
- hyperclaw/feeds.py +339 -0
- hyperclaw/google_workspace/__init__.py +5 -0
- hyperclaw/google_workspace/api.py +151 -0
- hyperclaw/google_workspace/auth.py +397 -0
- hyperclaw/google_workspace/calendar.py +157 -0
- hyperclaw/google_workspace/cli.py +72 -0
- hyperclaw/google_workspace/drive.py +236 -0
- hyperclaw/google_workspace/errors.py +58 -0
- hyperclaw/google_workspace/gmail.py +295 -0
- hyperclaw/google_workspace/meet.py +122 -0
- hyperclaw/google_workspace/tools.py +319 -0
- hyperclaw/hyperspeed.py +57 -0
- hyperclaw/integrations_layer.py +1427 -0
- hyperclaw/live_state.py +188 -0
- hyperclaw/managed_config.py +245 -0
- hyperclaw/media_hub.py +179 -0
- hyperclaw/memory_manager.py +584 -0
- hyperclaw/model_catalog.py +551 -0
- hyperclaw/model_policy.py +211 -0
- hyperclaw/model_router.py +595 -0
- hyperclaw/model_selector.py +72 -0
- hyperclaw/naming.py +284 -0
- hyperclaw/notices.py +65 -0
- hyperclaw/oai_stream.py +106 -0
- hyperclaw/orchestrator.py +591 -0
- hyperclaw/outbox.py +82 -0
- hyperclaw/persona.py +367 -0
- hyperclaw/prometheus.py +219 -0
- hyperclaw/prompt_assembly.py +157 -0
- hyperclaw/provider_setup.py +146 -0
- hyperclaw/providers.py +340 -0
- hyperclaw/qualification.py +341 -0
- hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
- hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
- hyperclaw/reasoning_display.py +35 -0
- hyperclaw/scheduler.py +454 -0
- hyperclaw/server.py +847 -0
- hyperclaw/setup.py +627 -0
- hyperclaw/skills.py +717 -0
- hyperclaw/solomon.py +442 -0
- hyperclaw/swarm.py +533 -0
- hyperclaw/taint.py +832 -0
- hyperclaw/team/__init__.py +28 -0
- hyperclaw/team/dispatcher.py +431 -0
- hyperclaw/team/genesis.py +106 -0
- hyperclaw/team/lead.py +110 -0
- hyperclaw/team/model.py +135 -0
- hyperclaw/team/morning.py +238 -0
- hyperclaw/team/repos.py +66 -0
- hyperclaw/team/roster.py +264 -0
- hyperclaw/team/specialist.py +235 -0
- hyperclaw/team/status.py +163 -0
- hyperclaw/team/watchers.py +112 -0
- hyperclaw/teamsetup/__init__.py +17 -0
- hyperclaw/teamsetup/checks.py +208 -0
- hyperclaw/teamsetup/item.py +391 -0
- hyperclaw/teamsetup/oauth_loopback.py +84 -0
- hyperclaw/teamsetup/onboard.py +550 -0
- hyperclaw/teamsetup/profile.py +82 -0
- hyperclaw/teamsetup/secrets.py +313 -0
- hyperclaw/teamsetup/selfcheck.py +295 -0
- hyperclaw/teamsetup/service.py +495 -0
- hyperclaw/teamsetup/slack_oauth.py +442 -0
- hyperclaw/teamsetup/tty.py +62 -0
- hyperclaw/telegram_bot.py +1106 -0
- hyperclaw/telegram_intake.py +126 -0
- hyperclaw/telegram_pairing.py +360 -0
- hyperclaw/terminal_provider.py +323 -0
- hyperclaw/tools/__init__.py +1 -0
- hyperclaw/tools/approval.py +949 -0
- hyperclaw/tools/args.py +212 -0
- hyperclaw/tools/catalog.py +244 -0
- hyperclaw/tools/classify.py +266 -0
- hyperclaw/tools/effects.py +724 -0
- hyperclaw/tools/egress.py +210 -0
- hyperclaw/tools/envelope.py +188 -0
- hyperclaw/tools/guard.py +367 -0
- hyperclaw/tools/ledger.py +339 -0
- hyperclaw/tools/receipts.py +70 -0
- hyperclaw/tools/registry.py +318 -0
- hyperclaw/tools/service_guard.py +151 -0
- hyperclaw/tools/surfaces.py +54 -0
- hyperclaw/tools/workspace.py +87 -0
- hyperclaw/trading/__init__.py +77 -0
- hyperclaw/truth_guard.py +791 -0
- hyperclaw/tts.py +124 -0
- hyperclaw/tui.py +4255 -0
- hyperclaw/tui_bridge.py +938 -0
- hyperclaw/unattended.py +60 -0
- hyperclaw/updates/__init__.py +54 -0
- hyperclaw/updates/check.py +131 -0
- hyperclaw/updates/index.py +178 -0
- hyperclaw/updates/install.py +110 -0
- hyperclaw/updates/managed.py +161 -0
- hyperclaw/updates/manifest.py +138 -0
- hyperclaw/updates/notices.py +86 -0
- hyperclaw/updates/paths.py +53 -0
- hyperclaw/updates/release.py +146 -0
- hyperclaw/updates/upgrade.py +253 -0
- hyperclaw/updates/versioning.py +104 -0
- hyperclaw/userconfig.py +119 -0
- hyperclaw/xai.py +83 -0
- integrations/__init__.py +31 -0
- integrations/automation/__init__.py +1 -0
- integrations/automation/make.py +87 -0
- integrations/automation/zapier.py +87 -0
- integrations/base.py +476 -0
- integrations/communication/__init__.py +1 -0
- integrations/communication/twilio.py +279 -0
- integrations/communication/zoom.py +290 -0
- integrations/data/__init__.py +1 -0
- integrations/data/google_analytics.py +135 -0
- integrations/data/postgres.py +182 -0
- integrations/data/supabase_connector.py +202 -0
- integrations/data/weather_feeds.py +450 -0
- integrations/developer/__init__.py +9 -0
- integrations/developer/github.py +406 -0
- integrations/developer/gitlab.py +298 -0
- integrations/enterprise/__init__.py +13 -0
- integrations/enterprise/confluence.py +266 -0
- integrations/enterprise/hubspot.py +293 -0
- integrations/enterprise/jira.py +314 -0
- integrations/enterprise/salesforce.py +286 -0
- integrations/finance/__init__.py +1 -0
- integrations/finance/polymarket_client.py +247 -0
- integrations/finance/quickbooks.py +281 -0
- integrations/finance/stripe.py +261 -0
- integrations/gateway.py +238 -0
- integrations/messaging/__init__.py +32 -0
- integrations/messaging/discord.py +159 -0
- integrations/messaging/email.py +401 -0
- integrations/messaging/imessage.py +443 -0
- integrations/messaging/signal.py +175 -0
- integrations/messaging/slack.py +217 -0
- integrations/messaging/sms.py +175 -0
- integrations/messaging/teams.py +208 -0
- integrations/messaging/telegram.py +260 -0
- integrations/messaging/whatsapp.py +203 -0
- integrations/productivity/__init__.py +17 -0
- integrations/productivity/airtable.py +217 -0
- integrations/productivity/asana.py +248 -0
- integrations/productivity/google/__init__.py +19 -0
- integrations/productivity/google/base.py +89 -0
- integrations/productivity/google/calendar.py +272 -0
- integrations/productivity/google/docs.py +202 -0
- integrations/productivity/google/drive.py +268 -0
- integrations/productivity/google/gmail.py +279 -0
- integrations/productivity/google/meet.py +218 -0
- integrations/productivity/google/sheets.py +222 -0
- integrations/productivity/google/tasks.py +199 -0
- integrations/productivity/linear.py +279 -0
- integrations/productivity/microsoft/__init__.py +13 -0
- integrations/productivity/microsoft/base.py +93 -0
- integrations/productivity/microsoft/calendar.py +207 -0
- integrations/productivity/microsoft/onedrive.py +222 -0
- integrations/productivity/microsoft/outlook.py +241 -0
- integrations/productivity/microsoft/sharepoint.py +211 -0
- integrations/productivity/notion.py +243 -0
- integrations/productivity/todoist.py +250 -0
- integrations/productivity/trello.py +244 -0
- integrations/storage/__init__.py +1 -0
- integrations/storage/box.py +279 -0
- integrations/storage/dropbox.py +215 -0
- integrations/telegram_inbound.py +179 -0
- integrations/telegram_poller.py +151 -0
- memory/__init__.py +1 -0
- memory/causal_graph.py +125 -0
- memory/impact_tracker.py +88 -0
- memory/vector_memory.py +436 -0
- models/__init__.py +12 -0
- models/chatjimmy_client.py +210 -0
- models/claude_client.py +235 -0
- models/claude_code_subagent.py +192 -0
- models/openai_client.py +120 -0
- models/router.py +162 -0
- recursive/discovery_loop.py +118 -0
- recursive/optimization_loop.py +135 -0
- recursive/validation_loop.py +38 -0
- security/__init__.py +1 -0
- security/audit_logger.py +106 -0
- security/filesystem_guard.py +100 -0
- security/hypershield.py +99 -0
- security/network_guard.py +97 -0
- security/policy_engine.py +198 -0
- swarm/__init__.py +1 -0
- swarm/agents/__init__.py +0 -0
- swarm/agents/base.py +230 -0
- swarm/agents/business/__init__.py +0 -0
- swarm/agents/business/counsel.py +31 -0
- swarm/agents/business/herald.py +32 -0
- swarm/agents/business/ledger.py +26 -0
- swarm/agents/business/nexus.py +30 -0
- swarm/agents/business/ops.py +30 -0
- swarm/agents/business/pipeline.py +28 -0
- swarm/agents/business/revenue.py +30 -0
- swarm/agents/business/sovereign.py +31 -0
- swarm/agents/business/strategos.py +27 -0
- swarm/agents/business/talent.py +27 -0
- swarm/agents/business/venture.py +32 -0
- swarm/agents/comms/__init__.py +1 -0
- swarm/agents/comms/cipher.py +32 -0
- swarm/agents/comms/echo_comms.py +31 -0
- swarm/agents/comms/envoy.py +31 -0
- swarm/agents/comms/herald_scheduler.py +33 -0
- swarm/agents/comms/pulse.py +29 -0
- swarm/agents/creative/__init__.py +0 -0
- swarm/agents/creative/author.py +27 -0
- swarm/agents/creative/lens.py +25 -0
- swarm/agents/executive/__init__.py +1 -0
- swarm/agents/intelligence/__init__.py +1 -0
- swarm/agents/intelligence/arbiter.py +30 -0
- swarm/agents/intelligence/sentinel.py +30 -0
- swarm/agents/media/__init__.py +0 -0
- swarm/agents/personal/__init__.py +0 -0
- swarm/agents/personal/atlas.py +27 -0
- swarm/agents/personal/hearth.py +26 -0
- swarm/agents/personal/midas.py +27 -0
- swarm/agents/personal/navigator.py +27 -0
- swarm/agents/personal/nourish.py +31 -0
- swarm/agents/personal/vitals.py +33 -0
- swarm/agents/recursive/__init__.py +0 -0
- swarm/agents/recursive/alchemist.py +73 -0
- swarm/agents/recursive/calibrator.py +45 -0
- swarm/agents/recursive/scout.py +97 -0
- swarm/agents/scientific/__init__.py +0 -0
- swarm/agents/scientific/cosmos.py +36 -0
- swarm/agents/scientific/gaia.py +26 -0
- swarm/agents/scientific/medicus.py +33 -0
- swarm/agents/scientific/oracle_agent.py +39 -0
- swarm/agents/scientific/scribe.py +39 -0
- swarm/agents/talent/__init__.py +0 -0
- swarm/agents/talent/deal.py +28 -0
- swarm/agents/talent/roster.py +29 -0
- swarm/agents/talent/scout.py +29 -0
- swarm/agents/talent/stage.py +27 -0
- swarm/agents/tech/__init__.py +0 -0
- swarm/agents/tech/aegis.py +28 -0
- swarm/agents/tech/bridge.py +28 -0
- swarm/agents/tech/forge.py +28 -0
- swarm/agents/trading/__init__.py +1 -0
- swarm/agents/trading/global_prediction_engine.py +611 -0
- swarm/agents/trading/polymarket_trader.py +165 -0
- swarm/agents/trading/prediction_strategist.py +218 -0
- swarm/autogen_bridge.py +68 -0
- swarm/bid_protocol.py +196 -0
- swarm/dispatcher.py +527 -0
- swarm/nexus.py +181 -0
- swarm/registry.py +112 -0
- telegram_listener.py +403 -0
- ui/tui/app.py +214 -0
hyperclaw/cost_guard.py
ADDED
|
@@ -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
|