feat(budget): enforce a global portfolio token cap before the call, not after it (S3.4/F10)
PortfolioBudget + PortfolioMeter carry ONE token ledger over a whole portfolio pass -- and, seeded from a persisted spend file, across passes -- while the per-run Budget/TokenMeter pair is untouched. Three enforcement points, each doing a different job: - startup: a remainder that cannot fund one run raises BudgetRefused before anything loads (a pass that can afford zero projects is a caller mistake, not a result); - wave assembly: an unfundable project is NEVER STARTED and the pass stops structurally (budget_stop + stopped_early, completed runs preserved). Because every member of a wave is funded against the SAME pre-wave remainder, admission RESERVES each member's requirement -- otherwise a wave of k over-commits the cap by up to k runs; - pre-call: BudgetMiddleware refuses a call the remainder cannot pay for instead of making it. The post-charge check stays: real usage is only knowable after the response, so the guard stops the NEXT call, never the one in flight. budget_stop is its own field rather than a widened stop_reason -- a goal-stop is success, this is resource exhaustion, and fusing them would make "we stopped" unreadable. PortfolioMeter splits record/check so tokens the provider already billed reach the ledger even when the same charge breaks the run's own cap. read_spend raises on corrupt content (our own accounting state, unlike the tolerant RAW inbox layer); write_spend takes a REQUIRED stamp with no wall-clock default, mirroring promote_verdict. Load-bearing MEASURED, not asserted -- 6 mutations, all red: detach the wave check; detach the pre-call guard; detach the wave reservation; check the run cap before crediting the global ledger; detach the startup refusal; make read_spend tolerant. Files restored from shasum-verified copies after each. Two findings worth keeping: the pre-call guard MASKS a detached wave check if the test asserts on overspend (spend stays under the cap either way), so the load-bearing assertion had to become failures == () plus never-started; and the token arithmetic is probed (32 tokens/run at tokens=8), not guessed. 537 -> 553 tests, ruff + mypy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EaxFnaDAbMQkmTeX4u7sd
This commit is contained in:
parent
0d50ab89d3
commit
a831aa1e3b
6 changed files with 733 additions and 11 deletions
|
|
@ -15,6 +15,8 @@ from portfolio_optimiser.budget import (
|
|||
Budget,
|
||||
BudgetExceeded,
|
||||
BudgetMiddleware,
|
||||
PortfolioBudget,
|
||||
PortfolioMeter,
|
||||
TokenMeter,
|
||||
UsageUnavailable,
|
||||
)
|
||||
|
|
@ -85,6 +87,90 @@ async def test_budget_middleware_fires_on_real_agent_chat(make_client_factory) -
|
|||
assert meter.tokens == 8 # charged from the synthetic UsageDetails via the middleware
|
||||
|
||||
|
||||
async def test_pre_call_guard_does_not_await_call_next_when_exhausted() -> None:
|
||||
"""S3.4 unit arm of the pre-call guard: the middleware refuses BEFORE ``call_next``. Counting
|
||||
the awaits is the whole measurement — the post-charge arm raises either way."""
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _counted() -> None:
|
||||
calls["n"] += 1
|
||||
|
||||
meter = TokenMeter(Budget(max_tokens=10, max_rounds=10))
|
||||
meter.charge(10) # exactly at the cap -> nothing left to fund a call with
|
||||
mw = BudgetMiddleware(meter)
|
||||
with pytest.raises(BudgetExceeded):
|
||||
await mw.process(_Ctx(_resp(5)), _counted) # type: ignore[arg-type]
|
||||
assert calls["n"] == 0
|
||||
|
||||
|
||||
async def test_below_the_cap_still_calls_through() -> None:
|
||||
"""Control for the guard: with budget left, the call goes through exactly as before — the
|
||||
guard must gate exhaustion, not traffic."""
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _counted() -> None:
|
||||
calls["n"] += 1
|
||||
|
||||
meter = TokenMeter(Budget(max_tokens=10, max_rounds=10))
|
||||
mw = BudgetMiddleware(meter)
|
||||
await mw.process(_Ctx(_resp(4)), _counted) # type: ignore[arg-type]
|
||||
assert calls["n"] == 1 and meter.tokens == 4 and meter.remaining() == 6
|
||||
|
||||
|
||||
def test_portfolio_budget_rejects_unusable_configs() -> None:
|
||||
"""Fail-fast at construction (mirroring ``Budget``): non-positive caps, a per-run cap larger
|
||||
than the global one (one run could then cross the pass's own ceiling), and a reserve larger
|
||||
than a run can ever spend (which would refuse every pass forever)."""
|
||||
with pytest.raises(ValueError):
|
||||
PortfolioBudget(max_total_tokens=0, max_tokens_per_run=10)
|
||||
with pytest.raises(ValueError):
|
||||
PortfolioBudget(max_total_tokens=100, max_tokens_per_run=0)
|
||||
with pytest.raises(ValueError):
|
||||
PortfolioBudget(max_total_tokens=100, max_tokens_per_run=200)
|
||||
with pytest.raises(ValueError):
|
||||
PortfolioBudget(max_total_tokens=100, max_tokens_per_run=50, min_run_reserve=60)
|
||||
with pytest.raises(ValueError):
|
||||
PortfolioMeter(PortfolioBudget(max_total_tokens=100, max_tokens_per_run=50), spent=-1)
|
||||
|
||||
|
||||
def test_portfolio_meter_accumulates_and_crosses() -> None:
|
||||
budget = PortfolioBudget(max_total_tokens=100, max_tokens_per_run=50)
|
||||
meter = PortfolioMeter(budget, spent=40)
|
||||
assert meter.remaining() == 60 and meter.required_per_run == 50
|
||||
meter.record(60)
|
||||
meter.check() # exactly at the cap is still within it (mirrors TokenMeter's `>` boundary)
|
||||
meter.record(1)
|
||||
with pytest.raises(BudgetExceeded) as exc:
|
||||
meter.check()
|
||||
assert exc.value.kind == "portfolio_tokens"
|
||||
assert exc.value.limit == 100 and exc.value.observed == 101
|
||||
|
||||
|
||||
async def test_run_meter_bound_to_portfolio_charges_both_ledgers() -> None:
|
||||
"""A bound run meter charges the global ledger on every call, and ``remaining`` reads whichever
|
||||
cap binds — that is what lets one project's spend refuse another's next call."""
|
||||
portfolio = PortfolioMeter(PortfolioBudget(max_total_tokens=100, max_tokens_per_run=80))
|
||||
meter = TokenMeter(Budget(max_tokens=80, max_rounds=10), portfolio=portfolio)
|
||||
mw = BudgetMiddleware(meter)
|
||||
await mw.process(_Ctx(_resp(30)), _noop) # type: ignore[arg-type]
|
||||
assert meter.tokens == 30 and portfolio.spent == 30
|
||||
assert meter.remaining() == 50 # run has 50 left, portfolio 70 -> the RUN binds
|
||||
await mw.process(_Ctx(_resp(45)), _noop) # type: ignore[arg-type]
|
||||
assert meter.remaining() == 5 # run 5, portfolio 25 -> still the run
|
||||
|
||||
|
||||
async def test_spend_is_ledgered_even_when_the_per_run_cap_raises() -> None:
|
||||
"""Order matters: tokens the provider already billed must reach the global ledger even though
|
||||
the RUN's own cap raises on the same charge. Checking the run cap first and returning early
|
||||
would lose that spend and hand the next project a budget that was never really there."""
|
||||
portfolio = PortfolioMeter(PortfolioBudget(max_total_tokens=1000, max_tokens_per_run=50))
|
||||
meter = TokenMeter(Budget(max_tokens=50, max_rounds=10), portfolio=portfolio)
|
||||
with pytest.raises(BudgetExceeded) as exc:
|
||||
meter.charge(60)
|
||||
assert exc.value.kind == "tokens" # the RUN's cap is the one that broke
|
||||
assert portfolio.spent == 60 # ...and the global ledger still saw the spend
|
||||
|
||||
|
||||
def test_no_word_count_token_proxy_in_src() -> None:
|
||||
# The meter is fed from real UsageDetails, never a len(text.split()) word-count proxy
|
||||
# (research 03 Rec 3 — the Fase 1 _word_tokens anti-pattern is retired). NOTE: a bare
|
||||
|
|
|
|||
302
tests/test_portfolio_budget_loadbearing.py
Normal file
302
tests/test_portfolio_budget_loadbearing.py
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
"""S3.4 load-bearing — the GLOBAL token cap across a portfolio pass (F10).
|
||||
|
||||
Two properties the session plan names as RED conditions, plus the wave-size and control arms
|
||||
that keep them honest:
|
||||
|
||||
1. **The global cap stops the pass mid-way, with completed runs preserved.** Detach the
|
||||
wave-assembly funding check and the third project is STARTED anyway — it then dies on the
|
||||
meter mid-run, so the pass reports a failure instead of a structured stop. The property is
|
||||
never-started, not merely never-overspent: a project that cannot be funded must cost zero
|
||||
model round-trips.
|
||||
2. **The pre-call guard refuses the call BEFORE it is made.** The client double counts its own
|
||||
invocations, so "the call never happened" is measured, not inferred. Today's post-charge
|
||||
middleware would let the call through and only then raise — which is exactly the money the
|
||||
guard exists to not spend.
|
||||
|
||||
Token arithmetic is MEASURED, not guessed: every project in the reference portfolio spends
|
||||
4 chat calls x ``tokens`` per reply. At ``tokens=8`` that is a flat 32 tokens per run (probed
|
||||
against ``provenance.token_usage`` before these tests were written), which is what makes the
|
||||
80/64/32 budget below land the stop between run 2 and run 3.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent
|
||||
|
||||
from portfolio_optimiser.budget import (
|
||||
Budget,
|
||||
BudgetExceeded,
|
||||
BudgetMiddleware,
|
||||
BudgetRefused,
|
||||
PortfolioBudget,
|
||||
PortfolioMeter,
|
||||
TokenMeter,
|
||||
read_spend,
|
||||
write_spend,
|
||||
)
|
||||
from portfolio_optimiser.run import run_portfolio
|
||||
|
||||
_PORTFOLIO_IDS = ["FV42-GSV-E1", "RV13-RAS-TP", "BRU-LAKS-REHAB"]
|
||||
|
||||
# Per-project replies (the tested constants of tests/test_portfolio.py, unchanged): all three
|
||||
# validate, and their claimed savings are DISTINCT — which is how a run is keyed back to its
|
||||
# project here, since ``RunResult`` carries no project id. 200000 + 130000 = the first two.
|
||||
REPLIES = {
|
||||
"FV42-GSV-E1": (
|
||||
'{"measure":"Reduce scope","affected_items":['
|
||||
'{"code":"05.2","quantity":4300,"unit_cost":215},'
|
||||
'{"code":"03.1","quantity":1800,"unit_cost":310}],"claimed_saving_nok":200000}'
|
||||
),
|
||||
"RV13-RAS-TP": (
|
||||
'{"measure":"Material substitution","affected_items":['
|
||||
'{"code":"88.2","quantity":180,"unit_cost":4200}],"claimed_saving_nok":130000}'
|
||||
),
|
||||
"BRU-LAKS-REHAB": (
|
||||
'{"measure":"Reduce scope","affected_items":['
|
||||
'{"code":"05.2","quantity":4300,"unit_cost":215},'
|
||||
'{"code":"07.4","quantity":2400,"unit_cost":690}],"claimed_saving_nok":210000}'
|
||||
),
|
||||
}
|
||||
_FIRST_TWO_SAVING = 330000
|
||||
|
||||
# Measured: 32 tokens per run at tokens=8. 80 total funds two runs (32 + 32 = 64) and leaves 16,
|
||||
# which is below the 32-token reserve one more run requires -> the third is never started.
|
||||
_TOTAL = 80
|
||||
_PER_RUN = 64
|
||||
_RESERVE = 32
|
||||
_PER_RUN_SPEND = 32
|
||||
|
||||
|
||||
def _budget() -> PortfolioBudget:
|
||||
return PortfolioBudget(
|
||||
max_total_tokens=_TOTAL, max_tokens_per_run=_PER_RUN, min_run_reserve=_RESERVE
|
||||
)
|
||||
|
||||
|
||||
async def test_global_cap_stops_the_pass_mid_way_with_completed_runs_preserved(
|
||||
make_portfolio_client_factory, fresh_store
|
||||
) -> None:
|
||||
"""RED 1: the pass stops the moment the REMAINING global budget cannot fund another run, and
|
||||
every completed run survives the stop.
|
||||
|
||||
Detach the wave-assembly funding check and this goes red three ways at once: project 3 is
|
||||
started (so it lands in ``failures`` when its charge crosses the cap), ``stopped_early`` stays
|
||||
False, and ``budget_stop`` stays None. The strongest of the three is ``failures == ()``: an
|
||||
unfunded project that is merely *interrupted* has already cost model round-trips, which is the
|
||||
precise thing a global cap is for."""
|
||||
meter = PortfolioMeter(_budget())
|
||||
result = await run_portfolio(
|
||||
_PORTFOLIO_IDS,
|
||||
"local",
|
||||
store=fresh_store,
|
||||
client_factory=make_portfolio_client_factory(REPLIES, tokens=8),
|
||||
portfolio_meter=meter,
|
||||
)
|
||||
|
||||
assert result.stopped_early is True
|
||||
assert result.budget_stop is not None
|
||||
assert result.budget_stop.limit_tokens == _TOTAL
|
||||
assert result.budget_stop.spent_tokens == 2 * _PER_RUN_SPEND
|
||||
assert result.budget_stop.remaining_tokens == _TOTAL - 2 * _PER_RUN_SPEND
|
||||
assert result.budget_stop.required_tokens == _RESERVE
|
||||
|
||||
# Completed runs preserved, and the unfunded project NEVER STARTED: absent from BOTH sides of
|
||||
# the runs/failures partition, not merely absent from runs. A project that had been started and
|
||||
# then killed by the meter would land in ``failures`` — which is what makes ``== ()`` the
|
||||
# sharpest assertion here. The claimed-saving sum names WHICH two ran (the replies are distinct).
|
||||
assert len(result.runs) == 2
|
||||
assert result.failures == ()
|
||||
assert result.validated_count == 2
|
||||
assert result.sum_claimed_saving_nok == _FIRST_TWO_SAVING
|
||||
|
||||
# The cap itself held: the pass spent what two runs cost and no more.
|
||||
assert meter.spent == 2 * _PER_RUN_SPEND
|
||||
assert meter.spent <= _TOTAL
|
||||
assert meter.spent == result.sum_token_usage
|
||||
|
||||
|
||||
async def test_generous_budget_runs_the_whole_pass(
|
||||
make_portfolio_client_factory, fresh_store
|
||||
) -> None:
|
||||
"""Control: the stop above is CAUSED by the cap, not by wiring a meter in at all. With a global
|
||||
budget that funds every project the pass completes untouched — a gate that can only fire proves
|
||||
nothing about what fires it."""
|
||||
meter = PortfolioMeter(PortfolioBudget(max_total_tokens=10_000, max_tokens_per_run=_PER_RUN))
|
||||
result = await run_portfolio(
|
||||
_PORTFOLIO_IDS,
|
||||
"local",
|
||||
store=fresh_store,
|
||||
client_factory=make_portfolio_client_factory({}, tokens=8),
|
||||
portfolio_meter=meter,
|
||||
)
|
||||
assert result.stopped_early is False
|
||||
assert result.budget_stop is None
|
||||
assert len(result.runs) == 3
|
||||
assert meter.spent == 3 * _PER_RUN_SPEND
|
||||
|
||||
|
||||
async def test_global_cap_holds_at_wave_size_three(
|
||||
make_portfolio_client_factory, fresh_store
|
||||
) -> None:
|
||||
"""The cap is a property of the PASS, not of the sequential schedule: at ``concurrency=3`` the
|
||||
whole portfolio is assembled into ONE wave, and every member's funding check reads the same
|
||||
pre-wave ``remaining``. Admitting all three off that shared reading would overspend by design.
|
||||
The wave therefore RESERVES each admitted member's requirement as it admits it, so the same 80
|
||||
tokens fund the same two projects at k=3 as at k=1."""
|
||||
meter = PortfolioMeter(_budget())
|
||||
result = await run_portfolio(
|
||||
_PORTFOLIO_IDS,
|
||||
"local",
|
||||
store=fresh_store,
|
||||
client_factory=make_portfolio_client_factory({}, tokens=8),
|
||||
concurrency=3,
|
||||
portfolio_meter=meter,
|
||||
)
|
||||
assert len(result.runs) == 2
|
||||
assert result.failures == ()
|
||||
assert result.stopped_early is True
|
||||
assert meter.spent <= _TOTAL
|
||||
|
||||
|
||||
async def test_pre_call_guard_refuses_the_call_before_it_is_made(make_client_factory) -> None:
|
||||
"""RED 2: with the budget exhausted, the chat call is REFUSED — the client is never invoked.
|
||||
|
||||
``call_count`` on the scripted client is the measurement: post-charge middleware alone would
|
||||
make the call, read its usage, and only then raise, so ``pytest.raises`` would pass either way.
|
||||
Only the counter separates 'refused' from 'made and then regretted'."""
|
||||
client = make_client_factory("ok", tokens=8)("proposer")
|
||||
meter = TokenMeter(Budget(max_tokens=10, max_rounds=10))
|
||||
meter.charge(10) # exactly at the cap: within budget, but nothing left to fund a call with
|
||||
assert meter.remaining() == 0
|
||||
|
||||
agent = Agent(
|
||||
client, "propose a measure", name="proposer", middleware=[BudgetMiddleware(meter)]
|
||||
)
|
||||
with pytest.raises(BudgetExceeded) as exc:
|
||||
await agent.run("hi")
|
||||
assert exc.value.kind == "tokens"
|
||||
assert client.call_count == 0, "the call must NEVER be made once the budget is exhausted"
|
||||
|
||||
|
||||
async def test_pre_call_guard_fires_on_the_GLOBAL_remainder_too(make_client_factory) -> None:
|
||||
"""The guard reads whichever cap binds. Here the run's OWN budget is untouched and only the
|
||||
portfolio remainder is exhausted — a run that starts funded can still be refused mid-flight
|
||||
because a sibling spent the rest of the global budget."""
|
||||
client = make_client_factory("ok", tokens=8)("proposer")
|
||||
portfolio = PortfolioMeter(
|
||||
PortfolioBudget(max_total_tokens=100, max_tokens_per_run=100), spent=100
|
||||
)
|
||||
meter = TokenMeter(Budget(max_tokens=100, max_rounds=10), portfolio=portfolio)
|
||||
assert meter.tokens == 0 # the RUN has spent nothing
|
||||
assert meter.remaining() == 0 # ...but the portfolio has nothing left
|
||||
|
||||
agent = Agent(
|
||||
client, "propose a measure", name="proposer", middleware=[BudgetMiddleware(meter)]
|
||||
)
|
||||
with pytest.raises(BudgetExceeded) as exc:
|
||||
await agent.run("hi")
|
||||
assert exc.value.kind == "portfolio_tokens"
|
||||
assert client.call_count == 0
|
||||
|
||||
|
||||
async def test_startup_refusal_when_the_remainder_cannot_fund_one_run(
|
||||
make_portfolio_client_factory, fresh_store
|
||||
) -> None:
|
||||
"""Fail-fast at startup (never a silently truncated pass): a meter whose remainder is already
|
||||
below one run's requirement refuses BEFORE anything runs. This is the across-passes arm — the
|
||||
remainder here is what an earlier pass left behind."""
|
||||
meter = PortfolioMeter(_budget(), spent=_TOTAL - (_RESERVE - 1))
|
||||
with pytest.raises(BudgetRefused) as exc:
|
||||
await run_portfolio(
|
||||
_PORTFOLIO_IDS,
|
||||
"local",
|
||||
store=fresh_store,
|
||||
client_factory=make_portfolio_client_factory({}, tokens=8),
|
||||
portfolio_meter=meter,
|
||||
)
|
||||
assert exc.value.remaining == _RESERVE - 1
|
||||
assert exc.value.required == _RESERVE
|
||||
assert meter.spent == _TOTAL - (_RESERVE - 1) # nothing ran, nothing charged
|
||||
|
||||
|
||||
async def test_spend_persists_across_passes(
|
||||
make_portfolio_client_factory, fresh_store, tmp_path: Path
|
||||
) -> None:
|
||||
"""The cap holds ACROSS passes, which is what the persisted spend buys: pass A writes its
|
||||
spend, pass B is seeded from that file and stops one run earlier than it otherwise would."""
|
||||
spend_file = tmp_path / "spend.json"
|
||||
assert read_spend(spend_file) == 0 # no prior pass -> no prior spend
|
||||
|
||||
budget = PortfolioBudget(
|
||||
max_total_tokens=3 * _PER_RUN_SPEND, max_tokens_per_run=_PER_RUN, min_run_reserve=_RESERVE
|
||||
)
|
||||
meter_a = PortfolioMeter(budget)
|
||||
result_a = await run_portfolio(
|
||||
_PORTFOLIO_IDS[:1],
|
||||
"local",
|
||||
store=fresh_store,
|
||||
client_factory=make_portfolio_client_factory({}, tokens=8),
|
||||
portfolio_meter=meter_a,
|
||||
)
|
||||
assert len(result_a.runs) == 1
|
||||
write_spend(spend_file, meter_a.spent, stamp="pass-a")
|
||||
|
||||
meter_b = PortfolioMeter(budget, spent=read_spend(spend_file))
|
||||
assert meter_b.spent == _PER_RUN_SPEND # pass B starts where pass A stopped
|
||||
result_b = await run_portfolio(
|
||||
_PORTFOLIO_IDS,
|
||||
"local",
|
||||
store=fresh_store,
|
||||
client_factory=make_portfolio_client_factory({}, tokens=8),
|
||||
portfolio_meter=meter_b,
|
||||
)
|
||||
# 96 total funds three runs; one is already spent, so pass B affords two and stops before the
|
||||
# third — the earlier pass's spend is what moved the stop.
|
||||
assert len(result_b.runs) == 2
|
||||
assert result_b.stopped_early is True
|
||||
assert meter_b.spent == 3 * _PER_RUN_SPEND
|
||||
|
||||
|
||||
def test_spend_file_is_deterministic_and_stamped(tmp_path: Path) -> None:
|
||||
"""Byte-determinism with an EXPLICIT stamp (mirroring ``promote_verdict``): no wall-clock, so
|
||||
two writes of the same spend are byte-identical and a diff means the spend changed."""
|
||||
a, b = tmp_path / "a.json", tmp_path / "b.json"
|
||||
write_spend(a, 1234, stamp="run-7")
|
||||
write_spend(b, 1234, stamp="run-7")
|
||||
assert a.read_bytes() == b.read_bytes()
|
||||
assert json.loads(a.read_text(encoding="utf-8")) == {"spent_tokens": 1234, "stamp": "run-7"}
|
||||
assert read_spend(a) == 1234
|
||||
|
||||
|
||||
def test_spend_file_malformed_fails_fast(tmp_path: Path) -> None:
|
||||
"""The spend file is OUR OWN accounting state, not the tolerant raw inbox layer: a corrupt file
|
||||
raises rather than silently reading as zero spend, which would hand back the whole budget."""
|
||||
bad = tmp_path / "bad.json"
|
||||
bad.write_text("{not json", encoding="utf-8")
|
||||
with pytest.raises(ValueError):
|
||||
read_spend(bad)
|
||||
negative = tmp_path / "neg.json"
|
||||
negative.write_text('{"spent_tokens": -5, "stamp": "x"}', encoding="utf-8")
|
||||
with pytest.raises(ValueError):
|
||||
read_spend(negative)
|
||||
|
||||
|
||||
async def test_meter_factory_and_portfolio_meter_are_mutually_exclusive(
|
||||
make_portfolio_client_factory, fresh_store
|
||||
) -> None:
|
||||
"""Fail-fast on the one wiring that would silently disable the global cap: a ``meter_factory``
|
||||
meter is not bound to the portfolio ledger, so accepting both would run a pass that LOOKS
|
||||
capped and is not."""
|
||||
with pytest.raises(ValueError):
|
||||
await run_portfolio(
|
||||
_PORTFOLIO_IDS,
|
||||
"local",
|
||||
store=fresh_store,
|
||||
client_factory=make_portfolio_client_factory({}, tokens=8),
|
||||
meter_factory=lambda: TokenMeter(Budget(max_tokens=1000, max_rounds=10)),
|
||||
portfolio_meter=PortfolioMeter(_budget()),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue