fix(live): bound model spending and isolate decision context (#1854)
This commit is contained in:
1 parent
d7f1b9e22b
commit
66aed999c4
26 files changed
+1088
-4
No files matched your search
@@ -2010,6 +2010,38 @@ The agent exposes connector-scoped tools named `trading_connections`,
|
||||
Live-broker raw MCP tools are not registered directly as `mcp_<broker>_*`.
|
||||
No IBKR order-placement tool is registered.
|
||||
|
||||
### Live runner model spending
|
||||
|
||||
Model calls are billed even when a mandate permits only one trade or the agent
|
||||
places no orders. The runner checks safety every minute, but defaults to a model
|
||||
decision every **15 minutes**, at most **26 decisions / 1,000,000 reported tokens
|
||||
per UTC day**. Each decision starts a fresh conversation and reconstructs broker
|
||||
state under the existing mandate gate.
|
||||
|
||||
Configure cadence, a once-daily wall-clock time/timezone, and daily budgets in
|
||||
**Runtime → Model schedule and spending limits**, or the chat runner panel.
|
||||
Stop the runner before editing. The CLI uses the same authenticated API:
|
||||
|
||||
```bash
|
||||
vibe-trading connector runner-settings --profile robinhood-live-mcp
|
||||
vibe-trading connector runner-settings --profile robinhood-live-mcp --file runner-settings.json
|
||||
```
|
||||
|
||||
The JSON accepts `interval_seconds`, optional `daily_at` (`HH:MM`) and `timezone`
|
||||
(IANA, default `America/New_York`), `max_daily_turns`, `max_daily_tokens`, and
|
||||
optional `max_daily_cost_usd`, `input_usd_per_million`, `output_usd_per_million`.
|
||||
Both prices are required for a dollar limit. Use the highest applicable input or
|
||||
cache-write price; estimates ignore cache discounts and auxiliary model calls.
|
||||
Daily projections need measured usage and configured prices; unknown costs stay
|
||||
unknown. The same settings are exposed by `GET` / `PUT /live/runner/settings?broker=robinhood`.
|
||||
|
||||
Spending history survives restarts. Limits are checked **after each provider
|
||||
response**, so a response can overshoot a token or estimated-dollar budget.
|
||||
Exhaustion or missing usage trips the existing kill switch and cancels the active
|
||||
decision before its tool calls. Existing halt/cancel/flatten behavior applies;
|
||||
inspect the broker and usage before resuming. An unfinished decision after a
|
||||
crash is blocked for inspection, never silently counted as free.
|
||||
|
||||
### 🔐 TAP Mode — full credential isolation & human-approved writes
|
||||
|
||||
**Opt-in, off by default.** If the `TAP_*` variables below are unset, the
|
||||
|
||||
@@ -331,6 +331,17 @@ vibe-trading connector quote AAPL
|
||||
vibe-trading connector history AAPL --duration "30 D" --bar-size "1 day"
|
||||
```
|
||||
|
||||
Live-runner model spending is independent of mandate trade limits. Operators
|
||||
configure it through Runtime / the chat runner panel, or
|
||||
`vibe-trading connector runner-settings --profile robinhood-live-mcp`
|
||||
(`--file settings.json` saves settings while stopped). Defaults are one model
|
||||
decision every 15 minutes, 26 decisions and 1,000,000 reported tokens per UTC day,
|
||||
with fresh conversation context per decision. A once-daily time and IANA timezone
|
||||
are optional. Dollar limits require operator-supplied input/output prices and are
|
||||
estimates, not provider billing guarantees. Usage is checked after each response;
|
||||
budget exhaustion or missing usage activates the existing kill switch. The agent
|
||||
must not edit this operator state or raise its own spending limits.
|
||||
|
||||
Default ports are TWS paper `7497`, IB Gateway paper `4002`, TWS live-readonly
|
||||
`7496`, and IB Gateway live-readonly `4001`.
|
||||
|
||||
|
||||
@@ -3912,6 +3912,19 @@ def cmd_live_revoke(broker: str) -> int:
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
def _preview_live_spending(broker: str) -> bool:
|
||||
"""Show operator settings before either foreground or background start."""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
preview = _live_api_call("GET", "/live/runner/settings?" + urlencode({"broker": broker}))
|
||||
if preview.get("status") == "error":
|
||||
console.print("[red]Could not read runner model-spending settings; not starting.[/red]")
|
||||
return False
|
||||
console.print("[yellow]Model calls are billed independently of trade limits.[/yellow]")
|
||||
console.print_json(data=preview)
|
||||
return True
|
||||
|
||||
|
||||
def cmd_live_start(broker: Optional[str] = None) -> int:
|
||||
"""Start the persistent live runner in the background (SPEC §7.5).
|
||||
|
||||
@@ -3928,6 +3941,8 @@ def cmd_live_start(broker: Optional[str] = None) -> int:
|
||||
Process exit code. ``EXIT_RUN_FAILED`` when the server is unreachable.
|
||||
"""
|
||||
key = (broker or _DEFAULT_LIVE_BROKER).strip().lower()
|
||||
if not _preview_live_spending(key):
|
||||
return EXIT_RUN_FAILED
|
||||
result = _live_api_call(
|
||||
"POST", "/live/runner/start", body={"broker": key, "foreground": False}
|
||||
)
|
||||
@@ -3988,6 +4003,9 @@ def cmd_live_run(broker: Optional[str] = None) -> int:
|
||||
key = (broker or _DEFAULT_LIVE_BROKER).strip().lower()
|
||||
runner_id = _runner_id_for(key)
|
||||
|
||||
if not _preview_live_spending(key):
|
||||
return EXIT_RUN_FAILED
|
||||
|
||||
result = _live_api_call(
|
||||
"POST", "/live/runner/start", body={"broker": key, "foreground": True}
|
||||
)
|
||||
@@ -5279,6 +5297,36 @@ def cmd_connector_start(profile_id: Optional[str]) -> int:
|
||||
return cmd_live_start(broker)
|
||||
|
||||
|
||||
def cmd_connector_runner_settings(profile_id: Optional[str], settings_file: Optional[str] = None) -> int:
|
||||
"""Show model budgets or save a JSON settings file for a stopped runner.
|
||||
|
||||
Args:
|
||||
profile_id: Selected live connector profile.
|
||||
settings_file: Optional JSON file containing DecisionSettings fields.
|
||||
|
||||
Returns:
|
||||
CLI process exit code.
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
code, broker = _live_profile_connector(profile_id, require_runner=True)
|
||||
if code != EXIT_SUCCESS or broker is None:
|
||||
return code
|
||||
path = "/live/runner/settings?" + urlencode({"broker": broker})
|
||||
body = None
|
||||
if settings_file:
|
||||
try:
|
||||
from src.live.runtime.decision_budget import DecisionSettings
|
||||
|
||||
body = DecisionSettings.model_validate_json(Path(settings_file).read_text()).model_dump()
|
||||
except (OSError, ValueError) as exc:
|
||||
console.print(f"[red]Invalid runner settings:[/red] {exc}")
|
||||
return EXIT_USAGE_ERROR
|
||||
result = _live_api_call("PUT" if body is not None else "GET", path, body=body)
|
||||
console.print_json(data=result)
|
||||
return EXIT_RUN_FAILED if result.get("status") == "error" else EXIT_SUCCESS
|
||||
|
||||
|
||||
def cmd_connector_stop(profile_id: Optional[str]) -> int:
|
||||
"""Stop a live remote MCP connector runner."""
|
||||
code, broker = _live_profile_connector(profile_id, require_runner=True)
|
||||
@@ -5421,6 +5469,8 @@ def _dispatch_connector(args: argparse.Namespace) -> int:
|
||||
return cmd_connector_status(args.profile)
|
||||
if sub == "start":
|
||||
return cmd_connector_start(args.profile)
|
||||
if sub == "runner-settings":
|
||||
return cmd_connector_runner_settings(args.profile, args.file)
|
||||
if sub == "stop":
|
||||
return cmd_connector_stop(args.profile)
|
||||
if sub == "halt":
|
||||
@@ -5750,6 +5800,12 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
p = connector_subparsers.add_parser(name, help=help_text)
|
||||
_add_connector_profile_arg(p)
|
||||
|
||||
runner_settings = connector_subparsers.add_parser(
|
||||
"runner-settings", help="Preview model spending or save runner cadence/budgets from JSON"
|
||||
)
|
||||
_add_connector_profile_arg(runner_settings)
|
||||
runner_settings.add_argument("--file", help="Validated settings JSON; stop the runner before changing it")
|
||||
|
||||
# Alpha Zoo subcommands (registered via cli_handlers.add_subparser)
|
||||
from src.factors.cli_handlers import add_subparser as _add_alpha_subparser
|
||||
_add_alpha_subparser(subparsers)
|
||||
|
||||
@@ -715,6 +715,12 @@ class AgentLoop(ToolCallLifecycle):
|
||||
"iter": current_iter,
|
||||
},
|
||||
)
|
||||
else:
|
||||
self._emit("llm_usage_unavailable", {"iter": current_iter})
|
||||
# A caller's spending monitor can cancel synchronously on the
|
||||
# usage event. Do not execute tools from that paid response.
|
||||
if self._cancel_event.is_set():
|
||||
break
|
||||
if active_goal_id and session_id:
|
||||
token_delta = int(usage_delta.get("total_tokens") or 0) if usage_delta else 0
|
||||
turn_delta = 0 if goal_turn_accounted else 1
|
||||
|
||||
@@ -32,6 +32,8 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.live.runtime.decision_budget import DecisionBudget, DecisionSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -154,6 +156,7 @@ class LiveBrokerStatus(BaseModel):
|
||||
mandate: Optional[ActiveMandateState] = None
|
||||
runner: RunnerLivenessState
|
||||
halted: bool = Field(..., description="Per-broker OR global kill switch is tripped")
|
||||
decision_budget: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class LiveStatusResponse(BaseModel):
|
||||
@@ -562,7 +565,22 @@ def _runner_liveness_state(broker: str) -> RunnerLivenessState:
|
||||
return RunnerLivenessState(broker=broker, alive=alive, last_tick=tick, last_tick_age_seconds=age)
|
||||
|
||||
|
||||
async def _run_live_session_attempt(svc: Any, session_id: str, prompt: str) -> Dict[str, Any]:
|
||||
def _decision_budget_state(broker: str) -> Dict[str, Any] | None:
|
||||
"""Read model spending controls without probing broker credentials."""
|
||||
from src.trading.service import broker_supports_live_runner
|
||||
|
||||
if not broker_supports_live_runner(broker):
|
||||
return None
|
||||
try:
|
||||
return DecisionBudget(broker).snapshot(datetime.now(timezone.utc))
|
||||
except Exception:
|
||||
logger.warning("could not read live model spending state", exc_info=True)
|
||||
return {"error": "model spending state is unreadable; runner cannot start"}
|
||||
|
||||
|
||||
async def _run_live_session_attempt(
|
||||
svc: Any, session_id: str, prompt: str, *, usage_callback: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Own a queued session attempt until completion or cooperative cancellation.
|
||||
|
||||
Args:
|
||||
@@ -583,6 +601,15 @@ async def _run_live_session_attempt(svc: Any, session_id: str, prompt: str) -> D
|
||||
early_results: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def on_terminal(event: Any) -> None:
|
||||
if event.session_id == session_id and usage_callback is not None and event.event_type in {
|
||||
"llm_usage", "llm_usage_unavailable"
|
||||
}:
|
||||
try:
|
||||
usage_callback(event.data if event.event_type == "llm_usage" else {})
|
||||
except Exception:
|
||||
svc.cancel_current(session_id)
|
||||
logger.warning("live model budget stopped the active decision", exc_info=True)
|
||||
return
|
||||
if event.session_id != session_id or event.event_type not in {
|
||||
"attempt.completed", "attempt.failed", "attempt.cancelled", "attempt.interrupted"
|
||||
}:
|
||||
@@ -751,8 +778,47 @@ def _build_live_runner(broker: str) -> Any:
|
||||
session = svc.create_session(title=f"live-runner:{broker}")
|
||||
session_id = session.session_id
|
||||
|
||||
budget = DecisionBudget(broker)
|
||||
settings = budget.settings()
|
||||
|
||||
async def _agent_caller(sid: str, prompt: str) -> Dict[str, Any]:
|
||||
return await _run_live_session_attempt(svc, sid, prompt)
|
||||
from src.live.halt import trip_halt
|
||||
|
||||
decision = None
|
||||
failed = False
|
||||
try:
|
||||
decision = budget.begin(settings, datetime.now(timezone.utc))
|
||||
if decision is None:
|
||||
return {"status": "waiting"}
|
||||
# Broker state and the pinned mandate are reconstructed each tick.
|
||||
# Previous model conversations are neither authority nor input.
|
||||
tick_session = svc.create_session(title=f"live-decision:{broker}")
|
||||
|
||||
def record_usage(data: Dict[str, Any]) -> None:
|
||||
try:
|
||||
budget.record(decision, data, settings, datetime.now(timezone.utc))
|
||||
except Exception:
|
||||
trip_halt(by="file", reason="live model spending limit or usage accounting failure", broker=broker)
|
||||
raise
|
||||
|
||||
return await _run_live_session_attempt(
|
||||
svc, tick_session.session_id, prompt, usage_callback=record_usage,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
failed = True
|
||||
raise
|
||||
except Exception:
|
||||
failed = True
|
||||
trip_halt(by="file", reason="live model decision stopped; inspect runtime usage", broker=broker)
|
||||
raise
|
||||
finally:
|
||||
if decision is not None:
|
||||
try:
|
||||
budget.finish(decision, settings, datetime.now(timezone.utc))
|
||||
except Exception:
|
||||
trip_halt(by="file", reason="live model budget exhausted or provider usage missing", broker=broker)
|
||||
if not failed:
|
||||
raise
|
||||
|
||||
def _audit_with_bus(event: Any) -> Dict[str, Any]:
|
||||
return write_live_action(
|
||||
@@ -1212,6 +1278,7 @@ def register_live_routes(
|
||||
mandate=h._active_mandate_state(key),
|
||||
runner=_runner_liveness_state(key),
|
||||
halted=halt_flag_set(broker=key),
|
||||
decision_budget=_decision_budget_state(key),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1285,6 +1352,31 @@ def register_live_routes(
|
||||
report = h._check_connector_status(profile_id, force=force)
|
||||
return report
|
||||
|
||||
def runner_settings_store(broker: str) -> DecisionBudget:
|
||||
from src.trading.service import broker_supports_live_runner
|
||||
|
||||
key = broker.strip().lower()
|
||||
if not broker_supports_live_runner(key):
|
||||
raise HTTPException(status_code=400, detail=f"live runner is not supported for {key}")
|
||||
return DecisionBudget(key)
|
||||
|
||||
@app.get("/live/runner/settings", dependencies=[Depends(require_auth)])
|
||||
async def get_runner_settings(broker: str = Query(..., min_length=1, max_length=64)):
|
||||
"""Preview cadence, budgets and measured cost before starting a runner."""
|
||||
return runner_settings_store(broker).snapshot(datetime.now(timezone.utc))
|
||||
|
||||
@app.put("/live/runner/settings", dependencies=[Depends(require_auth)])
|
||||
async def put_runner_settings(
|
||||
payload: DecisionSettings, broker: str = Query(..., min_length=1, max_length=64),
|
||||
):
|
||||
"""Save operator settings while stopped; spending history is retained."""
|
||||
store = runner_settings_store(broker)
|
||||
task = _host()._runner_tasks.get(broker.strip().lower())
|
||||
if task is not None and not task.done():
|
||||
raise HTTPException(status_code=409, detail="stop the runner before changing its settings")
|
||||
store.save_settings(payload)
|
||||
return store.snapshot(datetime.now(timezone.utc))
|
||||
|
||||
@app.post("/live/runner/start", dependencies=[Depends(require_auth)])
|
||||
async def start_runner_endpoint(payload: LiveRunnerControlRequest):
|
||||
"""Start the persistent live runner for a broker (SPEC §7.5)."""
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Persisted cadence and measured model budgets for autonomous live decisions.
|
||||
|
||||
Trading authority remains in the mandate gate. These operator-owned settings
|
||||
control model spending only. Usage arrives after a provider response, so token
|
||||
and estimated-dollar stops can overshoot by that response, never silently reset
|
||||
on runner restart. Budget days are UTC; daily schedules use an IANA timezone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sqlite3
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from src.live.paths import broker_dir
|
||||
|
||||
|
||||
class DecisionSettings(BaseModel):
|
||||
"""Operator settings shared by the REST, CLI and Web control surfaces."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
interval_seconds: int = Field(default=900, ge=60, le=86400, strict=True)
|
||||
daily_at: str | None = Field(default=None, pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
timezone: str = "America/New_York"
|
||||
max_daily_turns: int = Field(default=26, ge=1, le=1440, strict=True)
|
||||
max_daily_tokens: int = Field(default=1_000_000, ge=1, strict=True)
|
||||
max_daily_cost_usd: float | None = Field(default=None, gt=0, strict=True)
|
||||
input_usd_per_million: float | None = Field(default=None, ge=0, strict=True)
|
||||
output_usd_per_million: float | None = Field(default=None, ge=0, strict=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_pricing_and_zone(self) -> "DecisionSettings":
|
||||
"""Reject unknown timezones and dollar limits without operator prices."""
|
||||
try:
|
||||
ZoneInfo(self.timezone)
|
||||
except (ValueError, ZoneInfoNotFoundError) as exc:
|
||||
raise ValueError("timezone must be an IANA timezone") from exc
|
||||
rates = (self.input_usd_per_million, self.output_usd_per_million)
|
||||
if (rates[0] is None) != (rates[1] is None):
|
||||
raise ValueError("both input and output prices are required")
|
||||
if self.max_daily_cost_usd is not None and rates[0] is None:
|
||||
raise ValueError("a dollar limit requires input and output prices")
|
||||
return self
|
||||
|
||||
|
||||
class DecisionBudgetError(RuntimeError):
|
||||
"""Model execution must stop because its budget cannot be honored."""
|
||||
|
||||
|
||||
class DecisionBudget:
|
||||
"""SQLite-backed settings, decision reservations and idempotent usage rows."""
|
||||
|
||||
def __init__(self, broker: str, *, path: Path | None = None) -> None:
|
||||
"""Resolve the broker's private runtime store without creating it."""
|
||||
self.path = path or broker_dir(broker) / "runtime" / "decisions.sqlite3"
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
db = sqlite3.connect(self.path, timeout=10)
|
||||
db.row_factory = sqlite3.Row
|
||||
try:
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
db.executescript(
|
||||
"CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY, value TEXT NOT NULL);"
|
||||
"CREATE TABLE IF NOT EXISTS decisions ("
|
||||
"id TEXT PRIMARY KEY, started REAL NOT NULL, finished INTEGER NOT NULL DEFAULT 0, "
|
||||
"accounting_error INTEGER NOT NULL DEFAULT 0);"
|
||||
"CREATE TABLE IF NOT EXISTS usage ("
|
||||
"decision TEXT NOT NULL REFERENCES decisions(id), iteration INTEGER NOT NULL, "
|
||||
"tokens INTEGER NOT NULL, cost REAL, recorded REAL NOT NULL, PRIMARY KEY(decision, iteration));"
|
||||
)
|
||||
with db:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def settings(self) -> DecisionSettings:
|
||||
"""Read validated settings; a corrupt store never resets to defaults."""
|
||||
if not self.path.exists():
|
||||
return DecisionSettings()
|
||||
with self._connect() as db:
|
||||
row = db.execute("SELECT value FROM settings WHERE id=1").fetchone()
|
||||
return DecisionSettings.model_validate_json(row[0]) if row else DecisionSettings()
|
||||
|
||||
def save_settings(self, settings: DecisionSettings) -> None:
|
||||
"""Save validated operator settings without deleting spending history."""
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"INSERT INTO settings VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET value=excluded.value",
|
||||
(settings.model_dump_json(),),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _totals(db: sqlite3.Connection, now: datetime) -> dict[str, Any]:
|
||||
start = now.astimezone(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
|
||||
rows = db.execute(
|
||||
"SELECT d.id, d.finished, d.accounting_error, count(u.iteration) calls, coalesce(sum(u.tokens),0) tokens, "
|
||||
"sum(u.cost) cost, sum(u.cost IS NULL AND u.iteration IS NOT NULL) unpriced "
|
||||
"FROM decisions d LEFT JOIN usage u ON d.id=u.decision WHERE d.started>=? "
|
||||
"GROUP BY d.id", (start,),
|
||||
).fetchall()
|
||||
measured = db.execute(
|
||||
"SELECT coalesce(sum(tokens),0) tokens, sum(cost) cost, "
|
||||
"sum(cost IS NULL) unpriced FROM usage WHERE recorded>=?", (start,),
|
||||
).fetchone()
|
||||
unaccounted = sum(bool(r["accounting_error"] or (r["finished"] and not r["calls"])) for r in rows)
|
||||
return {
|
||||
"day": now.astimezone(timezone.utc).date().isoformat(),
|
||||
"turns": len(rows),
|
||||
"tokens": measured["tokens"],
|
||||
"estimated_cost_usd": (measured["cost"] or 0) if not measured["unpriced"] and not unaccounted else None,
|
||||
"unaccounted_turns": unaccounted,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _check(settings: DecisionSettings, totals: dict[str, Any], *, starting: bool) -> None:
|
||||
if totals["unaccounted_turns"]:
|
||||
raise DecisionBudgetError("provider usage is unavailable for a prior decision today")
|
||||
if starting and totals["turns"] >= settings.max_daily_turns:
|
||||
raise DecisionBudgetError("daily model decision limit reached")
|
||||
if totals["tokens"] >= settings.max_daily_tokens:
|
||||
raise DecisionBudgetError("daily model token budget reached")
|
||||
if settings.max_daily_cost_usd is not None:
|
||||
cost = totals["estimated_cost_usd"]
|
||||
if cost is None:
|
||||
raise DecisionBudgetError("earlier model usage has no configured price")
|
||||
if cost >= settings.max_daily_cost_usd:
|
||||
raise DecisionBudgetError("daily estimated model cost budget reached")
|
||||
|
||||
def begin(self, settings: DecisionSettings, now: datetime) -> str | None:
|
||||
"""Atomically reserve a due decision, or return None between decisions.
|
||||
|
||||
Raises:
|
||||
DecisionBudgetError: If a limit or an unfinished prior turn blocks it.
|
||||
"""
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
if db.execute("SELECT 1 FROM decisions WHERE finished=0 LIMIT 1").fetchone():
|
||||
raise DecisionBudgetError("an unfinished model decision requires inspection before restart")
|
||||
self._check(settings, self._totals(db, now), starting=True)
|
||||
last = db.execute("SELECT max(started) FROM decisions").fetchone()[0]
|
||||
if settings.daily_at:
|
||||
local = now.astimezone(ZoneInfo(settings.timezone))
|
||||
if local.strftime("%H:%M") < settings.daily_at:
|
||||
return None
|
||||
if last is not None and datetime.fromtimestamp(last, ZoneInfo(settings.timezone)).date() >= local.date():
|
||||
return None
|
||||
elif last is not None and now.timestamp() - last < settings.interval_seconds:
|
||||
return None
|
||||
decision = uuid.uuid4().hex
|
||||
db.execute("INSERT INTO decisions(id, started) VALUES (?, ?)", (decision, now.timestamp()))
|
||||
return decision
|
||||
|
||||
def record(self, decision: str, usage: dict[str, Any], settings: DecisionSettings, now: datetime) -> None:
|
||||
"""Persist one provider usage event before enforcing a measured stop.
|
||||
|
||||
Dollar figures use operator-supplied prices, without cache discounts.
|
||||
Set the input rate to the highest applicable input/cache-write rate.
|
||||
"""
|
||||
values = [usage.get(k) for k in ("iter", "input_tokens", "output_tokens", "total_tokens")]
|
||||
if any(type(v) is not int or v < 0 for v in values) or not max(values[3], values[1] + values[2]):
|
||||
with self._connect() as db:
|
||||
db.execute("UPDATE decisions SET accounting_error=1 WHERE id=?", (decision,))
|
||||
raise DecisionBudgetError("provider usage is missing or invalid")
|
||||
iteration, tokens_in, tokens_out, reported = values
|
||||
tokens = max(reported, tokens_in + tokens_out)
|
||||
cost = None
|
||||
if settings.input_usd_per_million is not None:
|
||||
cost = (tokens_in * settings.input_usd_per_million + tokens_out * settings.output_usd_per_million) / 1_000_000
|
||||
# Some providers report additional tokens without a category.
|
||||
cost += (tokens - tokens_in - tokens_out) * max(settings.input_usd_per_million, settings.output_usd_per_million) / 1_000_000
|
||||
if not math.isfinite(cost):
|
||||
with self._connect() as db:
|
||||
db.execute("UPDATE decisions SET accounting_error=1 WHERE id=?", (decision,))
|
||||
raise DecisionBudgetError("estimated model cost is not finite")
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
db.execute("INSERT OR IGNORE INTO usage VALUES (?, ?, ?, ?, ?)", (decision, iteration, tokens, cost, now.timestamp()))
|
||||
totals = self._totals(db, now)
|
||||
self._check(settings, totals, starting=False)
|
||||
|
||||
def finish(self, decision: str, settings: DecisionSettings, now: datetime) -> None:
|
||||
"""Close the reservation, refusing unreported usage rather than a zero bill."""
|
||||
with self._connect() as db:
|
||||
db.execute("UPDATE decisions SET finished=1 WHERE id=?", (decision,))
|
||||
accounted = db.execute(
|
||||
"SELECT accounting_error=0 AND EXISTS(SELECT 1 FROM usage WHERE decision=?) "
|
||||
"FROM decisions WHERE id=?", (decision, decision),
|
||||
).fetchone()
|
||||
totals = self._totals(db, now)
|
||||
if not accounted or not accounted[0]:
|
||||
raise DecisionBudgetError("provider usage is unavailable for this decision")
|
||||
self._check(settings, totals, starting=False)
|
||||
|
||||
def snapshot(self, now: datetime) -> dict[str, Any]:
|
||||
"""Return control settings and today's measured usage for the operator."""
|
||||
settings = self.settings()
|
||||
if self.path.exists():
|
||||
with self._connect() as db:
|
||||
totals = self._totals(db, now)
|
||||
else:
|
||||
totals = {"day": now.astimezone(timezone.utc).date().isoformat(), "turns": 0,
|
||||
"tokens": 0, "estimated_cost_usd": None, "unaccounted_turns": 0}
|
||||
projected = None
|
||||
if settings.input_usd_per_million is None:
|
||||
totals["estimated_cost_usd"] = None
|
||||
if totals["turns"] and totals["estimated_cost_usd"] is not None:
|
||||
projected = totals["estimated_cost_usd"] / totals["turns"] * (1 if settings.daily_at else settings.max_daily_turns)
|
||||
return {"settings": settings.model_dump(), "usage": totals, "projected_daily_cost_usd": projected}
|
||||
@@ -536,6 +536,9 @@ class LiveRunner:
|
||||
audit_id=audit_id,
|
||||
).to_dict()
|
||||
|
||||
if result.get("status") == "waiting":
|
||||
return TickResult(outcome="waiting", broker=self.broker, reason="decision cadence").to_dict()
|
||||
|
||||
audit_id = self._audit(
|
||||
kind="order_placed",
|
||||
outcome="accepted",
|
||||
|
||||
@@ -178,6 +178,30 @@ def test_runner_start_requires_committed_mandate(tmp_path: Path, monkeypatch) ->
|
||||
assert "mandate" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_runner_settings_roundtrip_and_running_guard(tmp_path, monkeypatch):
|
||||
client = _client(tmp_path, monkeypatch)
|
||||
path = "/live/runner/settings?broker=robinhood"
|
||||
first = client.get(path)
|
||||
assert first.status_code == 200
|
||||
settings = first.json()["settings"]
|
||||
assert settings["interval_seconds"] == 900
|
||||
assert first.json()["projected_daily_cost_usd"] is None
|
||||
settings.update(daily_at="10:00", max_daily_tokens=300000)
|
||||
assert client.put(path, json=settings).status_code == 200
|
||||
assert client.get(path).json()["settings"] == settings
|
||||
state = client.get("/live/status?broker=robinhood").json()["brokers"][0]
|
||||
assert state["decision_budget"]["settings"] == settings
|
||||
monkeypatch.setattr(api_server, "_runner_tasks", {"robinhood": SimpleNamespace(done=lambda: False)})
|
||||
assert client.put(path, json=settings).status_code == 409
|
||||
assert client.get("/live/runner/settings?broker=ibkr").status_code == 400
|
||||
|
||||
|
||||
def test_runner_dollar_budget_requires_explicit_prices(tmp_path, monkeypatch):
|
||||
client = _client(tmp_path, monkeypatch)
|
||||
response = client.put("/live/runner/settings?broker=robinhood", json={"max_daily_cost_usd": 5})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_runner_start_rejects_readonly_connector_without_runner(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
@@ -822,6 +846,67 @@ def test_build_live_runner_wires_a_real_runner(tmp_path, monkeypatch) -> None:
|
||||
assert runner.runner_id == "robinhood"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exhaust", [False, True])
|
||||
def test_real_runner_factory_uses_fresh_sessions_and_persistent_budget(tmp_path, monkeypatch, exhaust):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import src.api.live_routes as routes
|
||||
from src.live.halt import halt_flag_set
|
||||
from src.live.runtime.decision_budget import DecisionBudget, DecisionSettings
|
||||
from src.session.events import EventBus
|
||||
from src.session.service import SessionService
|
||||
from src.session.store import SessionStore
|
||||
|
||||
_live_env(tmp_path, monkeypatch)
|
||||
class Clock(datetime):
|
||||
current = datetime(2026, 10, 15, 14, tzinfo=timezone.utc)
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return cls.current
|
||||
monkeypatch.setattr(routes, "datetime", Clock)
|
||||
monkeypatch.setattr(api_server, "_mandate_account_ref", lambda broker: "acct_test")
|
||||
monkeypatch.setattr(api_server, "_live_broker_adapter", lambda broker: SimpleNamespace(call_tool=lambda *args: {}))
|
||||
budget = DecisionBudget("robinhood")
|
||||
budget.save_settings(DecisionSettings(interval_seconds=60, max_daily_tokens=100 if exhaust else 1000))
|
||||
|
||||
async def scenario():
|
||||
bus = EventBus()
|
||||
bus.set_loop(asyncio.get_running_loop())
|
||||
svc = SessionService(SessionStore(base_dir=tmp_path / "sessions"), bus, runs_dir=tmp_path / "runs")
|
||||
sessions = []
|
||||
async def attempt(attempt, **kwargs):
|
||||
sessions.append(attempt.session_id)
|
||||
cancelled = False
|
||||
def cancel():
|
||||
nonlocal cancelled
|
||||
cancelled = True
|
||||
svc._active_loops[attempt.session_id] = SimpleNamespace(cancel=cancel)
|
||||
try:
|
||||
bus.emit(attempt.session_id, "llm_usage", {"iter": 1, "input_tokens": 100, "output_tokens": 10, "total_tokens": 110})
|
||||
return {"status": "cancelled" if cancelled else "success", "content": "hold"}
|
||||
finally:
|
||||
svc._active_loops.pop(attempt.session_id, None)
|
||||
monkeypatch.setattr(svc, "_run_with_agent", attempt)
|
||||
monkeypatch.setattr(api_server, "_get_session_service", lambda: svc)
|
||||
runner = api_server._build_live_runner("robinhood")
|
||||
if exhaust:
|
||||
with pytest.raises(RuntimeError):
|
||||
await runner._agent_caller(runner._session_id, "decision")
|
||||
assert halt_flag_set("robinhood")
|
||||
assert budget.snapshot(Clock.current)["usage"]["tokens"] == 110
|
||||
else:
|
||||
assert (await runner._agent_caller(runner._session_id, "decision"))["status"] == "success"
|
||||
Clock.current += timedelta(seconds=1)
|
||||
assert (await runner._agent_caller(runner._session_id, "decision"))["status"] == "waiting"
|
||||
Clock.current += timedelta(seconds=60)
|
||||
assert (await runner._agent_caller(runner._session_id, "decision"))["status"] == "success"
|
||||
assert len(set(sessions)) == 2
|
||||
for sid in sessions:
|
||||
assert len([m for m in svc.get_messages(sid) if m.role == "user"]) == 1
|
||||
assert budget.snapshot(Clock.current)["usage"]["tokens"] == 220
|
||||
assert not bus._listeners
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cancel,phase,fails", [
|
||||
(False, "registry", False), (True, "registry", False),
|
||||
(True, "agent", False), (False, "agent", True),
|
||||
@@ -845,6 +930,7 @@ def test_live_runner_owns_its_actual_background_session_attempt(tmp_path, monkey
|
||||
runs_dir=tmp_path / "runs")
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
cancelled = False
|
||||
attempt_sessions = []
|
||||
|
||||
def cooperative_cancel() -> None:
|
||||
nonlocal cancelled
|
||||
@@ -852,6 +938,7 @@ def test_live_runner_owns_its_actual_background_session_attempt(tmp_path, monkey
|
||||
release.set()
|
||||
|
||||
async def controlled_attempt(attempt, **kwargs):
|
||||
attempt_sessions.append(attempt.session_id)
|
||||
if phase == "agent":
|
||||
service._active_loops[attempt.session_id] = SimpleNamespace(cancel=cooperative_cancel)
|
||||
started.set()
|
||||
@@ -859,6 +946,10 @@ def test_live_runner_owns_its_actual_background_session_attempt(tmp_path, monkey
|
||||
await release.wait()
|
||||
if fails:
|
||||
return {"status": "failed", "reason": "execution failed"}
|
||||
if not cancelled:
|
||||
bus.emit(attempt.session_id, "llm_usage", {
|
||||
"iter": 1, "input_tokens": 100, "output_tokens": 10, "total_tokens": 110,
|
||||
})
|
||||
return {"status": "cancelled" if cancelled else "success", "content": "completed analysis"}
|
||||
finally:
|
||||
service._active_loops.pop(attempt.session_id, None)
|
||||
@@ -874,7 +965,7 @@ def test_live_runner_owns_its_actual_background_session_attempt(tmp_path, monkey
|
||||
driver.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await driver
|
||||
session = service.get_session(runner._session_id)
|
||||
session = service.get_session(attempt_sessions[-1])
|
||||
attempt = service.store.get_attempt(session.session_id, session.last_attempt_id)
|
||||
assert attempt.status.value == "cancelled"
|
||||
assert not service._active_tasks
|
||||
@@ -889,6 +980,7 @@ def test_live_runner_owns_its_actual_background_session_attempt(tmp_path, monkey
|
||||
result = await asyncio.wait_for(driver, 1)
|
||||
assert result["status"] == "success"
|
||||
assert result["content"] == "completed analysis"
|
||||
assert attempt_sessions[-1] != runner._session_id
|
||||
assert not bus._listeners
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
@@ -93,6 +93,26 @@ def _write_mandate(root: Path, broker: str = "robinhood", *, schema_version: int
|
||||
|
||||
|
||||
class TestConnectorLiveDispatch:
|
||||
def test_runner_settings_json_reaches_authenticated_api(self, tmp_path):
|
||||
from cli._legacy import cmd_connector_runner_settings
|
||||
|
||||
source = tmp_path / "settings.json"
|
||||
source.write_text('{"daily_at":"10:00","max_daily_turns":1}')
|
||||
with patch("cli._legacy._live_api_call", return_value={"settings": {}}) as api:
|
||||
assert cmd_connector_runner_settings("robinhood-live-mcp", str(source)) == 0
|
||||
args, kwargs = api.call_args
|
||||
assert args == ("PUT", "/live/runner/settings?broker=robinhood")
|
||||
assert kwargs["body"]["daily_at"] == "10:00"
|
||||
assert kwargs["body"]["max_daily_turns"] == 1
|
||||
|
||||
@pytest.mark.parametrize("command", ["cmd_live_start", "cmd_live_run"])
|
||||
def test_no_start_if_spending_preview_fails(self, command):
|
||||
from cli import _legacy
|
||||
|
||||
with patch("cli._legacy._live_api_call", return_value={"status": "error"}) as api:
|
||||
assert getattr(_legacy, command)("robinhood") == _legacy.EXIT_RUN_FAILED
|
||||
api.assert_called_once_with("GET", "/live/runner/settings?broker=robinhood")
|
||||
|
||||
def _dispatch(self, argv: list[str]) -> int:
|
||||
from cli._legacy import _build_parser, _dispatch_connector
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Spending, restart and scheduling boundaries use real isolated SQLite state."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.live.runtime.decision_budget import DecisionBudget, DecisionBudgetError, DecisionSettings
|
||||
|
||||
NOW = datetime(2026, 10, 15, 14, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _store(tmp_path):
|
||||
return DecisionBudget("robinhood", path=tmp_path / "budget.sqlite3")
|
||||
|
||||
|
||||
def _usage(iteration=1, tokens_in=100, tokens_out=10):
|
||||
return {"iter": iteration, "input_tokens": tokens_in, "output_tokens": tokens_out, "total_tokens": tokens_in + tokens_out}
|
||||
|
||||
|
||||
def test_cadence_survives_restart_and_keeps_prior_spend(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
settings = DecisionSettings()
|
||||
decision = budget.begin(settings, NOW)
|
||||
budget.record(decision, _usage(), settings, NOW)
|
||||
budget.finish(decision, settings, NOW)
|
||||
restarted = _store(tmp_path)
|
||||
assert restarted.begin(settings, NOW + timedelta(minutes=1)) is None
|
||||
assert restarted.begin(settings, NOW + timedelta(minutes=15))
|
||||
assert restarted.snapshot(NOW)["usage"]["tokens"] == 110
|
||||
|
||||
|
||||
def test_token_stop_persists_even_when_record_raises(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
settings = DecisionSettings(max_daily_tokens=100)
|
||||
decision = budget.begin(settings, NOW)
|
||||
with pytest.raises(DecisionBudgetError, match="token budget"):
|
||||
budget.record(decision, _usage(), settings, NOW)
|
||||
with pytest.raises(DecisionBudgetError):
|
||||
budget.finish(decision, settings, NOW)
|
||||
assert _store(tmp_path).snapshot(NOW)["usage"]["tokens"] == 110
|
||||
with pytest.raises(DecisionBudgetError, match="token budget"):
|
||||
_store(tmp_path).begin(settings, NOW + timedelta(hours=1))
|
||||
|
||||
|
||||
def test_dollar_stop_and_usage_idempotence(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
settings = DecisionSettings(input_usd_per_million=10, output_usd_per_million=20, max_daily_cost_usd=0.002)
|
||||
budget.save_settings(settings)
|
||||
decision = budget.begin(settings, NOW)
|
||||
budget.record(decision, _usage(), settings, NOW)
|
||||
budget.record(decision, _usage(), settings, NOW)
|
||||
assert budget.snapshot(NOW)["usage"]["estimated_cost_usd"] == pytest.approx(0.0012)
|
||||
with pytest.raises(DecisionBudgetError, match="cost budget"):
|
||||
budget.record(decision, _usage(iteration=2), settings, NOW)
|
||||
assert budget.snapshot(NOW)["usage"]["tokens"] == 220
|
||||
|
||||
|
||||
def test_missing_usage_and_crash_do_not_reset_budget(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
settings = DecisionSettings()
|
||||
decision = budget.begin(settings, NOW)
|
||||
with pytest.raises(DecisionBudgetError, match="unfinished"):
|
||||
_store(tmp_path).begin(settings, NOW + timedelta(days=1))
|
||||
with pytest.raises(DecisionBudgetError, match="unavailable"):
|
||||
budget.finish(decision, settings, NOW)
|
||||
with pytest.raises(DecisionBudgetError, match="unavailable"):
|
||||
_store(tmp_path).begin(settings, NOW + timedelta(hours=1))
|
||||
|
||||
|
||||
def test_daily_schedule_follows_dst_and_fires_once(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
settings = DecisionSettings(daily_at="10:00")
|
||||
before = datetime(2026, 11, 2, 14, 59, tzinfo=timezone.utc)
|
||||
assert budget.begin(settings, before) is None
|
||||
at = before + timedelta(minutes=1)
|
||||
decision = budget.begin(settings, at)
|
||||
budget.record(decision, _usage(), settings, at)
|
||||
budget.finish(decision, settings, at)
|
||||
assert budget.begin(settings, at + timedelta(hours=3)) is None
|
||||
assert budget.begin(settings, at + timedelta(days=1))
|
||||
|
||||
|
||||
def test_begin_is_atomic_across_connections(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
budget.save_settings(DecisionSettings())
|
||||
def reserve(_):
|
||||
try:
|
||||
return _store(tmp_path).begin(DecisionSettings(), NOW)
|
||||
except DecisionBudgetError:
|
||||
return None
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(reserve, range(2)))
|
||||
assert sum(bool(result) for result in results) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("values", [
|
||||
{"interval_seconds": 0}, {"interval_seconds": True}, {"max_daily_tokens": -1},
|
||||
{"max_daily_cost_usd": 1}, {"input_usd_per_million": float("nan")},
|
||||
{"daily_at": "25:00"}, {"timezone": "Not/AZone"}, {"unknown": 1},
|
||||
])
|
||||
def test_invalid_settings_refused(values):
|
||||
with pytest.raises(ValidationError):
|
||||
DecisionSettings(**values)
|
||||
|
||||
|
||||
def test_corrupt_database_is_not_an_empty_budget(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
budget.path.write_bytes(b"not sqlite")
|
||||
with pytest.raises(Exception):
|
||||
budget.begin(DecisionSettings(), NOW)
|
||||
|
||||
|
||||
def test_response_crossing_midnight_is_charged_to_new_utc_day(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
settings = DecisionSettings()
|
||||
before = NOW.replace(hour=23, minute=59)
|
||||
after = before + timedelta(minutes=2)
|
||||
decision = budget.begin(settings, before)
|
||||
budget.record(decision, _usage(), settings, after)
|
||||
budget.finish(decision, settings, after)
|
||||
assert budget.snapshot(after)["usage"]["tokens"] == 110
|
||||
assert budget.snapshot(after)["usage"]["turns"] == 0
|
||||
|
||||
|
||||
def test_missing_later_response_stays_unaccounted_after_restart(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
settings = DecisionSettings()
|
||||
decision = budget.begin(settings, NOW)
|
||||
budget.record(decision, _usage(), settings, NOW)
|
||||
with pytest.raises(DecisionBudgetError):
|
||||
budget.record(decision, {}, settings, NOW)
|
||||
with pytest.raises(DecisionBudgetError):
|
||||
budget.finish(decision, settings, NOW)
|
||||
assert budget.snapshot(NOW)["usage"]["estimated_cost_usd"] is None
|
||||
with pytest.raises(DecisionBudgetError, match="unavailable"):
|
||||
_store(tmp_path).begin(settings, NOW + timedelta(hours=1))
|
||||
|
||||
|
||||
def test_uncategorized_tokens_use_the_higher_configured_price(tmp_path):
|
||||
budget = _store(tmp_path)
|
||||
settings = DecisionSettings(input_usd_per_million=10, output_usd_per_million=20)
|
||||
budget.save_settings(settings)
|
||||
decision = budget.begin(settings, NOW)
|
||||
budget.record(decision, {**_usage(), "total_tokens": 120}, settings, NOW)
|
||||
assert budget.snapshot(NOW)["usage"]["estimated_cost_usd"] == pytest.approx(0.0014)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reported", [True, False])
|
||||
def test_real_agent_stops_before_tools_when_usage_monitor_cancels(tmp_path, reported):
|
||||
from tests.test_agent_loop_no_progress import DiscoveryLLM, DiscoveryTool, build_loop
|
||||
|
||||
tool = DiscoveryTool(['{"status":"ok"}'])
|
||||
llm = DiscoveryLLM(finish_after=1)
|
||||
original = llm.stream_chat
|
||||
def response(*args, **kwargs):
|
||||
result = original(*args, **kwargs)
|
||||
result.usage_metadata = {"input_tokens": 100, "output_tokens": 10, "total_tokens": 110} if reported else None
|
||||
return result
|
||||
llm.stream_chat = response
|
||||
agent, _, _ = build_loop(tmp_path, tool, llm)
|
||||
seen = []
|
||||
def monitor(name, data):
|
||||
if name in {"llm_usage", "llm_usage_unavailable"}:
|
||||
seen.append(name)
|
||||
agent.cancel()
|
||||
agent._event_callback = monitor
|
||||
result = agent.run(user_message="inspect the artifact")
|
||||
assert seen == ["llm_usage" if reported else "llm_usage_unavailable"]
|
||||
assert tool.calls == 0
|
||||
assert llm.calls == 1
|
||||
assert result["status"] == "cancelled"
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type LiveDecisionBudget, type LiveDecisionSettings } from "@/lib/api";
|
||||
|
||||
/** Operator-owned spending controls; the agent cannot edit these settings. */
|
||||
export function RunnerBudgetControls({ broker, budget, running, onRefresh }: {
|
||||
broker: string;
|
||||
budget?: LiveDecisionBudget | null;
|
||||
running: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState(budget?.settings);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!dirty) setSettings(budget?.settings);
|
||||
}, [budget?.settings, dirty]);
|
||||
if (!budget) return null;
|
||||
if (budget.error || !settings) return <p role="alert">{t("runnerBudget.unavailable")}</p>;
|
||||
|
||||
const update = (key: keyof LiveDecisionSettings, value: string | number | null) => {
|
||||
setSettings({ ...settings, [key]: value });
|
||||
setDirty(true);
|
||||
};
|
||||
const fields = ["interval_seconds", "max_daily_turns", "max_daily_tokens", "max_daily_cost_usd", "input_usd_per_million", "output_usd_per_million"] as const;
|
||||
const usage = budget.usage;
|
||||
return <div className="grid gap-2 rounded-lg border border-border/60 bg-card/60 p-3 text-xs">
|
||||
<p className="font-medium">{t("runnerBudget.warning")}</p>
|
||||
<p className="text-muted-foreground">{t("runnerBudget.summary", { minutes: settings.interval_seconds / 60, turns: settings.max_daily_turns, tokens: settings.max_daily_tokens })}</p>
|
||||
{settings.daily_at && <p>{t("runnerBudget.dailySummary", { time: settings.daily_at, timezone: settings.timezone })}</p>}
|
||||
{usage && <p>{t("runnerBudget.usage", { turns: usage.turns, tokens: usage.tokens, cost: usage.estimated_cost_usd == null ? "—" : `$${usage.estimated_cost_usd.toFixed(2)}` })}</p>}
|
||||
<p className="text-muted-foreground">{budget.projected_daily_cost_usd == null
|
||||
? t("runnerBudget.estimateUnknown")
|
||||
: t("runnerBudget.estimate", { cost: budget.projected_daily_cost_usd.toFixed(2) })}</p>
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">{t("runnerBudget.configure")}</summary>
|
||||
<form className="mt-3 grid gap-3" onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setSaving(true); setError(null);
|
||||
try {
|
||||
await api.saveRunnerSettings(broker, settings);
|
||||
setDirty(false); onRefresh();
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("runnerBudget.unavailable")); }
|
||||
finally { setSaving(false); }
|
||||
}}>
|
||||
<fieldset disabled={running || saving} className="grid gap-3 sm:grid-cols-2 disabled:opacity-60">
|
||||
{fields.map((key) => <label key={key} className="grid gap-1">
|
||||
{t(`runnerBudget.${key}`)}
|
||||
<input className="rounded-md border bg-background px-2 py-1.5" type="number" min={key === "interval_seconds" ? 60 : key.includes("usd") ? 0 : 1}
|
||||
step={key.includes("usd") ? "any" : 1} value={settings[key] ?? ""}
|
||||
required={!key.includes("usd")}
|
||||
onChange={(event) => update(key, event.target.value === "" && key.includes("usd") ? null : Number(event.target.value))} />
|
||||
</label>)}
|
||||
<label className="grid gap-1">{t("runnerBudget.daily_at")}<input className="rounded-md border bg-background px-2 py-1.5" type="time" value={settings.daily_at ?? ""} onChange={(e) => update("daily_at", e.target.value || null)} /></label>
|
||||
<label className="grid gap-1">{t("runnerBudget.timezone")}<input className="rounded-md border bg-background px-2 py-1.5" value={settings.timezone} onChange={(e) => update("timezone", e.target.value)} required /></label>
|
||||
<button className="rounded-md border px-3 py-2 font-medium" type="submit" disabled={!dirty}>{t("runnerBudget.save")}</button>
|
||||
</fieldset>
|
||||
<p className="text-muted-foreground">{t("runnerBudget.limitsNote")}</p>
|
||||
<p className="text-muted-foreground">{t("runnerBudget.pricesNote")}</p>
|
||||
{running && <p>{t("runnerBudget.stopToEdit")}</p>}
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
</form>
|
||||
</details>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import i18n from '@/i18n';
|
||||
import { RunnerBudgetControls } from "./RunnerBudgetControls";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
@@ -300,7 +301,7 @@ function BrokerRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleRunner}
|
||||
disabled={busy || halted || !mandate}
|
||||
disabled={busy || (!runnerAlive && (halted || !mandate || countdown.expired || !!broker.decision_budget?.error))}
|
||||
className={[
|
||||
"inline-flex items-center gap-1 rounded-lg border px-2 py-1 text-[11px] font-medium transition-colors disabled:opacity-40",
|
||||
runnerAlive
|
||||
@@ -315,6 +316,7 @@ function BrokerRow({
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{isOAuthLiveBroker(auth) && <RunnerBudgetControls broker={brokerKey} budget={broker.decision_budget} running={runnerAlive} onRefresh={onRefresh} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { RunnerBudgetControls } from "../RunnerBudgetControls";
|
||||
import { api, type LiveDecisionBudget } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/api", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||
return { ...actual, api: { ...actual.api, saveRunnerSettings: vi.fn().mockResolvedValue({}) } };
|
||||
});
|
||||
|
||||
const budget: LiveDecisionBudget = {
|
||||
settings: { interval_seconds: 900, daily_at: null, timezone: "America/New_York", max_daily_turns: 26,
|
||||
max_daily_tokens: 1000000, max_daily_cost_usd: null, input_usd_per_million: null, output_usd_per_million: null },
|
||||
usage: { day: "2026-10-15", turns: 2, tokens: 1234, estimated_cost_usd: null, unaccounted_turns: 0 },
|
||||
projected_daily_cost_usd: null,
|
||||
};
|
||||
|
||||
it("shows the spending warning and unknown cost before a start", () => {
|
||||
render(<RunnerBudgetControls broker="robinhood" budget={budget} running={false} onRefresh={vi.fn()} />);
|
||||
expect(screen.getByText(/even when no trades/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/cannot be estimated/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Every 15 minutes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves the operator's cadence without discarding their other limits", async () => {
|
||||
const user = userEvent.setup();
|
||||
const refresh = vi.fn();
|
||||
render(<RunnerBudgetControls broker="robinhood" budget={budget} running={false} onRefresh={refresh} />);
|
||||
await user.click(screen.getByText("Model schedule and spending limits"));
|
||||
const input = screen.getByLabelText("Decision interval (seconds)");
|
||||
await user.clear(input);
|
||||
await user.type(input, "1800");
|
||||
await user.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
await waitFor(() => expect(api.saveRunnerSettings).toHaveBeenCalledWith("robinhood", { ...budget.settings, interval_seconds: 1800 }));
|
||||
expect(refresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires stopping the runner before editing and displays measured projections", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RunnerBudgetControls broker="robinhood" budget={{ ...budget, projected_daily_cost_usd: 4.2 }} running onRefresh={vi.fn()} />);
|
||||
expect(screen.getByText(/about \$4.20/)).toBeInTheDocument();
|
||||
await user.click(screen.getByText("Model schedule and spending limits"));
|
||||
expect(screen.getByLabelText("Decision interval (seconds)")).toBeDisabled();
|
||||
expect(screen.getByText(/Stop the runner before editing/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "عضو خبير",
|
||||
"Lead": "قائد"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "حالة تكلفة النموذج غير متاحة.",
|
||||
"warning": "استدعاءات النموذج مدفوعة حتى دون تنفيذ صفقات.",
|
||||
"summary": "كل {{minutes}} دقيقة؛ بحد أقصى {{turns}} قرارًا و{{tokens}} رمزًا لكل يوم UTC.",
|
||||
"dailySummary": "يوميًا عند {{time}} ({{timezone}}) بدلًا من الفاصل الزمني.",
|
||||
"usage": "اليوم: {{turns}} قرارًا · {{tokens}} رمزًا · التقدير {{cost}}.",
|
||||
"estimateUnknown": "يلزم تحديد الأسعار وقياس الاستخدام لتقدير التكلفة اليومية.",
|
||||
"estimate": "التكلفة اليومية المتوقعة نحو ${{cost}} حسب الاستخدام المقاس.",
|
||||
"configure": "جدول النموذج وحدود الإنفاق",
|
||||
"interval_seconds": "فاصل القرارات بالثواني",
|
||||
"max_daily_turns": "أقصى قرارات لكل يوم UTC",
|
||||
"max_daily_tokens": "أقصى رموز لكل يوم UTC",
|
||||
"max_daily_cost_usd": "حد يومي تقديري بالدولار (اختياري)",
|
||||
"input_usd_per_million": "دولار لكل مليون رمز إدخال (اختياري)",
|
||||
"output_usd_per_million": "دولار لكل مليون رمز إخراج (اختياري)",
|
||||
"daily_at": "وقت التنفيذ اليومي (اختياري)",
|
||||
"timezone": "المنطقة الزمنية",
|
||||
"save": "حفظ الإعدادات",
|
||||
"limitsNote": "يبدأ كل قرار محادثة جديدة. تُفحص الحدود بعد الرد وقد تتجاوزها تكلفة ذلك الرد. غياب الاستخدام أو تجاوز الميزانية يفعّل مفتاح الإيقاف الحالي.",
|
||||
"pricesNote": "الأسعار تقديرات يحددها المشغّل. استخدم أعلى سعر للإدخال أو كتابة الذاكرة المؤقتة؛ لا يشمل التقدير خصوماتها أو استدعاءات النموذج المساعدة.",
|
||||
"stopToEdit": "أوقف المشغّل قبل تعديل الإعدادات."
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "Erfahren",
|
||||
"Lead": "Leitung"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "Modellkostenstatus nicht verfügbar.",
|
||||
"warning": "Modellaufrufe kosten auch ohne Handelsausführung Geld.",
|
||||
"summary": "Alle {{minutes}} Minuten; höchstens {{turns}} Entscheidungen und {{tokens}} Token pro UTC-Tag.",
|
||||
"dailySummary": "Täglich um {{time}} ({{timezone}}); ersetzt das Intervall.",
|
||||
"usage": "Heute: {{turns}} Entscheidungen · {{tokens}} Token · geschätzt {{cost}}.",
|
||||
"estimateUnknown": "Eine Tagesschätzung benötigt Preise und gemessenen Verbrauch.",
|
||||
"estimate": "Geschätzte tägliche Modellkosten: etwa ${{cost}}, anhand des Verbrauchs.",
|
||||
"configure": "Modellzeitplan und Kostenlimits",
|
||||
"interval_seconds": "Entscheidungsintervall (Sekunden)",
|
||||
"max_daily_turns": "Maximale Entscheidungen pro UTC-Tag",
|
||||
"max_daily_tokens": "Maximale Token pro UTC-Tag",
|
||||
"max_daily_cost_usd": "Geschätztes USD-Tageslimit (optional)",
|
||||
"input_usd_per_million": "USD pro Million Eingabetoken (optional)",
|
||||
"output_usd_per_million": "USD pro Million Ausgabetoken (optional)",
|
||||
"daily_at": "Tägliche Uhrzeit (optional)",
|
||||
"timezone": "Zeitzone des Zeitplans",
|
||||
"save": "Einstellungen speichern",
|
||||
"limitsNote": "Jede Entscheidung beginnt eine neue Unterhaltung. Limits werden nach der Antwort geprüft und können um diese Antwort überschritten werden. Fehlender Verbrauch oder eine Überschreitung löst den vorhandenen Notstopp aus.",
|
||||
"pricesNote": "Preise sind Betreiberschätzungen. Den höchsten Eingabe-/Cache-Schreibpreis verwenden; Cache-Rabatte und zusätzliche Modellaufrufe sind nicht enthalten.",
|
||||
"stopToEdit": "Vor Änderungen den Runner stoppen."
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "Senior",
|
||||
"Lead": "Lead"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "Model spending state is unavailable.",
|
||||
"warning": "Model calls cost money even when no trades are placed.",
|
||||
"summary": "Every {{minutes}} minutes; up to {{turns}} decisions and {{tokens}} tokens per UTC day.",
|
||||
"dailySummary": "Daily schedule: {{time}} ({{timezone}}); replaces the interval.",
|
||||
"usage": "Today: {{turns}} decisions · {{tokens}} tokens · estimated {{cost}}.",
|
||||
"estimateUnknown": "Daily cost cannot be estimated until prices and measured usage are available.",
|
||||
"estimate": "Projected daily model cost: about ${{cost}}, based on measured usage.",
|
||||
"configure": "Model schedule and spending limits",
|
||||
"interval_seconds": "Decision interval (seconds)",
|
||||
"max_daily_turns": "Maximum decisions per UTC day",
|
||||
"max_daily_tokens": "Maximum tokens per UTC day",
|
||||
"max_daily_cost_usd": "Estimated USD limit per day (optional)",
|
||||
"input_usd_per_million": "Input USD per million tokens (optional)",
|
||||
"output_usd_per_million": "Output USD per million tokens (optional)",
|
||||
"daily_at": "Once daily at (optional)",
|
||||
"timezone": "Schedule timezone",
|
||||
"save": "Save settings",
|
||||
"limitsNote": "Each decision starts a fresh conversation. Usage limits stop after a response and may overshoot by that response. Missing usage or an exceeded budget trips the existing kill switch.",
|
||||
"pricesNote": "Prices are operator estimates. Use the highest applicable input/cache-write rate; cache discounts and auxiliary model calls are not included in the estimate.",
|
||||
"stopToEdit": "Stop the runner before editing these settings."
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "Sénior",
|
||||
"Lead": "Responsable"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "No se puede leer el gasto del modelo.",
|
||||
"warning": "Las llamadas al modelo cuestan dinero aunque no haya operaciones.",
|
||||
"summary": "Cada {{minutes}} minutos; hasta {{turns}} decisiones y {{tokens}} tokens por día UTC.",
|
||||
"dailySummary": "Una vez al día a las {{time}} ({{timezone}}); sustituye al intervalo.",
|
||||
"usage": "Hoy: {{turns}} decisiones · {{tokens}} tokens · estimado {{cost}}.",
|
||||
"estimateUnknown": "Se necesitan tarifas y uso medido para estimar el coste diario.",
|
||||
"estimate": "Coste diario proyectado: unos ${{cost}}, según el uso medido.",
|
||||
"configure": "Horario y límites de gasto del modelo",
|
||||
"interval_seconds": "Intervalo de decisión (segundos)",
|
||||
"max_daily_turns": "Decisiones máximas por día UTC",
|
||||
"max_daily_tokens": "Tokens máximos por día UTC",
|
||||
"max_daily_cost_usd": "Límite diario estimado en USD (opcional)",
|
||||
"input_usd_per_million": "USD por millón de tokens de entrada (opcional)",
|
||||
"output_usd_per_million": "USD por millón de tokens de salida (opcional)",
|
||||
"daily_at": "Hora diaria (opcional)",
|
||||
"timezone": "Zona horaria",
|
||||
"save": "Guardar ajustes",
|
||||
"limitsNote": "Cada decisión usa una conversación nueva. Los límites se comprueban tras la respuesta y pueden excederse en ese importe. El uso desconocido o un exceso activa el interruptor de parada existente.",
|
||||
"pricesNote": "Las tarifas son estimaciones del operador. Use la mayor tarifa de entrada/escritura en caché; se excluyen los descuentos de caché y las llamadas auxiliares.",
|
||||
"stopToEdit": "Detenga el ejecutor antes de cambiar estos ajustes."
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "عضو ارشد",
|
||||
"Lead": "سرپرست"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "وضعیت هزینه مدل در دسترس نیست.",
|
||||
"warning": "فراخوانی مدل حتی بدون معامله هزینه دارد.",
|
||||
"summary": "هر {{minutes}} دقیقه؛ حداکثر {{turns}} تصمیم و {{tokens}} توکن در هر روز UTC.",
|
||||
"dailySummary": "روزانه ساعت {{time}} ({{timezone}})؛ جایگزین فاصله زمانی.",
|
||||
"usage": "امروز: {{turns}} تصمیم · {{tokens}} توکن · برآورد {{cost}}.",
|
||||
"estimateUnknown": "برآورد روزانه به نرخها و مصرف اندازهگیریشده نیاز دارد.",
|
||||
"estimate": "هزینه روزانه مدل بر پایه مصرف: حدود ${{cost}}.",
|
||||
"configure": "زمانبندی مدل و سقف هزینه",
|
||||
"interval_seconds": "فاصله تصمیمها (ثانیه)",
|
||||
"max_daily_turns": "حداکثر تصمیم در روز UTC",
|
||||
"max_daily_tokens": "حداکثر توکن در روز UTC",
|
||||
"max_daily_cost_usd": "سقف برآورد روزانه دلار (اختیاری)",
|
||||
"input_usd_per_million": "دلار برای یک میلیون توکن ورودی (اختیاری)",
|
||||
"output_usd_per_million": "دلار برای یک میلیون توکن خروجی (اختیاری)",
|
||||
"daily_at": "ساعت روزانه (اختیاری)",
|
||||
"timezone": "منطقه زمانی",
|
||||
"save": "ذخیره تنظیمات",
|
||||
"limitsNote": "هر تصمیم گفتوگوی تازهای آغاز میکند. سقفها پس از پاسخ بررسی میشوند و ممکن است به اندازه آن پاسخ عبور کنند. نبود آمار مصرف یا عبور از بودجه کلید توقف موجود را فعال میکند.",
|
||||
"pricesNote": "نرخها برآورد اپراتورند. بالاترین نرخ ورودی/نوشتن حافظه نهان را وارد کنید؛ تخفیف حافظه نهان و فراخوانیهای کمکی محاسبه نمیشوند.",
|
||||
"stopToEdit": "پیش از تغییر تنظیمات اجراکننده را متوقف کنید."
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "Senior",
|
||||
"Lead": "Pemimpin"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "Status biaya model tidak tersedia.",
|
||||
"warning": "Panggilan model tetap berbiaya meski tidak ada transaksi.",
|
||||
"summary": "Setiap {{minutes}} menit; maksimum {{turns}} keputusan dan {{tokens}} token per hari UTC.",
|
||||
"dailySummary": "Setiap hari pukul {{time}} ({{timezone}}); menggantikan interval.",
|
||||
"usage": "Hari ini: {{turns}} keputusan · {{tokens}} token · perkiraan {{cost}}.",
|
||||
"estimateUnknown": "Harga dan penggunaan terukur diperlukan untuk perkiraan biaya harian.",
|
||||
"estimate": "Perkiraan biaya model harian: sekitar ${{cost}}, berdasarkan penggunaan.",
|
||||
"configure": "Jadwal dan batas biaya model",
|
||||
"interval_seconds": "Interval keputusan (detik)",
|
||||
"max_daily_turns": "Keputusan maksimum per hari UTC",
|
||||
"max_daily_tokens": "Token maksimum per hari UTC",
|
||||
"max_daily_cost_usd": "Batas perkiraan USD harian (opsional)",
|
||||
"input_usd_per_million": "USD per juta token masukan (opsional)",
|
||||
"output_usd_per_million": "USD per juta token keluaran (opsional)",
|
||||
"daily_at": "Waktu harian (opsional)",
|
||||
"timezone": "Zona waktu jadwal",
|
||||
"save": "Simpan pengaturan",
|
||||
"limitsNote": "Setiap keputusan memakai percakapan baru. Batas diperiksa setelah respons dan dapat terlampaui sebesar respons itu. Penggunaan yang hilang atau anggaran terlampaui memicu sakelar penghentian yang ada.",
|
||||
"pricesNote": "Harga adalah perkiraan operator. Gunakan tarif masukan/penulisan cache tertinggi; diskon cache dan panggilan tambahan tidak termasuk.",
|
||||
"stopToEdit": "Hentikan runner sebelum mengubah pengaturan."
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "シニア",
|
||||
"Lead": "リーダー"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "モデル費用の状態を取得できません。",
|
||||
"warning": "取引がなくてもモデル呼び出しには費用がかかります。",
|
||||
"summary": "{{minutes}} 分間隔。UTC 日ごとに最大 {{turns}} 回、{{tokens}} トークン。",
|
||||
"dailySummary": "毎日 {{time}}({{timezone}})に実行し、間隔設定を置き換えます。",
|
||||
"usage": "今日:{{turns}} 回 · {{tokens}} トークン · 推定 {{cost}}。",
|
||||
"estimateUnknown": "料金と実測使用量が揃うまで日額を推定できません。",
|
||||
"estimate": "実測使用量に基づく日額:約 ${{cost}}。",
|
||||
"configure": "モデルの実行予定と費用上限",
|
||||
"interval_seconds": "判断間隔(秒)",
|
||||
"max_daily_turns": "UTC 日ごとの判断回数上限",
|
||||
"max_daily_tokens": "UTC 日ごとのトークン上限",
|
||||
"max_daily_cost_usd": "推定日額上限 USD(任意)",
|
||||
"input_usd_per_million": "入力100万トークンの USD 料金(任意)",
|
||||
"output_usd_per_million": "出力100万トークンの USD 料金(任意)",
|
||||
"daily_at": "毎日の実行時刻(任意)",
|
||||
"timezone": "実行タイムゾーン",
|
||||
"save": "設定を保存",
|
||||
"limitsNote": "判断ごとに新しい会話を使います。応答後に上限を確認するため、その応答分を超過する場合があります。使用量不明や超過時は既存の停止スイッチが作動します。",
|
||||
"pricesNote": "料金は利用者が設定する推定値です。入力・キャッシュ書込の最高料金を使ってください。キャッシュ割引と補助モデル呼び出しは推定に含みません。",
|
||||
"stopToEdit": "変更する前にランナーを停止してください。"
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "선임",
|
||||
"Lead": "책임자"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "모델 비용 상태를 불러올 수 없습니다.",
|
||||
"warning": "거래가 없어도 모델 호출에는 비용이 발생합니다.",
|
||||
"summary": "{{minutes}}분 간격. UTC 일별 최대 {{turns}}회 결정, {{tokens}} 토큰.",
|
||||
"dailySummary": "매일 {{time}}({{timezone}})에 실행하며 간격 설정을 대체합니다.",
|
||||
"usage": "오늘: {{turns}}회 · {{tokens}} 토큰 · 추정 {{cost}}.",
|
||||
"estimateUnknown": "요금과 실제 사용량이 있어야 일일 비용을 추정할 수 있습니다.",
|
||||
"estimate": "실제 사용량 기준 일일 모델 비용: 약 ${{cost}}.",
|
||||
"configure": "모델 일정 및 비용 한도",
|
||||
"interval_seconds": "결정 간격(초)",
|
||||
"max_daily_turns": "UTC 일별 최대 결정 횟수",
|
||||
"max_daily_tokens": "UTC 일별 최대 토큰",
|
||||
"max_daily_cost_usd": "일일 추정 USD 한도(선택)",
|
||||
"input_usd_per_million": "입력 100만 토큰당 USD(선택)",
|
||||
"output_usd_per_million": "출력 100만 토큰당 USD(선택)",
|
||||
"daily_at": "매일 실행 시간(선택)",
|
||||
"timezone": "일정 시간대",
|
||||
"save": "설정 저장",
|
||||
"limitsNote": "결정마다 새 대화를 사용합니다. 응답 후 사용량을 확인하므로 해당 응답만큼 초과할 수 있습니다. 사용량 누락이나 예산 초과 시 기존 중지 스위치가 작동합니다.",
|
||||
"pricesNote": "요금은 운영자 추정값입니다. 입력/캐시 쓰기의 가장 높은 요금을 사용하세요. 캐시 할인과 보조 모델 호출은 추정에 포함되지 않습니다.",
|
||||
"stopToEdit": "설정을 변경하기 전에 실행기를 중지하세요."
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "Sênior",
|
||||
"Lead": "Líder"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "Estado de custos do modelo indisponível.",
|
||||
"warning": "Chamadas ao modelo custam dinheiro mesmo sem operações.",
|
||||
"summary": "A cada {{minutes}} minutos; até {{turns}} decisões e {{tokens}} tokens por dia UTC.",
|
||||
"dailySummary": "Diariamente às {{time}} ({{timezone}}); substitui o intervalo.",
|
||||
"usage": "Hoje: {{turns}} decisões · {{tokens}} tokens · estimativa {{cost}}.",
|
||||
"estimateUnknown": "São necessários preços e uso medido para estimar o custo diário.",
|
||||
"estimate": "Custo diário projetado: cerca de ${{cost}}, conforme o uso medido.",
|
||||
"configure": "Agenda e limites de gastos do modelo",
|
||||
"interval_seconds": "Intervalo de decisão (segundos)",
|
||||
"max_daily_turns": "Máximo de decisões por dia UTC",
|
||||
"max_daily_tokens": "Máximo de tokens por dia UTC",
|
||||
"max_daily_cost_usd": "Limite diário estimado em USD (opcional)",
|
||||
"input_usd_per_million": "USD por milhão de tokens de entrada (opcional)",
|
||||
"output_usd_per_million": "USD por milhão de tokens de saída (opcional)",
|
||||
"daily_at": "Horário diário (opcional)",
|
||||
"timezone": "Fuso horário",
|
||||
"save": "Salvar configurações",
|
||||
"limitsNote": "Cada decisão usa uma nova conversa. Os limites são verificados após a resposta e podem exceder pelo valor dessa resposta. Uso desconhecido ou excesso aciona a parada de emergência existente.",
|
||||
"pricesNote": "Os preços são estimativas do operador. Use a maior tarifa de entrada/gravação em cache; descontos de cache e chamadas auxiliares não estão incluídos.",
|
||||
"stopToEdit": "Pare o executor antes de alterar as configurações."
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "Kıdemli",
|
||||
"Lead": "Lider"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "Model harcama durumu okunamıyor.",
|
||||
"warning": "İşlem yapılmasa da model çağrıları ücretlidir.",
|
||||
"summary": "{{minutes}} dakikada bir; UTC günü başına en fazla {{turns}} karar ve {{tokens}} token.",
|
||||
"dailySummary": "Her gün {{time}} ({{timezone}}); aralık ayarının yerini alır.",
|
||||
"usage": "Bugün: {{turns}} karar · {{tokens}} token · tahmini {{cost}}.",
|
||||
"estimateUnknown": "Günlük tahmin için fiyatlar ve ölçülen kullanım gerekir.",
|
||||
"estimate": "Ölçülen kullanıma göre günlük tahmini model maliyeti: ${{cost}}.",
|
||||
"configure": "Model zamanlaması ve harcama sınırları",
|
||||
"interval_seconds": "Karar aralığı (saniye)",
|
||||
"max_daily_turns": "UTC günü başına azami karar",
|
||||
"max_daily_tokens": "UTC günü başına azami token",
|
||||
"max_daily_cost_usd": "Günlük tahmini USD sınırı (isteğe bağlı)",
|
||||
"input_usd_per_million": "Milyon girdi tokenı başına USD (isteğe bağlı)",
|
||||
"output_usd_per_million": "Milyon çıktı tokenı başına USD (isteğe bağlı)",
|
||||
"daily_at": "Günlük saat (isteğe bağlı)",
|
||||
"timezone": "Zamanlama saat dilimi",
|
||||
"save": "Ayarları kaydet",
|
||||
"limitsNote": "Her karar yeni bir konuşma başlatır. Sınırlar yanıttan sonra kontrol edilir ve o yanıt kadar aşılabilir. Eksik kullanım veya aşım mevcut durdurma anahtarını tetikler.",
|
||||
"pricesNote": "Fiyatlar operatör tahminidir. En yüksek girdi/önbellek yazma ücretini kullanın; önbellek indirimleri ve yardımcı çağrılar dahil değildir.",
|
||||
"stopToEdit": "Ayarları değiştirmeden önce çalıştırıcıyı durdurun."
|
||||
}
|
||||
}
|
||||
@@ -2445,5 +2445,27 @@
|
||||
"Senior": "资深成员",
|
||||
"Lead": "负责人"
|
||||
}
|
||||
},
|
||||
"runnerBudget": {
|
||||
"unavailable": "无法读取模型费用状态。",
|
||||
"warning": "即使没有成交,调用模型也会产生费用。",
|
||||
"summary": "每 {{minutes}} 分钟决策一次;每个 UTC 日最多 {{turns}} 次决策、{{tokens}} 个 token。",
|
||||
"dailySummary": "每日 {{time}}({{timezone}})执行一次,替代间隔调度。",
|
||||
"usage": "今日:{{turns}} 次决策 · {{tokens}} 个 token · 估算 {{cost}}。",
|
||||
"estimateUnknown": "配置费率并取得实际用量后,才能估算每日费用。",
|
||||
"estimate": "按实测用量估算,每日模型费用约 ${{cost}}。",
|
||||
"configure": "模型调度和费用限制",
|
||||
"interval_seconds": "决策间隔(秒)",
|
||||
"max_daily_turns": "每个 UTC 日的决策次数上限",
|
||||
"max_daily_tokens": "每个 UTC 日的 token 上限",
|
||||
"max_daily_cost_usd": "每日估算美元上限(可选)",
|
||||
"input_usd_per_million": "每百万输入 token 美元费率(可选)",
|
||||
"output_usd_per_million": "每百万输出 token 美元费率(可选)",
|
||||
"daily_at": "每日执行时间(可选)",
|
||||
"timezone": "调度时区",
|
||||
"save": "保存设置",
|
||||
"limitsNote": "每次决策使用新会话。收到响应后检查用量,可能超出该次响应的费用。用量缺失或预算超限会触发现有停机开关。",
|
||||
"pricesNote": "费率由用户配置。输入使用适用的最高输入/缓存写入费率;估算不包含缓存折扣和辅助模型调用。",
|
||||
"stopToEdit": "请先停止 runner,再修改设置。"
|
||||
}
|
||||
}
|
||||
@@ -743,6 +743,11 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ broker }),
|
||||
}),
|
||||
saveRunnerSettings: (broker: string, settings: LiveDecisionSettings) =>
|
||||
request<LiveDecisionBudget>(`/live/runner/settings?broker=${encodeURIComponent(broker)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(settings),
|
||||
}),
|
||||
stopLiveRunner: (broker: string) =>
|
||||
request<LiveRunnerResponse>("/live/runner/stop", {
|
||||
method: "POST",
|
||||
@@ -2024,6 +2029,25 @@ export interface LiveBrokerStatus {
|
||||
mandate?: LiveMandateStatus | null;
|
||||
runner: LiveRunnerLiveness;
|
||||
halted: boolean;
|
||||
decision_budget?: LiveDecisionBudget | null;
|
||||
}
|
||||
|
||||
export interface LiveDecisionSettings {
|
||||
interval_seconds: number;
|
||||
daily_at: string | null;
|
||||
timezone: string;
|
||||
max_daily_turns: number;
|
||||
max_daily_tokens: number;
|
||||
max_daily_cost_usd: number | null;
|
||||
input_usd_per_million: number | null;
|
||||
output_usd_per_million: number | null;
|
||||
}
|
||||
|
||||
export interface LiveDecisionBudget {
|
||||
settings?: LiveDecisionSettings;
|
||||
usage?: { day: string; turns: number; tokens: number; estimated_cost_usd: number | null; unaccounted_turns: number };
|
||||
projected_daily_cost_usd?: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Response of `GET /live/status` (SPEC §7.5 runner status panel + C2). */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { RunnerBudgetControls } from "@/components/chat/RunnerBudgetControls";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
@@ -293,6 +294,7 @@ function BrokerRuntimeCard({
|
||||
<p className="text-sm text-muted-foreground">{risk.description}</p>
|
||||
</RuntimePanel>
|
||||
</div>
|
||||
<div className="mt-3"><RunnerBudgetControls broker={brokerKey} budget={broker.decision_budget} running={runnerAlive} onRefresh={() => { void onRefresh(); }} /></div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user