fix(live): bound model spending and isolate decision context (#1854)

This commit is contained in:
Haozhe Wu committed 2026-10-15 16:53:22 +08:00
1 parent d7f1b9e22b
commit 66aed999c4
26 files changed
+1088 -4

No files matched your search

+32
View File
@@ -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
+11
View File
@@ -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`.
+56
View File
@@ -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)
+6
View File
@@ -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
+94 -2
View File
@@ -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)."""
+217
View File
@@ -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}
+3
View File
@@ -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",
+93 -1
View File
@@ -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()
+20
View File
@@ -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
+173
View File
@@ -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();
});
+22
View File
@@ -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": "أوقف المشغّل قبل تعديل الإعدادات."
}
}
+22
View File
@@ -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."
}
}
+22
View File
@@ -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."
}
}
+22
View File
@@ -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."
}
}
+22
View File
@@ -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": "پیش از تغییر تنظیمات اجراکننده را متوقف کنید."
}
}
+22
View File
@@ -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."
}
}
+22
View File
@@ -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": "変更する前にランナーを停止してください。"
}
}
+22
View File
@@ -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": "설정을 변경하기 전에 실행기를 중지하세요."
}
}
+22
View File
@@ -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."
}
}
+22
View File
@@ -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."
}
}
+22
View File
@@ -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,再修改设置。"
}
}
+24
View File
@@ -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). */
+2
View File
@@ -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>
);
}