feat(13-13): D-05 prepaid budget — arithmetic ceiling, hard stop, D-04 chain complete
Completes D-04's backend chain: Ollama -> Claude -> Routstr (budget-gated), and wires D-05's operator-set prepaid allowance as a hard, arithmetic stop a prompt-injected model can never cross. - assistant/mod.rs: `AssistantBudget` (allowance_sats/spent_sats, persisted 0600 under data_dir/assistant/budget.json, mirroring Grants::load/save exactly — a missing/corrupt file defaults to a ZERO allowance, D-16's "default closed" applied to money). `payment_policy()` builds a `PaymentPolicy` from ONLY these two persisted fields — no parameter accepts anything model/tool/provider-influenced, which is what makes the ceiling arithmetic rather than a policy an injected model could argue with. `record_spend()` persists a successful payment and raises a one-time 80%-threshold owner notice (AI-SPEC §7b). New typed `BudgetExhausted` error (downcastable via anyhow) is the signal `loop_.rs` distinguishes from an ordinary transport error. - assistant/loop_.rs: `run_loop` downcasts a `BudgetExhausted` out of the backend's `Err` and returns `Ok` with a plain-language stop message — no retry, no re-price, no partial spend, no fall-through to a different provider at a different price. Verified to actually matter: temporarily replaced the terminating `return` with `continue` and confirmed `zero_budget_stops_loop_without_retry` goes red (the backend gets retried 8x to MAX_TURNS and the turn errors instead of stopping cleanly); restored and reconfirmed green (13-13-SUMMARY.md records the observed failure). - assistant/backends/mod.rs: `select_backend` now takes `&RpcHandler` (was `&Path`) to also read the Tor-proxy config; completes the D-04 chain — Routstr never selected when the operator's allowance is zero (Claude alone instead), otherwise chained as Claude's fallback (Ollama -> Claude -> Routstr, each leg reached only when the priors are unavailable). New `BackendId::Routstr` variant. - assistant/backends/routstr.rs: the payment-decline arm now returns the typed `BudgetExhausted` (was a plain bail in Task 2's commit, per the plan's own "handled in Task 3" note); a successful payment records spend against the persisted budget immediately (the Cashu proofs are already committed at that point, regardless of whether the subsequent chat HTTP call itself succeeds). - api/rpc/assistant_chat.rs: `assistant.budget-get`/`assistant.budget-set` RPCs (routed through the existing single `assistant.` dispatcher arm — dispatcher.rs untouched) and a `nostr_tor_proxy()` accessor for select_backend's onion-preference decision. Named tests (assistant::tests::): zero_budget_stops_loop_without_retry (S-12), zero_allowance_never_selects_routstr, ceiling_is_not_a_function_of_model_output, injection_loop_against_low_budget_does_not_overspend (EV-17) — all pass. Full assistant:: suite: 91/91. Full crate suite: 1235/1235 (2 pre-existing ignored, unrelated). dispatcher.rs and Cargo.toml untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a3521e5eeb
commit
8ba6041251
@@ -65,7 +65,44 @@ pub async fn run_loop(
|
||||
}
|
||||
|
||||
for turn_idx in 0..MAX_TURNS {
|
||||
match backend.send(system, tools, &history).await? {
|
||||
let turn = match backend.send(system, tools, &history).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// D-05/S-12/T-13-85: the Routstr leg's payment primitive
|
||||
// declined this specific price against the operator's
|
||||
// remaining prepaid allowance — arithmetic, upstream of
|
||||
// anything the model influenced. Downcasting out of the
|
||||
// generic `Err` (rather than string-matching) is what lets
|
||||
// this be distinguished from an ordinary transport error
|
||||
// reliably. Stop HERE: no retry, no re-price, no partial
|
||||
// spend, and no falling through to a different provider at
|
||||
// a different price for this turn — a retry loop against a
|
||||
// budget ceiling is exactly the "prompt-injection-driven
|
||||
// tool-call loop overspends" failure mode this guards.
|
||||
// Exhaustion is designed behaviour (AI-SPEC §7b), so this
|
||||
// returns Ok with a plain-language stop message, never an
|
||||
// Err that would read as a crash.
|
||||
if let Some(exhausted) = e.downcast_ref::<crate::assistant::BudgetExhausted>() {
|
||||
let stop_message = format!(
|
||||
"I've reached the prepaid spending limit for cloud inference this \
|
||||
period ({} sats remaining, this request needed {} sats) — stopping \
|
||||
here rather than retrying, re-pricing, or partially spending. Raise \
|
||||
the allowance in AI settings if you'd like to continue.",
|
||||
exhausted.remaining_sats, exhausted.quoted_price_sats
|
||||
);
|
||||
history.push(ChatMessage {
|
||||
role: Role::Assistant,
|
||||
text: Some(stop_message.clone()),
|
||||
tool_calls: vec![],
|
||||
tool_results: vec![],
|
||||
});
|
||||
ctx.counters.note_turns_used((turn_idx + 1) as u64);
|
||||
return Ok((stop_message, history));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
match turn {
|
||||
BackendTurn::Text(answer) => {
|
||||
history.push(ChatMessage {
|
||||
role: Role::Assistant,
|
||||
|
||||
Reference in New Issue
Block a user