From 67d91eaa65a61a152a122c5500593f187d2ae86a Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 8 Oct 2026 10:23:33 +0000 Subject: [PATCH 01/24] feat(agent-server): settable, server-enforced per-conversation cost budget + live cost Add a per-conversation cost budget that is settable at creation, enforced before each LLM call via worst-case reservations, and observable as live accumulated cost while a run is in progress. - max_budget_per_run is accepted on ConversationConfig (so both StartConversationRequest and the persisted StoredConversation carry it), forwarded to LocalConversation by the agent-server, persisted in meta.json, and exposed on ConversationInfo / RemoteConversation. - CostBudget reserves each call's worst-case cost atomically per conversation before it is sent, then settles to the cost the call actually recorded and releases the remainder. spent + reserved is a live upper bound, so parallel callers and subagents sharing one ceiling cannot all pass a stale check and overshoot. Unknown pricing falls back to a conservative rate, and cache-token rates are included in the bound. The ledger is shared across a parent and its subagents and is seeded once per run from the conversation's recorded combined cost. - LLMBudgetExceededError is raised before the provider request, inside the retry decorator so each attempt is admitted individually, and is surfaced as a terminal MaxBudgetReached ConversationErrorEvent rather than a crash or a retried call. - mark_dirty() flushes in-place-accumulated metrics to base_state.json at least once per step, so accumulated_cost is non-zero and monotonic while execution_status == running, observable from both the API and base_state.json. --- .../agent_server/conversation_service.py | 1 + .../openhands/agent_server/event_service.py | 1 + .../openhands/agent_server/models.py | 9 ++ .../sdk/conversation/conversation.py | 5 + .../conversation/impl/local_conversation.py | 95 +++++++++-- .../conversation/impl/remote_conversation.py | 6 + .../openhands/sdk/conversation/request.py | 11 ++ .../openhands/sdk/conversation/state.py | 10 ++ .../openhands/sdk/llm/call_context.py | 8 +- .../openhands/sdk/llm/cost_budget.py | 150 +++++++++++++++++ .../openhands/sdk/llm/exceptions/__init__.py | 2 + .../sdk/llm/exceptions/classifier.py | 3 +- .../openhands/sdk/llm/exceptions/types.py | 13 ++ openhands-sdk/openhands/sdk/llm/llm.py | 151 ++++++++++++++++++ .../agent_server/test_conversation_service.py | 43 +++++ .../test_remote_conversation_live_server.py | 29 ++++ .../local/test_cost_budget_enforcement.py | 84 ++++++++++ .../local/test_state_serialization.py | 44 +++++ tests/sdk/llm/test_cost_budget.py | 129 +++++++++++++++ 19 files changed, 779 insertions(+), 15 deletions(-) create mode 100644 openhands-sdk/openhands/sdk/llm/cost_budget.py create mode 100644 tests/sdk/conversation/local/test_cost_budget_enforcement.py create mode 100644 tests/sdk/llm/test_cost_budget.py diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index 0164f9149e1..73778653565 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -333,6 +333,7 @@ def _compose_conversation_info( ) return ConversationInfo( **state.model_dump(mode="json"), + max_budget_per_run=stored.max_budget_per_run, title=stored.title, metrics=stored.metrics, created_at=stored.created_at, diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py index 7323213c8a9..8793fa42871 100644 --- a/openhands-agent-server/openhands/agent_server/event_service.py +++ b/openhands-agent-server/openhands/agent_server/event_service.py @@ -1267,6 +1267,7 @@ def _token_streaming_callback(chunk: LLMStreamChunk | str) -> None: token_callbacks=([_token_streaming_callback] if streaming_enabled else []), stream_callbacks=[_publish_stream_progress], max_iteration_per_run=self.stored.max_iterations, + max_budget_per_run=self.stored.max_budget_per_run, stuck_detection=self.stored.stuck_detection, visualizer=None, secrets=self.stored.secrets, diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py index d5f8ee4c69e..70ebea5f030 100644 --- a/openhands-agent-server/openhands/agent_server/models.py +++ b/openhands-agent-server/openhands/agent_server/models.py @@ -168,6 +168,15 @@ class _ConversationInfoBase(BaseModel): "Maximum number of iterations the agent can perform in a single run." ), ) + max_budget_per_run: float | None = Field( + default=None, + gt=0, + description=( + "Maximum cost in USD the agent may spend in a single run before " + "stopping. Enforcement is pre-call and reservation-based. ``None`` " + "leaves the run unbounded." + ), + ) stuck_detection: bool = Field( default=True, description="Whether to enable stuck detection for the agent.", diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py index c508fef1544..510ac4b9a5d 100644 --- a/openhands-sdk/openhands/sdk/conversation/conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/conversation.py @@ -73,6 +73,7 @@ def __new__( token_callbacks: list[ConversationTokenCallbackType] | None = None, hook_config: HookConfig | None = None, max_iteration_per_run: int = 500, + max_budget_per_run: float | None = None, stuck_detection: bool = True, stuck_detection_thresholds: ( StuckDetectionThresholds | Mapping[str, int] | None @@ -103,6 +104,7 @@ def __new__( token_callbacks: list[ConversationTokenCallbackType] | None = None, hook_config: HookConfig | None = None, max_iteration_per_run: int = 500, + max_budget_per_run: float | None = None, stuck_detection: bool = True, stuck_detection_thresholds: ( StuckDetectionThresholds | Mapping[str, int] | None @@ -133,6 +135,7 @@ def __new__( token_callbacks: list[ConversationTokenCallbackType] | None = None, hook_config: HookConfig | None = None, max_iteration_per_run: int = 500, + max_budget_per_run: float | None = None, stuck_detection: bool = True, stuck_detection_thresholds: ( StuckDetectionThresholds | Mapping[str, int] | None @@ -200,6 +203,7 @@ def __new__( token_callbacks=token_callbacks, hook_config=hook_config, max_iteration_per_run=max_iteration_per_run, + max_budget_per_run=max_budget_per_run, stuck_detection=stuck_detection, stuck_detection_thresholds=stuck_detection_thresholds, visualizer=visualizer, @@ -223,6 +227,7 @@ def __new__( token_callbacks=token_callbacks, hook_config=hook_config, max_iteration_per_run=max_iteration_per_run, + max_budget_per_run=max_budget_per_run, stuck_detection=stuck_detection, stuck_detection_thresholds=stuck_detection_thresholds, visualizer=visualizer, diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 884e92d7ece..d0aab024f8f 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -5,6 +5,7 @@ import json import uuid from collections.abc import Mapping, Sequence +from dataclasses import replace from pathlib import Path, PurePath from typing import Any, Final, TypeGuard, cast @@ -56,7 +57,8 @@ from openhands.sdk.llm import LLM, Message, TextContent, content_to_str from openhands.sdk.llm.auth.openai import create_subscription_llm_from_config from openhands.sdk.llm.call_context import LLMCallContext, llm_call_context_scope -from openhands.sdk.llm.exceptions import LLMAuthenticationError +from openhands.sdk.llm.cost_budget import CostBudget +from openhands.sdk.llm.exceptions import LLMAuthenticationError, LLMBudgetExceededError from openhands.sdk.llm.llm_profile_store import LLMProfileStore from openhands.sdk.llm.llm_registry import LLMRegistry from openhands.sdk.logger import get_logger @@ -490,8 +492,35 @@ def _default_callback(e): ) self.max_iteration_per_run = max_iteration_per_run - # Hard cost ceiling (USD) for a run; None disables the budget check. + # Hard cost ceiling (USD) for the conversation; None disables it. self.max_budget_per_run = max_budget_per_run + inherited_budget = ( + _parent_llm_call_context.cost_budget + if _parent_llm_call_context is not None + else None + ) + self._cost_budget: CostBudget | None = None + # True only for the conversation that created the budget; a conversation + # that inherited a parent's budget shares its ledger and must never + # re-seed it mid-flight. + self._owns_budget = False + if max_budget_per_run is not None: + if inherited_budget is not None: + # A subagent shares its parent's reservation pool so parent and + # children contend on one ceiling; its own limit bounds it too. + self._cost_budget = inherited_budget.child( + max_budget_per_run, self._combined_cost + ) + else: + self._cost_budget = CostBudget(max_budget_per_run, self._combined_cost) + self._owns_budget = True + self._llm_call_context = replace( + self._llm_call_context, cost_budget=self._cost_budget + ) + elif inherited_budget is not None: + # No own ceiling, but inherit the parent's shared budget so a + # subagent still draws down the parent's allowance. + self._cost_budget = inherited_budget # Initialize stuck detector if stuck_detection: @@ -708,6 +737,10 @@ def state(self) -> ConversationState: def conversation_stats(self): return self._state.stats + def _combined_cost(self) -> float: + """Combined recorded cost across the conversation's LLMs (USD).""" + return self.conversation_stats.get_combined_metrics().accumulated_cost + def _latest_acp_prompt_message_id(self) -> str | None: """Id of the most recent ACP prompt message, or None if there is none.""" acp_prompt_messages = [ @@ -718,20 +751,28 @@ def _latest_acp_prompt_message_id(self) -> str | None: return acp_prompt_messages[-1].id if acp_prompt_messages else None def _budget_exceeded_detail(self) -> str | None: - """Error detail if the run has hit its cost budget, else None. + """Error detail if the conversation has hit its cost budget, else None. - Bounds total spend across all of the run's LLMs (agent, condenser, ...), - complementing the iteration cap which only bounds step count. + Bounds total spend across all of the conversation's LLMs (agent, + condenser, ...), complementing the iteration cap which only bounds step + count. Uses live cost (recorded + in-flight reservations) so a run in + progress cannot slip past the ceiling between checks. """ - if self.max_budget_per_run is None: - return None - spent = self.conversation_stats.get_combined_metrics().accumulated_cost - if spent < self.max_budget_per_run: + if self._cost_budget is None: return None - return ( - f"Agent reached maximum budget limit " - f"(${self.max_budget_per_run:.4f}); accumulated cost ${spent:.4f}." - ) + return self._cost_budget.exceeded_detail() + + def _begin_budget_run(self) -> None: + """Start a fresh budget allowance for a top-level run. + + Seeds the ledger from the conversation's recorded combined cost so the + ceiling is per-run (matching ``max_budget_per_run``). A conversation that + only inherited a parent's budget shares the parent's ledger and must not + re-seed it mid-flight. + """ + if self._owns_budget: + assert self._cost_budget is not None + self._cost_budget.start_run() def _emit_run_limit_error(self, code: str, detail: str) -> None: """Mark the run failed with a run-limit ConversationErrorEvent.""" @@ -1919,6 +1960,7 @@ def _run(self) -> None: # Ensure agent is fully initialized (loads plugins and initializes agent) self._ensure_agent_ready() self._cancel_token = CancellationToken() + self._begin_budget_run() with self._state: if self._state.execution_status in [ @@ -1998,6 +2040,11 @@ def _run(self) -> None: finally: self._step_holds_state_lock = False iteration += 1 + # Metrics accumulate in place on the shared stats, bypassing + # __setattr__ autosave; flag a flush so base_state.json — + # which the API's listing/GET snapshot reads — reports live + # accumulated_cost during the run, not just at completion. + self._state.mark_dirty() # Check for non-finished terminal conditions # Note: We intentionally do NOT check for FINISHED status here. @@ -2059,6 +2106,12 @@ def _run(self) -> None: raise ConversationRunError( self._state.id, e, persistence_dir=self._state.persistence_dir ) from e + except LLMBudgetExceededError as e: + # A pre-call reservation refusal is a normal terminal condition — + # like the iteration cap — not a crash: surface it as MaxBudgetReached + # and stop without raising ConversationRunError. + with self._state: + self._emit_run_limit_error("MaxBudgetReached", str(e)) except Exception as e: with self._state: self._state.execution_status = ConversationExecutionStatus.ERROR @@ -2123,6 +2176,7 @@ async def _arun(self) -> None: # self-deadlock that ReadTimeouts after 30s (agent-canvas#1072). # _ensure_agent_ready is thread-safe and already runs off-loop in run(). await asyncio.to_thread(self._ensure_agent_ready) + self._begin_budget_run() with self._state: if isinstance(self.agent, ACPAgent) and self._state.execution_status in ( @@ -2277,6 +2331,11 @@ async def _arun(self) -> None: finally: self._step_holds_state_lock = False iteration += 1 + # Metrics accumulate in place on the shared stats, + # bypassing __setattr__ autosave; flag a flush so the + # persisted snapshot reports live accumulated_cost during + # the run, not just at completion. + self._state.mark_dirty() # astep releases the state lock for the LLM call, so a # message can land mid-step with status still RUNNING and @@ -2429,6 +2488,10 @@ async def _arun(self) -> None: ) with self._state: iteration += 1 + # Metrics accumulate in place on the shared stats, bypassing + # __setattr__ autosave; flag a flush so the persisted + # snapshot reports live accumulated_cost during the run. + self._state.mark_dirty() pause_requested_during_acp_step = any( isinstance(event, PauseEvent) for event in self._state.events[acp_step_start_event_count:] @@ -2579,6 +2642,12 @@ async def _arun(self) -> None: raise ConversationRunError( self._state.id, e, persistence_dir=self._state.persistence_dir ) from e + except LLMBudgetExceededError as e: + # A pre-call reservation refusal is a normal terminal condition — + # like the iteration cap — not a crash: surface it as MaxBudgetReached + # and stop without raising ConversationRunError. + with self._state: + self._emit_run_limit_error("MaxBudgetReached", str(e)) except Exception as e: with self._state: updated_agent_state = dict(self._state.agent_state) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index 6522ca7a25f..f17fdadc0c9 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -729,6 +729,7 @@ def __init__( conversation_id: ConversationID | None = None, callbacks: list[ConversationCallbackType] | None = None, max_iteration_per_run: int = 500, + max_budget_per_run: float | None = None, stuck_detection: bool = True, stuck_detection_thresholds: ( StuckDetectionThresholds | Mapping[str, int] | None @@ -848,6 +849,7 @@ def __init__( ), "initial_message": None, "max_iterations": max_iteration_per_run, + "max_budget_per_run": max_budget_per_run, "stuck_detection": stuck_detection, # We need to convert RemoteWorkspace to LocalWorkspace for the server "workspace": LocalWorkspace( @@ -921,6 +923,7 @@ def __init__( conversation_id=conversation_id, callbacks=callbacks, max_iteration_per_run=max_iteration_per_run, + max_budget_per_run=max_budget_per_run, client_tools=[*(client_tools or []), *attached_client_tools], visualizer=visualizer, ) @@ -1040,6 +1043,7 @@ def _from_info( conversation_id=uuid.UUID(info["id"]), callbacks=callbacks, max_iteration_per_run=info["max_iterations"], + max_budget_per_run=info.get("max_budget_per_run"), client_tools=[ ClientToolSpec.model_validate(spec) for spec in info.get("client_tools") or [] @@ -1060,11 +1064,13 @@ def _initialize_connection( visualizer: ( type[ConversationVisualizerBase] | ConversationVisualizerBase | None ), + max_budget_per_run: float | None = None, ) -> None: super().__init__() # Initialize base class with span tracking self.agent = agent self._callbacks = callbacks or [] self.max_iteration_per_run = max_iteration_per_run + self.max_budget_per_run = max_budget_per_run self.workspace = workspace self._client = workspace.client self._cleanup_initiated = False diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py index 42e7abe1726..498d25f6210 100644 --- a/openhands-sdk/openhands/sdk/conversation/request.py +++ b/openhands-sdk/openhands/sdk/conversation/request.py @@ -146,6 +146,17 @@ class ConversationConfig(BaseModel): description="If set, the max number of iterations the agent will run " "before stopping. This is useful to prevent infinite loops.", ) + max_budget_per_run: float | None = Field( + default=None, + gt=0, + description=( + "Maximum cost in USD the agent may spend in a single run before " + "stopping. Enforcement is pre-call and reservation-based: each LLM " + "call reserves its worst-case cost before being sent, so a run " + "cannot overshoot the budget by a whole step. ``None`` (the " + "default) leaves the run unbounded." + ), + ) stuck_detection: bool = Field( default=True, description="If true, the conversation will use stuck detection to " diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py index ed774175926..aa0b9785f99 100644 --- a/openhands-sdk/openhands/sdk/conversation/state.py +++ b/openhands-sdk/openhands/sdk/conversation/state.py @@ -769,6 +769,16 @@ def locked(self) -> bool: """ return self._lock.locked() + def mark_dirty(self) -> None: + """Flag ``base_state.json`` for rewrite on the next flush. + + In-place mutations (e.g. the LLM metrics accumulating cost) bypass + ``__setattr__`` autosave, so the persisted snapshot can lag the live + state. Callers that mutate nested state in place call this so the run + loop's ``with self._state`` block persists one fresh snapshot per step. + """ + self._dirty = True + def owned(self) -> bool: """ Return True if the lock is currently held by the calling thread. diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py index 6c886595e55..8dd7cb5d866 100644 --- a/openhands-sdk/openhands/sdk/llm/call_context.py +++ b/openhands-sdk/openhands/sdk/llm/call_context.py @@ -6,7 +6,11 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass, replace -from typing import Any +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from openhands.sdk.llm.cost_budget import CostBudget @dataclass(frozen=True) @@ -21,6 +25,8 @@ class LLMCallContext: prompt_cache_key: str | None = None session_id: str | None = None + # Per-conversation cost ceiling. ``None`` disables budget enforcement. + cost_budget: CostBudget | None = None def for_conversation( self, diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py new file mode 100644 index 00000000000..9c8ed006cfa --- /dev/null +++ b/openhands-sdk/openhands/sdk/llm/cost_budget.py @@ -0,0 +1,150 @@ +"""Per-conversation cost budget with pre-call reservation. + +A conversation budget must not merely compare "spent so far" before a call: +concurrent callers (parallel delegates, subagents under one budget) all read +the same total and pass, and a single call can still overshoot by its own +worst case. ``CostBudget`` therefore *reserves* each call's worst-case cost +before it is sent and settles it once the call completes, releasing the +remainder. Reserved + settled is a live upper bound on what the conversation +can owe, which also answers "how much has this conversation accumulated so far" +while it is still running. + +Settled cost is carried on a shared ledger, so a parent and its subagents +contend on one ceiling even though a subagent's metrics join the parent only +after it finishes. The ledger is seeded once per run from the conversation's +combined metrics, which bounds spend across every LLM in the conversation -- +agent, condenser, fallback, and already-synced subagents -- without +double-counting across runs. +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from dataclasses import dataclass + + +class _Ledger: + """Shared settled/reserved totals for one budget tree, guarded by one lock.""" + + __slots__ = ("lock", "settled", "reserved") + + def __init__(self) -> None: + self.lock = threading.Lock() + self.settled = 0.0 + self.reserved = 0.0 + + +@dataclass(frozen=True) +class BudgetReservation: + """A reservation of ``amount`` USD held against a :class:`CostBudget`.""" + + budget: CostBudget + amount: float + + +class CostBudget: + """A cost ceiling (USD) enforced by pre-call reservation. + + ``limit`` bounds the combined cost of the conversation (and, for a shared + tree, of its subagents). The ledger is shared by derived child views, each + of which honours its own ``limit`` over the same running total. + """ + + def __init__(self, limit: float, cost_source: Callable[[], float]) -> None: + if limit <= 0: + raise ValueError("Cost budget limit must be strictly positive") + self._limit = limit + self._cost_source = cost_source + self._ledger = _Ledger() + + def child(self, limit: float, cost_source: Callable[[], float]) -> CostBudget: + """Derive a subagent view with its own (usually smaller) ceiling. + + Shares the parent's ledger, so the whole tree contends on one running + total, but reads the recorded cost source from the child conversation + and enforces the child's own limit as well. + """ + if limit <= 0: + raise ValueError("Cost budget limit must be strictly positive") + view = CostBudget.__new__(CostBudget) + view._limit = limit + view._cost_source = cost_source + view._ledger = self._ledger + return view + + @property + def limit(self) -> float: + return self._limit + + @property + def spent(self) -> float: + """Cost settled so far (excludes in-flight work), floored at 0.""" + with self._ledger.lock: + return max(0.0, self._ledger.settled) + + @property + def reserved(self) -> float: + with self._ledger.lock: + return self._ledger.reserved + + @property + def live_total(self) -> float: + """Live upper bound on cost: settled + in-flight reservations.""" + with self._ledger.lock: + return self._ledger.settled + self._ledger.reserved + + def start_run(self) -> None: + """Seed the ledger from recorded cost so each run gets a fresh allowance. + + Called at the start of a top-level run; a run that inherits a parent's + budget shares the ledger and must not re-seed it mid-flight. + """ + with self._ledger.lock: + self._ledger.settled = self._cost_source() + self._ledger.reserved = 0.0 + + def try_reserve(self, amount: float) -> BudgetReservation | None: + """Atomically reserve ``amount`` or return ``None`` if it would exceed. + + The comparison and the reservation happen under one lock, so parallel + callers cannot all pass the same check. + """ + if amount < 0: + raise ValueError("Reservation amount cannot be negative") + with self._ledger.lock: + if self._ledger.settled + self._ledger.reserved + amount > self._limit: + return None + self._ledger.reserved += amount + return BudgetReservation(self, amount) + + def settle(self, reservation: BudgetReservation, actual: float) -> None: + """Release a reservation and add the call's actual recorded cost.""" + with self._ledger.lock: + self._ledger.reserved = max(0.0, self._ledger.reserved - reservation.amount) + self._ledger.settled += max(0.0, actual) + + def exceeded_detail(self) -> str | None: + """Error detail if the live total has reached the ceiling, else ``None``.""" + if self.live_total < self._limit: + return None + return self.denial_detail(0.0) + + def denial_detail(self, amount: float) -> str: + """Message for a reservation of ``amount`` that would exceed the ceiling.""" + return self._limit_detail(attempted=self.live_total + amount) + + def _limit_detail(self, attempted: float | None = None) -> str: + # Report the larger of recorded cost and the live bound so the message + # reflects in-flight spend when it is the reason for the denial. + accumulated = max(self.spent, self.live_total) + detail = ( + f"Agent reached maximum budget limit (${self._limit:.4f}); " + f"accumulated cost ${accumulated:.4f}." + ) + if attempted is not None: + detail += f" Next call would need ${attempted:.4f}." + return detail + + +__all__ = ["BudgetReservation", "CostBudget"] diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py index 218c93a05aa..4e91237250e 100644 --- a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py +++ b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py @@ -13,6 +13,7 @@ FunctionCallValidationError, LLMAuthenticationError, LLMBadRequestError, + LLMBudgetExceededError, LLMContentPolicyViolationError, LLMContextWindowExceedError, LLMContextWindowTooSmallError, @@ -49,6 +50,7 @@ "LLMTimeoutError", "LLMServiceUnavailableError", "LLMBadRequestError", + "LLMBudgetExceededError", "UserCancelledError", "OperationCancelled", # Helpers diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py index 878fced0d3f..40452d15973 100644 --- a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py +++ b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py @@ -17,6 +17,7 @@ ) from .types import ( + LLMBudgetExceededError, LLMContextWindowExceedError, LLMMalformedConversationHistoryError, LLMRateLimitError, @@ -142,7 +143,7 @@ def is_prompt_cache_too_small(exception: Exception) -> bool: def is_budget_exceeded_error(exception: BaseException) -> bool: """Recognize explicit budget denials before or after SDK error mapping.""" - if isinstance(exception, BudgetExceededError): + if isinstance(exception, (BudgetExceededError, LLMBudgetExceededError)): return True if not isinstance(exception, (RateLimitError, LLMRateLimitError)): return False diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py index da9da38063c..8a34d40455f 100644 --- a/openhands-sdk/openhands/sdk/llm/exceptions/types.py +++ b/openhands-sdk/openhands/sdk/llm/exceptions/types.py @@ -131,6 +131,19 @@ def __init__(self, message: str = "Bad request to LLM provider") -> None: super().__init__(message) +class LLMBudgetExceededError(LLMError): + """The conversation's cost budget cannot admit this call. + + Raised *before* the provider request is sent, so a budget ceiling is never + crossed by a call that would exceed it. Deterministic in the reservation + state: retrying the same call cannot succeed, so the LLM retry loop must not + retry it and model fallback must not mask it. + """ + + def __init__(self, message: str = "Conversation cost budget exceeded") -> None: + super().__init__(message) + + class LLMContentPolicyViolationError(LLMBadRequestError): """Provider blocked the request/response via its content filter. diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index 3e304d2eef6..fffe66d968d 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -2,6 +2,7 @@ import asyncio import copy +import inspect import json import os import threading @@ -16,6 +17,7 @@ Sequence, ) from contextvars import ContextVar +from functools import wraps from typing import ( TYPE_CHECKING, Any, @@ -77,6 +79,7 @@ from openhands.sdk.tool.tool import ToolDefinition from openhands.sdk.llm.auth.openai import transform_for_subscription +from openhands.sdk.llm.call_context import resolve_llm_call_context with warnings.catch_warnings(): @@ -123,7 +126,9 @@ ) from tenacity import retry_if_exception, retry_if_exception_type +from openhands.sdk.llm.cost_budget import BudgetReservation from openhands.sdk.llm.exceptions import ( + LLMBudgetExceededError, LLMContextWindowTooSmallError, LLMNoResponseError, is_prompt_cache_too_small, @@ -168,6 +173,14 @@ litellm.modify_params = True +# Budget reservations price ``input + output`` at these conservative fallbacks +# when a route publishes no per-token rate or no token cap, so an unpriced or +# uncapped model cannot silently spend past a hard ceiling. They only matter +# when the model metadata is silent; known models use their real rates/caps. +_DEFAULT_INPUT_COST_PER_TOKEN: Final[float] = 1e-5 # $10 / 1M tokens +_DEFAULT_OUTPUT_COST_PER_TOKEN: Final[float] = 3e-5 # $30 / 1M tokens +_DEFAULT_BUDGET_TOKEN_PROJECTION: Final[int] = 32768 + _serialized_is_subscription = ContextVar( "serialized_is_subscription", default=False, @@ -1749,6 +1762,132 @@ async def agenerate( # Chat Completion API # ========================================================================= + def _cap_projection(self, tokens: int | None) -> int: + if tokens is None or tokens <= 0: + return _DEFAULT_BUDGET_TOKEN_PROJECTION + return tokens + + def worst_case_cost( + self, + *, + input_tokens: int | None = None, + max_output_tokens: int | None = None, + ) -> float: + """Upper-bound USD for one call, used to reserve against a budget. + + Prices ``input_tokens + output_upper`` at the highest per-token rate + the model exposes, so a reservation never under-counts a + context-tiered or cache-priced call. Unknown pricing falls back to a + conservative rate so an unpriced model cannot silently spend past a + hard ceiling. + """ + input_upper = self._cap_projection(input_tokens) + output_upper = self._cap_projection(max_output_tokens) + in_cost, out_cost = self._cost_per_token_bounds() + return input_upper * in_cost + output_upper * out_cost + + def _cost_per_token_bounds(self) -> tuple[float, float]: + """Highest input/output per-token rates known for this route.""" + input_costs = [c for c in (self.input_cost_per_token,) if c is not None] + output_costs = [c for c in (self.output_cost_per_token,) if c is not None] + info = self._model_info or {} + extra = ( + info.get("input_cost_per_token"), + info.get("output_cost_per_token"), + info.get("cache_read_input_token_cost"), + info.get("cache_creation_input_token_cost"), + ) + for value in extra: + if isinstance(value, int | float) and value > 0: + input_costs.append(float(value)) + output_costs.append(float(value)) + in_cost = max(input_costs) if input_costs else _DEFAULT_INPUT_COST_PER_TOKEN + out_cost = max(output_costs) if output_costs else _DEFAULT_OUTPUT_COST_PER_TOKEN + return in_cost, out_cost + + def _reserve_budget( + self, + call_context: LLMCallContext | None, + messages: list[Message], + tools: Sequence[ToolDefinition] | None, + ) -> BudgetReservation | None: + """Reserve this call's worst-case cost, or raise if it cannot be met. + + The reservation is taken atomically per conversation, so concurrent + callers sharing one budget cannot all pass the same check. Returns + ``None`` when no budget is bound to the call. + """ + budget = call_context.cost_budget if call_context else None + if budget is None: + return None + estimated_input = self.get_token_count(messages, tools) or None + worst_case = self.worst_case_cost( + input_tokens=estimated_input, + max_output_tokens=self.effective_max_output_tokens, + ) + reservation = budget.try_reserve(worst_case) + if reservation is None: + raise LLMBudgetExceededError(budget.denial_detail(worst_case)) + return reservation + + def _budget_guarded_attempt( + self, + call_context: LLMCallContext | None, + messages: list[Message], + tools: Sequence[ToolDefinition] | None, + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator reserving this attempt's worst-case cost before sending. + + Stacked *inside* the retry decorator (closest to the transport call) so + each attempt -- including retries -- is admitted and settled on its own + and a retry loop cannot spend past the budget invisibly. On success the + reservation settles to the cost this LLM actually recorded; on failure + it is released. A no-op when no budget is bound. + """ + + def decorate(attempt: Callable[..., Any]) -> Callable[..., Any]: + if inspect.iscoroutinefunction(attempt): + + @wraps(attempt) + async def _reserved_async(*args: Any, **kwargs: Any) -> Any: + reservation = self._reserve_budget(call_context, messages, tools) + if reservation is None: + return await attempt(*args, **kwargs) + cost_before = self.metrics.accumulated_cost + try: + result = await attempt(*args, **kwargs) + except BaseException: + reservation.budget.settle(reservation, 0.0) + raise + reservation.budget.settle( + reservation, self._cost_delta(cost_before) + ) + return result + + return _reserved_async + + @wraps(attempt) + def _reserved_sync(*args: Any, **kwargs: Any) -> Any: + reservation = self._reserve_budget(call_context, messages, tools) + if reservation is None: + return attempt(*args, **kwargs) + cost_before = self.metrics.accumulated_cost + try: + result = attempt(*args, **kwargs) + except BaseException: + reservation.budget.settle(reservation, 0.0) + raise + reservation.budget.settle(reservation, self._cost_delta(cost_before)) + return result + + return _reserved_sync + + return decorate + + def _cost_delta(self, cost_before: float) -> float: + """Cost added to this LLM's own metrics by the call just completed.""" + return max(0.0, self.metrics.accumulated_cost - cost_before) + def completion( self, messages: list[Message], @@ -1785,6 +1924,7 @@ def completion( # no-op for providers without runtime metadata. self.resolve_runtime_metadata() + call_context = resolve_llm_call_context(call_context) _caller_kwargs = kwargs.copy() enable_streaming = bool(kwargs.get("stream", False)) or self.stream if enable_streaming and on_token is None: @@ -1814,6 +1954,7 @@ def completion( ) @self._make_retry_decorator() + @self._budget_guarded_attempt(call_context, messages, tools) def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) @@ -1850,6 +1991,7 @@ def _one_attempt(**retry_kwargs: Any) -> ModelResponse: tools, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, + call_context=call_context, **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply @@ -1904,6 +2046,7 @@ async def acompletion( # calls are cheap. await self.aresolve_runtime_metadata() + call_context = resolve_llm_call_context(call_context) _caller_kwargs = kwargs.copy() enable_streaming = bool(kwargs.get("stream", False)) or self.stream if enable_streaming and on_token is None: @@ -1934,6 +2077,7 @@ async def acompletion( @self._make_retry_decorator() @self._async_hard_timeout_decorator() + @self._budget_guarded_attempt(call_context, messages, tools) async def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) @@ -1970,6 +2114,7 @@ async def _one_attempt(**retry_kwargs: Any) -> ModelResponse: tools, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, + call_context=call_context, **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply @@ -2043,6 +2188,7 @@ def responses( # metadata. self.resolve_runtime_metadata() + call_context = resolve_llm_call_context(call_context) _caller_kwargs = kwargs.copy() user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream if user_enable_streaming and on_token is None and not self.requires_streaming: @@ -2076,6 +2222,7 @@ def responses( ) @self._make_retry_decorator() + @self._budget_guarded_attempt(call_context, messages, tools) def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) @@ -2151,6 +2298,7 @@ def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse: store, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, + call_context=call_context, **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply @@ -2208,6 +2356,7 @@ async def aresponses( # limits. No blocking network I/O in-process. await self.aresolve_runtime_metadata() + call_context = resolve_llm_call_context(call_context) _caller_kwargs = kwargs.copy() user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream if user_enable_streaming and on_token is None and not self.requires_streaming: @@ -2242,6 +2391,7 @@ async def aresponses( @self._make_retry_decorator() @self._async_hard_timeout_decorator() + @self._budget_guarded_attempt(call_context, messages, tools) async def _one_attempt( **retry_kwargs: Any, ) -> ResponsesAPIResponse: @@ -2343,6 +2493,7 @@ async def _one_attempt( store, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, + call_context=call_context, **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py index 45b82a694ae..1ac36b3a05f 100644 --- a/tests/agent_server/test_conversation_service.py +++ b/tests/agent_server/test_conversation_service.py @@ -145,6 +145,49 @@ async def test_server_resolved_tool_modules_are_persisted(tmp_path): ) +@pytest.mark.asyncio +async def test_max_budget_per_run_persisted_and_exposed(tmp_path): + """A budget set at creation persists in meta.json and surfaces on GET. + + Reloading through a fresh service (server restart) must keep the bound so a + resumed run is still capped, and the composed ConversationInfo must expose + it alongside max_iterations. + """ + conversations_dir = tmp_path / "conversations" + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + request = StartConversationRequest( + agent=Agent(llm=LLM(model="gpt-4o", usage_id="test-llm"), tools=[]), + workspace=LocalWorkspace(working_dir=str(workspace_dir)), + confirmation_policy=NeverConfirm(), + max_budget_per_run=2.5, + ) + + async with ConversationService(conversations_dir=conversations_dir) as service: + info, _ = await service.start_conversation(request) + assert info.max_budget_per_run == pytest.approx(2.5) + + meta = json.loads((conversations_dir / info.id.hex / "meta.json").read_text()) + assert meta["max_budget_per_run"] == pytest.approx(2.5) + + async with ConversationService(conversations_dir=conversations_dir) as service2: + reloaded = await service2.get_conversation(info.id) + assert reloaded is not None + assert reloaded.max_budget_per_run == pytest.approx(2.5) + + +@pytest.mark.asyncio +async def test_start_conversation_rejects_non_positive_budget(tmp_path): + """The creation contract validates the budget as strictly positive.""" + with pytest.raises(ValueError): + StartConversationRequest( + agent=Agent(llm=LLM(model="gpt-4o", usage_id="test-llm"), tools=[]), + workspace=LocalWorkspace(working_dir=str(tmp_path)), + confirmation_policy=NeverConfirm(), + max_budget_per_run=0.0, + ) + + def _create_running_terminal_action(tool_call_id: str = "call_1") -> ActionEvent: tool_call = MessageToolCall.from_chat_tool_call( ChatCompletionMessageToolCall( diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py index b32563e0232..0296b78c44d 100644 --- a/tests/cross/test_remote_conversation_live_server.py +++ b/tests/cross/test_remote_conversation_live_server.py @@ -724,6 +724,35 @@ def test_remote_conversation_created_from_agent_settings(server_env): conversation.close() +def test_conversation_budget_round_trips_over_real_server(server_env): + """A budget set at creation survives the REST round-trip. + + Creates via ``POST /api/conversations`` with ``max_budget_per_run`` and + reads it back from ``GET /api/conversations/{id}``, proving the response + schema exposes the bound a watchdog would enforce against. + """ + host = server_env["host"] + with httpx.Client(base_url=host, timeout=10.0) as client: + create_resp = client.post( + "/api/conversations", + json={ + "agent": { + "llm": {"model": "gpt-4o-mini", "api_key": "test"}, + "tools": [], + }, + "workspace": {"working_dir": "/tmp/workspace/project"}, + "max_budget_per_run": 3.5, + }, + ) + assert create_resp.status_code == 201, create_resp.text + assert create_resp.json()["max_budget_per_run"] == 3.5 + conv_id = create_resp.json()["id"] + + get_resp = client.get(f"/api/conversations/{conv_id}") + assert get_resp.status_code == 200, get_resp.text + assert get_resp.json()["max_budget_per_run"] == 3.5 + + def test_openai_chat_completions_gateway_over_real_server( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patched_llm ): diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py new file mode 100644 index 00000000000..d87be7d1375 --- /dev/null +++ b/tests/sdk/conversation/local/test_cost_budget_enforcement.py @@ -0,0 +1,84 @@ +"""Per-conversation cost budget: server-enforced reservation + live cost.""" + +import tempfile +from itertools import count +from pathlib import Path +from unittest.mock import patch + +import pytest +from litellm.types.utils import Choices, Message as LiteLLMMessage, ModelResponse, Usage + +from openhands.sdk import LLM, Agent, Conversation, Message, TextContent +from openhands.sdk.conversation.impl.local_conversation import LocalConversation +from openhands.sdk.conversation.state import ConversationExecutionStatus +from openhands.sdk.event.conversation_error import ConversationErrorEvent + + +def _response_with_cost(model: str) -> ModelResponse: + # 1000 in + 1000 out tokens priced at the model's per-token rates. + return ModelResponse( + id=f"resp-{next(count())}", + choices=[Choices(message=LiteLLMMessage(role="assistant", content="done"))], + usage=Usage(prompt_tokens=1000, completion_tokens=1000, total_tokens=2000), + model=model, + ) + + +def _conversation(tmp: Path, *, max_budget_per_run: float | None) -> LocalConversation: + llm = LLM( + model="gpt-4o-mini", + usage_id="main", + num_retries=0, + input_cost_per_token=1e-5, + output_cost_per_token=1e-5, + ) + return Conversation( + agent=Agent(llm=llm, tools=[]), + workspace=str(tmp), + persistence_dir=str(tmp / "persist"), + max_budget_per_run=max_budget_per_run, + visualizer=None, + delete_on_close=False, + ) + + +def test_run_aborts_with_max_budget_reached_when_reservation_would_exceed(): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + conv = _conversation(tmp, max_budget_per_run=0.001) + conv.send_message(Message(role="user", content=[TextContent(text="go")])) + + def fake_completion(**kwargs): + return _response_with_cost(kwargs.get("model", "gpt-4o-mini")) + + with patch( + "openhands.sdk.llm.llm.litellm_completion", side_effect=fake_completion + ): + conv.run() + + assert conv.state.execution_status == ConversationExecutionStatus.ERROR + codes = [ + e.code for e in conv.state.events if isinstance(e, ConversationErrorEvent) + ] + assert "MaxBudgetReached" in codes + # The cheapest call (0.002 USD) never fits a 0.001 ceiling. + assert conv.conversation_stats.get_combined_metrics().accumulated_cost == 0.0 + + +def test_run_completes_and_exposes_accumulated_cost_within_budget(): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + conv = _conversation(tmp, max_budget_per_run=1.0) + conv.send_message(Message(role="user", content=[TextContent(text="go")])) + + def fake_completion(**kwargs): + return _response_with_cost(kwargs.get("model", "gpt-4o-mini")) + + with patch( + "openhands.sdk.llm.llm.litellm_completion", side_effect=fake_completion + ): + conv.run() + + assert conv.state.execution_status == ConversationExecutionStatus.FINISHED + cost = conv.conversation_stats.get_combined_metrics().accumulated_cost + assert cost == pytest.approx(0.02) diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py index 16457eb9398..075574f3a15 100644 --- a/tests/sdk/conversation/local/test_state_serialization.py +++ b/tests/sdk/conversation/local/test_state_serialization.py @@ -814,6 +814,50 @@ def test_conversation_state_stats_preserved_on_resume(): ), "Context window should be preserved after resume" +def test_mark_dirty_flushes_in_place_stats_mutation(): + """mark_dirty persists in-place metrics that bypass __setattr__ autosave. + + The run loop mutates ``stats.usage_to_metrics[*].accumulated_cost`` in place + (same Metrics objects the LLM holds), so no public-field assignment fires. + ``mark_dirty()`` + the enclosing ``with state:`` block must still flush a + fresh ``base_state.json`` so live cost is observable mid-run. + """ + with tempfile.TemporaryDirectory() as temp_dir: + llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key"), usage_id="test-llm" + ) + agent = Agent(llm=llm, tools=[]) + conv_id = uuid.UUID("12345678-1234-5678-9abc-123456789020") + persist_path = LocalConversation.get_persistence_dir(temp_dir, conv_id) + state = ConversationState.create( + workspace=LocalWorkspace(working_dir="/tmp"), + persistence_dir=persist_path, + agent=agent, + id=conv_id, + ) + state.stats.register_llm(RegistryEvent(llm=llm)) + + base_state_path = Path(persist_path) / "base_state.json" + with state: + state.mark_dirty() + before = json.loads(base_state_path.read_text())["stats"]["usage_to_metrics"] + assert before["test-llm"]["accumulated_cost"] == pytest.approx(0.0) + + # In-place cost mutation on the shared Metrics. No public-field + # assignment fires, so the snapshot still reads the old value until a + # mark_dirty() flush runs. + assert llm.metrics is not None + llm.metrics.add_cost(0.25) + stale = json.loads(base_state_path.read_text())["stats"]["usage_to_metrics"] + assert stale["test-llm"]["accumulated_cost"] == pytest.approx(0.0) + + with state: + state.mark_dirty() + + after = json.loads(base_state_path.read_text())["stats"]["usage_to_metrics"] + assert after["test-llm"]["accumulated_cost"] == pytest.approx(0.25) + + def test_resume_with_conversation_id_mismatch_raises_error(): """Test that resuming with mismatched conversation ID raises error.""" with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py new file mode 100644 index 00000000000..5e4cb415326 --- /dev/null +++ b/tests/sdk/llm/test_cost_budget.py @@ -0,0 +1,129 @@ +import threading + +import pytest + +from openhands.sdk.llm.cost_budget import BudgetReservation, CostBudget + + +class _Costs: + """A mutable stand-in for a conversation's recorded-cost source.""" + + def __init__(self, value: float = 0.0) -> None: + self.value = value + + def __call__(self) -> float: + return self.value + + +def test_reserve_denies_when_it_would_exceed_limit(): + budget = CostBudget(1.0, _Costs()) + + assert budget.try_reserve(0.4) is not None + assert budget.try_reserve(0.4) is not None + # 0.4 + 0.4 + 0.4 > 1.0 + assert budget.try_reserve(0.4) is None + assert budget.reserved == pytest.approx(0.8) + + +def test_settle_records_actual_cost_and_frees_capacity(): + budget = CostBudget(1.0, _Costs()) + + reservation = budget.try_reserve(0.5) + assert reservation is not None + assert budget.reserved == pytest.approx(0.5) + + # The provider charged less than the worst-case reservation. + budget.settle(reservation, 0.2) + assert budget.reserved == 0.0 + assert budget.spent == pytest.approx(0.2) + assert budget.live_total == pytest.approx(0.2) + + # Freed capacity is usable again. + assert budget.try_reserve(0.7) is not None + + +def test_live_total_includes_in_flight_reservations(): + budget = CostBudget(10.0, _Costs()) + budget.start_run() + seed = budget.try_reserve(0.0) + assert seed is not None + budget.settle(seed, 3.0) + reservation = budget.try_reserve(2.0) + assert reservation is not None + assert budget.live_total == pytest.approx(5.0) + + +def test_start_run_seeds_spend_from_recorded_cost(): + costs = _Costs(4.0) + budget = CostBudget(10.0, costs) + budget.start_run() + assert budget.spent == pytest.approx(4.0) + # 4.0 recorded + 7.0 attempted > 10.0 + assert budget.try_reserve(7.0) is None + assert budget.try_reserve(6.0) is not None + + +def test_child_shares_reservation_pool_but_bounds_its_own_spend(): + parent_costs = _Costs() + child_costs = _Costs() + parent = CostBudget(10.0, parent_costs) + parent.start_run() + child = parent.child(1.0, child_costs) + + # Child spend draws on the shared pool. + reservation = child.try_reserve(0.9) + assert reservation is not None + child_costs.value = 0.9 + child.settle(reservation, 0.9) + assert parent.reserved == pytest.approx(0.0) + assert parent.live_total == pytest.approx(0.9) + + # The child's own tighter ceiling now refuses more even though the parent + # has plenty of capacity left. + assert child.try_reserve(0.5) is None + assert parent.try_reserve(0.5) is not None + + +def test_concurrent_reservations_cannot_overshoot(): + """A shared budget admits at most the calls that fit, even in parallel.""" + budget = CostBudget(1.0, _Costs()) + barrier = threading.Barrier(8) + granted: list[BudgetReservation] = [] + lock = threading.Lock() + + def worker(): + barrier.wait() + reservation = budget.try_reserve(0.25) + if reservation is not None: + with lock: + granted.append(reservation) + + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + # 8 x 0.25 = 2.0 against a 1.0 ceiling: exactly four should be admitted. + assert len(granted) == 4 + assert budget.reserved == pytest.approx(1.0) + + +def test_denial_detail_names_the_limit_and_attempted_call(): + budget = CostBudget(1.0, _Costs()) + budget.start_run() + seed = budget.try_reserve(0.0) + assert seed is not None + budget.settle(seed, 0.9) + detail = budget.denial_detail(0.5) + assert "$1.0000" in detail + assert "$0.9000" in detail + # 0.9 recorded + 0.5 attempted. + assert "$1.4000" in detail + + +def test_non_positive_limit_is_rejected(): + with pytest.raises(ValueError): + CostBudget(0.0, _Costs()) + with pytest.raises(ValueError): + CostBudget(1.0, _Costs()).child(0.0, _Costs()) From 47825a8b77659820038a3b54a2d7e5c458d72933 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 8 Oct 2026 12:21:46 +0000 Subject: [PATCH 02/24] fix(sdk): enforce tightest ancestor budget; independent fork budgets; TS budget option Address PR review feedback on the per-conversation cost budget: - CostBudget child views now retain every ancestor ceiling and enforce the tightest one, so a delegate declaring a larger budget than its parent can no longer spend past the parent's cap. `limit` reports the tightest enforced ceiling; lock-held detail formatting avoids re-entrant lock hazards. - LocalConversation._budget_exceeded_detail keeps the ACP post-step backstop documented (ACP prompts bypass pre-call reservation) and defers to the shared ledger when a subagent only inherited a parent's ceiling. - fork() no longer inherits the source's budget ledger: the fork keeps the source's max_budget_per_run but starts with its own fresh ledger, so fork calls never reserve against the source conversation. - TypeScript client: add max_budget_per_run to CreateConversationRequest / CreateACPConversationRequest and expose maxBudgetPerRun via RemoteConversationOptions, start(), and ConversationManager.createACPConversation. Co-authored-by: openhands --- .../src/__tests__/api-clients.test.ts | 40 +++++++++++ .../src/conversation/conversation-manager.ts | 7 ++ .../src/conversation/remote-conversation.ts | 11 +++ clients/typescript/src/models/conversation.ts | 13 ++++ .../conversation/impl/local_conversation.py | 27 ++++++- .../openhands/sdk/llm/cost_budget.py | 70 ++++++++++++++----- tests/sdk/conversation/local/test_fork.py | 20 ++++++ tests/sdk/llm/test_cost_budget.py | 19 +++++ 8 files changed, 188 insertions(+), 19 deletions(-) diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts index c58a9a40ed9..ac7049dfa9e 100644 --- a/clients/typescript/src/__tests__/api-clients.test.ts +++ b/clients/typescript/src/__tests__/api-clients.test.ts @@ -1946,6 +1946,46 @@ describe('Auxiliary API clients', () => { }); }); + it('RemoteConversation.start sends the per-conversation cost budget', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'conv-123' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const agent = new Agent({ llm: { model: 'gpt-4o', api_key: 'k' } }); + const workspace = new RemoteWorkspace({ host: 'http://example.com', workingDir: '/tmp' }); + const conversation = new RemoteConversation(agent, workspace); + + await conversation.start({ initialMessage: 'hello', maxBudgetPerRun: 2.5 }); + + const [, init] = (global.fetch as Mock).mock.calls[0]; + expect(JSON.parse(init.body)).toMatchObject({ + max_budget_per_run: 2.5, + }); + }); + + it('RemoteConversation.start omits the budget when unset', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'conv-123' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const agent = new Agent({ llm: { model: 'gpt-4o', api_key: 'k' } }); + const workspace = new RemoteWorkspace({ host: 'http://example.com', workingDir: '/tmp' }); + const conversation = new RemoteConversation(agent, workspace); + + await conversation.start({ initialMessage: 'hello' }); + + const [, init] = (global.fetch as Mock).mock.calls[0]; + expect(JSON.parse(init.body)).toMatchObject({ + max_budget_per_run: null, + }); + }); + it('HttpClient can parse blob responses when requested', async () => { global.fetch = vi.fn().mockResolvedValue( new Response(new Blob(['zip-data']), { diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts index 8ce95d6eae7..d8aa41fa421 100644 --- a/clients/typescript/src/conversation/conversation-manager.ts +++ b/clients/typescript/src/conversation/conversation-manager.ts @@ -339,6 +339,12 @@ export class ConversationManager { stuckDetection?: boolean; workingDir?: string; userId?: string; + /** + * Per-conversation cost ceiling in USD. Must be > 0 when set. Note that + * ACP prompts are enforced post-hoc, since pre-call reservation cannot + * cover the external provider path. + */ + maxBudgetPerRun?: number; } = {} ): Promise { let initialMessage: CreateACPConversationRequest['initial_message']; @@ -353,6 +359,7 @@ export class ConversationManager { agent, initial_message: initialMessage, max_iterations: options.maxIterations || 500, + max_budget_per_run: options.maxBudgetPerRun ?? null, stuck_detection: options.stuckDetection ?? true, workspace: { type: 'local', working_dir: options.workingDir || '/tmp' }, user_id: options.userId ?? null, diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts index d6c82c8cfb4..05716d2316e 100644 --- a/clients/typescript/src/conversation/remote-conversation.ts +++ b/clients/typescript/src/conversation/remote-conversation.ts @@ -52,6 +52,11 @@ export interface RemoteConversationOptions extends BaseConversationOptions { * (PreToolUse, PostToolUse, UserPromptSubmit, Stop, etc.). */ hookConfig?: HookConfig; + /** + * Optional per-conversation cost ceiling in USD. Must be > 0 when set. + * Enforced server-side before each LLM call. + */ + maxBudgetPerRun?: number; /** * Optional error callback for non-fatal errors (WebSocket issues, state update failures). * If not provided, these errors are silently ignored. @@ -92,6 +97,7 @@ export class RemoteConversation implements IConversation { private onError?: ErrorCallbackType; private hookConfig?: HookConfig; private userId?: string; + private maxBudgetPerRun?: number; constructor( agent: AgentBase, @@ -105,6 +111,7 @@ export class RemoteConversation implements IConversation { this._conversationId = options.conversationId; this.hookConfig = options.hookConfig; this.userId = options.userId; + this.maxBudgetPerRun = options.maxBudgetPerRun; this.client = new HttpClient({ baseUrl: workspace.host, @@ -143,6 +150,8 @@ export class RemoteConversation implements IConversation { stuckDetection?: boolean; hookConfig?: HookConfig; userId?: string; + /** Per-conversation cost ceiling in USD. Must be > 0 when set. */ + maxBudgetPerRun?: number; } = {} ): Promise { if (this._conversationId) { @@ -166,11 +175,13 @@ export class RemoteConversation implements IConversation { // Use hook config from start options, falling back to constructor option const hookConfig = options.hookConfig ?? this.hookConfig ?? undefined; const userId = options.userId ?? this.userId ?? undefined; + const maxBudgetPerRun = options.maxBudgetPerRun ?? this.maxBudgetPerRun; const request: CreateConversationRequest = { agent: this.agent, initial_message: initialMessage, max_iterations: options.maxIterations || 500, + max_budget_per_run: maxBudgetPerRun ?? null, stuck_detection: options.stuckDetection ?? true, workspace: { type: 'local', working_dir: this.workspace.workingDir }, hook_config: hookConfig ?? null, diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts index bfd1b6eec14..f887053d840 100644 --- a/clients/typescript/src/models/conversation.ts +++ b/clients/typescript/src/models/conversation.ts @@ -57,6 +57,11 @@ export interface ConversationInfo { workspace: unknown; persistence_dir: string; max_iterations?: number; + /** + * Per-conversation cost ceiling in USD, or null/undefined when unbounded. + * Enforced server-side before each LLM call. + */ + max_budget_per_run?: number | null; stuck_detection?: boolean; conversation_stats?: ConversationStats; /** API may return stats instead of conversation_stats */ @@ -118,6 +123,12 @@ export interface CreateConversationRequest { agent: AgentBase; initial_message?: Message; max_iterations: number; + /** + * Per-conversation cost ceiling in USD. Must be > 0 when set; omit or pass + * null for an unbounded conversation. Enforced server-side before each LLM + * call. + */ + max_budget_per_run?: number | null; stuck_detection: boolean; workspace: Record; hook_config?: HookConfig | null; @@ -128,6 +139,8 @@ export interface CreateACPConversationRequest { agent: ACPAgentConfig; initial_message?: Message; max_iterations: number; + /** Per-conversation cost ceiling in USD; see {@link CreateConversationRequest}. */ + max_budget_per_run?: number | null; stuck_detection: boolean; workspace: Record; hook_config?: HookConfig | null; diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index d0aab024f8f..b7730ea4279 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -757,10 +757,26 @@ def _budget_exceeded_detail(self) -> str | None: condenser, ...), complementing the iteration cap which only bounds step count. Uses live cost (recorded + in-flight reservations) so a run in progress cannot slip past the ceiling between checks. + + ACP prompts bypass the pre-call reservation path, so for those the + post-step check below is the backstop that still stops the run. """ if self._cost_budget is None: return None - return self._cost_budget.exceeded_detail() + detail = self._cost_budget.exceeded_detail() + if detail is not None: + return detail + if self.max_budget_per_run is None: + # Inherited a parent's ceiling without one of its own; the shared + # ledger already enforces it. + return None + spent = self._combined_cost() + if spent < self.max_budget_per_run: + return None + return ( + f"Agent reached maximum budget limit (${self.max_budget_per_run:.4f}); " + f"accumulated cost ${spent:.4f}." + ) def _begin_budget_run(self) -> None: """Start a fresh budget allowance for a top-level run. @@ -910,11 +926,18 @@ def fork( persistence_dir=fork_persistence, conversation_id=fork_id, max_iteration_per_run=self.max_iteration_per_run, + max_budget_per_run=self.max_budget_per_run, stuck_detection=self._stuck_detector is not None, visualizer=type(self._visualizer) if self._visualizer else None, delete_on_close=self.delete_on_close, tags=tags, - _parent_llm_call_context=self._llm_call_context, + # A fork is an independent conversation: inherit the source's + # non-budget call context but not its ceiling, so the fork's + # calls never reserve against the source's ledger. Its own + # max_budget_per_run (copied from the source) starts fresh. + _parent_llm_call_context=replace( + self._llm_call_context, cost_budget=None + ), ) # Branch slice copies path_to_root(event) (root-first, re-rootable); diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py index 9c8ed006cfa..1cb2022adf2 100644 --- a/openhands-sdk/openhands/sdk/llm/cost_budget.py +++ b/openhands-sdk/openhands/sdk/llm/cost_budget.py @@ -14,7 +14,17 @@ after it finishes. The ledger is seeded once per run from the conversation's combined metrics, which bounds spend across every LLM in the conversation -- agent, condenser, fallback, and already-synced subagents -- without -double-counting across runs. +double-counting across runs. A derived child view keeps every ancestor ceiling +and enforces the tightest one, so a delegate declaring a larger budget than its +parent cannot spend past the parent's cap. + +Pre-call reservation only covers the LLM call wrappers that install a +reservation (``LLM.completion``/``acompletion``/...). Agents that delegate to an +external process -- notably :class:`~openhands.sdk.agent.acp_agent.ACPAgent`, +whose prompts never pass through those wrappers -- do not reserve, so a budget +on such a conversation is a *post-hoc* ceiling: the run loop checks recorded +cost after each step, which still stops the run rather than admitting an +unbounded call. """ from __future__ import annotations @@ -48,7 +58,8 @@ class CostBudget: ``limit`` bounds the combined cost of the conversation (and, for a shared tree, of its subagents). The ledger is shared by derived child views, each - of which honours its own ``limit`` over the same running total. + of which honours the tightest ``limit`` among itself and its ancestors over + the same running total. """ def __init__(self, limit: float, cost_source: Callable[[], float]) -> None: @@ -57,13 +68,15 @@ def __init__(self, limit: float, cost_source: Callable[[], float]) -> None: self._limit = limit self._cost_source = cost_source self._ledger = _Ledger() + self._ancestor_limits: tuple[float, ...] = () def child(self, limit: float, cost_source: Callable[[], float]) -> CostBudget: """Derive a subagent view with its own (usually smaller) ceiling. Shares the parent's ledger, so the whole tree contends on one running total, but reads the recorded cost source from the child conversation - and enforces the child's own limit as well. + and enforces its own limit *and* every ancestor ceiling. A child cannot + declare a larger budget than its parent and spend past the parent's cap. """ if limit <= 0: raise ValueError("Cost budget limit must be strictly positive") @@ -71,11 +84,25 @@ def child(self, limit: float, cost_source: Callable[[], float]) -> CostBudget: view._limit = limit view._cost_source = cost_source view._ledger = self._ledger + view._ancestor_limits = (*self._ancestor_limits, self._limit) return view @property def limit(self) -> float: - return self._limit + """Tightest ceiling this view enforces, including ancestor limits.""" + return min(self._ancestor_limits) if self._ancestor_limits else self._limit + + def _breaches(self, total: float) -> bool: + """True if ``total`` would exceed this view's ceiling or an ancestor's.""" + if total > self._limit: + return True + return any(total > ceiling for ceiling in self._ancestor_limits) + + def _reached(self, total: float) -> bool: + """True if ``total`` has already reached a ceiling (``>=`` for reporting).""" + if total >= self._limit: + return True + return any(total >= ceiling for ceiling in self._ancestor_limits) @property def spent(self) -> float: @@ -101,19 +128,22 @@ def start_run(self) -> None: budget shares the ledger and must not re-seed it mid-flight. """ with self._ledger.lock: - self._ledger.settled = self._cost_source() + self._ledger.settled = max(0.0, self._cost_source()) self._ledger.reserved = 0.0 def try_reserve(self, amount: float) -> BudgetReservation | None: """Atomically reserve ``amount`` or return ``None`` if it would exceed. The comparison and the reservation happen under one lock, so parallel - callers cannot all pass the same check. + callers cannot all pass the same check. Every ancestor ceiling is checked + alongside this view's own, so a child view cannot admit a call that + breaches a parent's cap. """ if amount < 0: raise ValueError("Reservation amount cannot be negative") with self._ledger.lock: - if self._ledger.settled + self._ledger.reserved + amount > self._limit: + total = self._ledger.settled + self._ledger.reserved + amount + if self._breaches(total): return None self._ledger.reserved += amount return BudgetReservation(self, amount) @@ -125,21 +155,27 @@ def settle(self, reservation: BudgetReservation, actual: float) -> None: self._ledger.settled += max(0.0, actual) def exceeded_detail(self) -> str | None: - """Error detail if the live total has reached the ceiling, else ``None``.""" - if self.live_total < self._limit: + """Error detail if the live total has reached a ceiling, else ``None``.""" + with self._ledger.lock: + total = self._ledger.settled + self._ledger.reserved + reached = self._reached(total) + if not reached: return None - return self.denial_detail(0.0) + return self._limit_detail(accumulated=max(total, 0.0)) def denial_detail(self, amount: float) -> str: - """Message for a reservation of ``amount`` that would exceed the ceiling.""" - return self._limit_detail(attempted=self.live_total + amount) + """Message for a reservation of ``amount`` that would exceed a ceiling.""" + with self._ledger.lock: + total = self._ledger.settled + self._ledger.reserved + return self._limit_detail( + accumulated=max(total, 0.0), attempted=max(total, 0.0) + amount + ) - def _limit_detail(self, attempted: float | None = None) -> str: - # Report the larger of recorded cost and the live bound so the message - # reflects in-flight spend when it is the reason for the denial. - accumulated = max(self.spent, self.live_total) + def _limit_detail( + self, *, accumulated: float, attempted: float | None = None + ) -> str: detail = ( - f"Agent reached maximum budget limit (${self._limit:.4f}); " + f"Agent reached maximum budget limit (${self.limit:.4f}); " f"accumulated cost ${accumulated:.4f}." ) if attempted is not None: diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py index 51f891f8681..b4609855fba 100644 --- a/tests/sdk/conversation/local/test_fork.py +++ b/tests/sdk/conversation/local/test_fork.py @@ -178,6 +178,26 @@ def test_fork_preserves_metrics_when_requested(): assert combined.accumulated_cost == pytest.approx(1.5) +def test_fork_does_not_share_source_budget_ledger(): + """A fork is independent: it must not reserve against the source's budget.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = Conversation( + agent=_agent(), + persistence_dir=tmpdir, + workspace=tmpdir, + max_budget_per_run=1.0, + ) + fork = src.fork() + + # The fork carries the source's ceiling... + assert fork.max_budget_per_run == pytest.approx(1.0) + assert fork._cost_budget is not None + # ...but owns its own ledger rather than sharing the source's. + assert src._cost_budget is not None + assert fork._cost_budget is not src._cost_budget + assert fork._cost_budget._ledger is not src._cost_budget._ledger + + def test_fork_copies_agent_state(): """agent_state dict should be carried over to the fork.""" with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index 5e4cb415326..bce5585febc 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -84,6 +84,25 @@ def test_child_shares_reservation_pool_but_bounds_its_own_spend(): assert parent.try_reserve(0.5) is not None +def test_child_cannot_exceed_a_tighter_ancestor_ceiling(): + """A delegate with a larger budget still cannot breach its parent's cap.""" + parent = CostBudget(1.0, _Costs()) + parent.start_run() + # The child declares a *larger* budget than the parent. + child = parent.child(5.0, _Costs()) + + grant = parent.try_reserve(0.0) + assert grant is not None + parent.settle(grant, 0.8) + + # The child's own $5 limit would admit $0.50, but the parent's $1 cap sees + # $1.30 total and refuses. `limit` reports the tightest enforced ceiling. + assert child.try_reserve(0.5) is None + assert child.limit == pytest.approx(1.0) + # Within the shared cap the child is admitted. + assert child.try_reserve(0.1) is not None + + def test_concurrent_reservations_cannot_overshoot(): """A shared budget admits at most the calls that fit, even in parallel.""" budget = CostBudget(1.0, _Costs()) From 515644dada1ec4d0f0812174d96c4508018d0e16 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 8 Oct 2026 14:07:59 +0000 Subject: [PATCH 03/24] fix(sdk): make max_budget_per_run a true per-run ceiling; document ACP path Split lifetime vs per-run spend so `max_budget_per_run` bounds the current run instead of draining later runs with earlier spend: - `_combined_cost_total()` is lifetime recorded cost; `_combined_cost()` subtracts the run-start baseline captured in `_begin_budget_run()`. - `CostBudget.start_run()` becomes `reseed()`, zeroing the shared ledger at each top-level run start; a child that inherited a parent's budget shares the ledger and never reseeds mid-flight. Also make the public field descriptions honest that pre-call reservation is per-LLM-call only: ACP delegates bypass the reservation wrappers and fall back to the post-step recorded-cost check. Tests: accrue cost mid-run for the status-transition budget test (real per-run scenario), add a per-run-vs-lifetime conversation test, and rewrite the CostBudget unit tests for the self-contained ledger. Co-authored-by: openhands --- .../openhands/agent_server/models.py | 4 +- .../conversation/impl/local_conversation.py | 50 +++++++----- .../openhands/sdk/conversation/request.py | 11 ++- .../openhands/sdk/llm/cost_budget.py | 63 ++++++++------- .../local/test_agent_status_transition.py | 24 ++++-- .../local/test_cost_budget_enforcement.py | 30 ++++++++ tests/sdk/llm/test_cost_budget.py | 76 ++++++++----------- 7 files changed, 148 insertions(+), 110 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py index 70ebea5f030..c7406b9b969 100644 --- a/openhands-agent-server/openhands/agent_server/models.py +++ b/openhands-agent-server/openhands/agent_server/models.py @@ -173,7 +173,9 @@ class _ConversationInfoBase(BaseModel): gt=0, description=( "Maximum cost in USD the agent may spend in a single run before " - "stopping. Enforcement is pre-call and reservation-based. ``None`` " + "stopping. Pre-call and reservation-based on the SDK's LLM call " + "path; agents that delegate to an external process (for example " + "ACP) stop once recorded cost reaches the limit instead. ``None`` " "leaves the run unbounded." ), ) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index b7730ea4279..5487ff004ed 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -207,6 +207,9 @@ class LocalConversation(BaseConversation): _pending_hook_config: HookConfig | None # Hook config to combine with plugin hooks _mcp_tool_provider: MCPToolProvider _llm_call_context: LLMCallContext + # Lifetime recorded cost at the last run start; ``max_budget_per_run`` is a + # per-run ceiling measured from here. + _budget_run_baseline: float def __init__( self, @@ -492,8 +495,9 @@ def _default_callback(e): ) self.max_iteration_per_run = max_iteration_per_run - # Hard cost ceiling (USD) for the conversation; None disables it. + # Hard cost ceiling (USD) per run; None disables it. self.max_budget_per_run = max_budget_per_run + self._budget_run_baseline = 0.0 inherited_budget = ( _parent_llm_call_context.cost_budget if _parent_llm_call_context is not None @@ -507,12 +511,10 @@ def _default_callback(e): if max_budget_per_run is not None: if inherited_budget is not None: # A subagent shares its parent's reservation pool so parent and - # children contend on one ceiling; its own limit bounds it too. - self._cost_budget = inherited_budget.child( - max_budget_per_run, self._combined_cost - ) + # children contend on one ceiling; the tightest limit applies. + self._cost_budget = inherited_budget.child(max_budget_per_run) else: - self._cost_budget = CostBudget(max_budget_per_run, self._combined_cost) + self._cost_budget = CostBudget(max_budget_per_run) self._owns_budget = True self._llm_call_context = replace( self._llm_call_context, cost_budget=self._cost_budget @@ -738,7 +740,15 @@ def conversation_stats(self): return self._state.stats def _combined_cost(self) -> float: - """Combined recorded cost across the conversation's LLMs (USD).""" + """Recorded cost across the conversation's LLMs in the current run (USD). + + Subtracts the run-start baseline so ``max_budget_per_run`` bounds the + current run rather than draining every later run with earlier spend. + """ + return self._combined_cost_total() - self._budget_run_baseline + + def _combined_cost_total(self) -> float: + """Lifetime recorded cost across the conversation's LLMs (USD).""" return self.conversation_stats.get_combined_metrics().accumulated_cost def _latest_acp_prompt_message_id(self) -> str | None: @@ -751,15 +761,15 @@ def _latest_acp_prompt_message_id(self) -> str | None: return acp_prompt_messages[-1].id if acp_prompt_messages else None def _budget_exceeded_detail(self) -> str | None: - """Error detail if the conversation has hit its cost budget, else None. + """Error detail if the run has hit its cost budget, else None. - Bounds total spend across all of the conversation's LLMs (agent, - condenser, ...), complementing the iteration cap which only bounds step - count. Uses live cost (recorded + in-flight reservations) so a run in - progress cannot slip past the ceiling between checks. + Bounds spend across all of the conversation's LLMs (agent, condenser, + ...) in the current run, complementing the iteration cap which only + bounds step count. Uses live cost (recorded + in-flight reservations) so + a run in progress cannot slip past the ceiling between checks. ACP prompts bypass the pre-call reservation path, so for those the - post-step check below is the backstop that still stops the run. + recorded-cost check below is the backstop that still stops the run. """ if self._cost_budget is None: return None @@ -779,16 +789,18 @@ def _budget_exceeded_detail(self) -> str | None: ) def _begin_budget_run(self) -> None: - """Start a fresh budget allowance for a top-level run. + """Start a fresh per-run budget allowance for a top-level run. - Seeds the ledger from the conversation's recorded combined cost so the - ceiling is per-run (matching ``max_budget_per_run``). A conversation that - only inherited a parent's budget shares the parent's ledger and must not - re-seed it mid-flight. + Records the run-start baseline and reseeds the ledger, so each + ``run()``/``arun()`` gets its own ``max_budget_per_run`` allowance rather + than inheriting earlier runs' spend. A conversation that only inherited a + parent's budget shares the parent's ledger and must not reseed it + mid-flight. """ + self._budget_run_baseline = self._combined_cost_total() if self._owns_budget: assert self._cost_budget is not None - self._cost_budget.start_run() + self._cost_budget.reseed() def _emit_run_limit_error(self, code: str, detail: str) -> None: """Mark the run failed with a run-limit ConversationErrorEvent.""" diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py index 498d25f6210..c651be642ac 100644 --- a/openhands-sdk/openhands/sdk/conversation/request.py +++ b/openhands-sdk/openhands/sdk/conversation/request.py @@ -151,10 +151,13 @@ class ConversationConfig(BaseModel): gt=0, description=( "Maximum cost in USD the agent may spend in a single run before " - "stopping. Enforcement is pre-call and reservation-based: each LLM " - "call reserves its worst-case cost before being sent, so a run " - "cannot overshoot the budget by a whole step. ``None`` (the " - "default) leaves the run unbounded." + "stopping. Enforcement is pre-call and reservation-based on the " + "SDK's LLM call path: each LLM call reserves its worst-case cost " + "before being sent, so a run cannot overshoot the budget by a whole " + "step. Agents that delegate to an external process (for example " + "ACP) do not reserve before sending; for those the run stops once " + "recorded cost reaches the limit. ``None`` (the default) leaves the " + "run unbounded." ), ) stuck_detection: bool = Field( diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py index 1cb2022adf2..d162c1779ac 100644 --- a/openhands-sdk/openhands/sdk/llm/cost_budget.py +++ b/openhands-sdk/openhands/sdk/llm/cost_budget.py @@ -5,32 +5,29 @@ the same total and pass, and a single call can still overshoot by its own worst case. ``CostBudget`` therefore *reserves* each call's worst-case cost before it is sent and settles it once the call completes, releasing the -remainder. Reserved + settled is a live upper bound on what the conversation -can owe, which also answers "how much has this conversation accumulated so far" -while it is still running. - -Settled cost is carried on a shared ledger, so a parent and its subagents -contend on one ceiling even though a subagent's metrics join the parent only -after it finishes. The ledger is seeded once per run from the conversation's -combined metrics, which bounds spend across every LLM in the conversation -- -agent, condenser, fallback, and already-synced subagents -- without -double-counting across runs. A derived child view keeps every ancestor ceiling -and enforces the tightest one, so a delegate declaring a larger budget than its -parent cannot spend past the parent's cap. +remainder. Settled + reserved is a live upper bound on what the current run can +owe. + +Settled cost is carried on a ledger shared by a parent and its subagents, so the +whole tree contends on one running total even though a subagent's metrics join +the parent only after it finishes. The ledger is reset at the start of each +top-level run (see :meth:`reseed`), which makes ``max_budget_per_run`` a +per-run ceiling rather than a lifetime cap. A derived child view keeps every +ancestor ceiling and enforces the tightest one, so a delegate declaring a larger +budget than its parent cannot spend past the parent's cap. Pre-call reservation only covers the LLM call wrappers that install a reservation (``LLM.completion``/``acompletion``/...). Agents that delegate to an external process -- notably :class:`~openhands.sdk.agent.acp_agent.ACPAgent`, -whose prompts never pass through those wrappers -- do not reserve, so a budget -on such a conversation is a *post-hoc* ceiling: the run loop checks recorded -cost after each step, which still stops the run rather than admitting an -unbounded call. +whose prompts never pass through those wrappers -- do not reserve, so a budget on +such a conversation is a *post-hoc* ceiling: the run loop checks recorded cost +after each step, which still stops the run rather than admitting an unbounded +call. """ from __future__ import annotations import threading -from collections.abc import Callable from dataclasses import dataclass @@ -56,33 +53,31 @@ class BudgetReservation: class CostBudget: """A cost ceiling (USD) enforced by pre-call reservation. - ``limit`` bounds the combined cost of the conversation (and, for a shared - tree, of its subagents). The ledger is shared by derived child views, each - of which honours the tightest ``limit`` among itself and its ancestors over - the same running total. + ``limit`` bounds the cost accrued in the current run (see :meth:`reseed`). + The ledger is shared by derived child views, each of which honours the + tightest ``limit`` among itself and its ancestors over the same running + total. """ - def __init__(self, limit: float, cost_source: Callable[[], float]) -> None: + def __init__(self, limit: float) -> None: if limit <= 0: raise ValueError("Cost budget limit must be strictly positive") self._limit = limit - self._cost_source = cost_source self._ledger = _Ledger() self._ancestor_limits: tuple[float, ...] = () - def child(self, limit: float, cost_source: Callable[[], float]) -> CostBudget: + def child(self, limit: float) -> CostBudget: """Derive a subagent view with its own (usually smaller) ceiling. Shares the parent's ledger, so the whole tree contends on one running - total, but reads the recorded cost source from the child conversation - and enforces its own limit *and* every ancestor ceiling. A child cannot - declare a larger budget than its parent and spend past the parent's cap. + total, and enforces its own limit *and* every ancestor ceiling. A child + cannot declare a larger budget than its parent and spend past the + parent's cap. """ if limit <= 0: raise ValueError("Cost budget limit must be strictly positive") view = CostBudget.__new__(CostBudget) view._limit = limit - view._cost_source = cost_source view._ledger = self._ledger view._ancestor_limits = (*self._ancestor_limits, self._limit) return view @@ -121,14 +116,16 @@ def live_total(self) -> float: with self._ledger.lock: return self._ledger.settled + self._ledger.reserved - def start_run(self) -> None: - """Seed the ledger from recorded cost so each run gets a fresh allowance. + def reseed(self) -> None: + """Start a fresh per-run allowance. - Called at the start of a top-level run; a run that inherits a parent's - budget shares the ledger and must not re-seed it mid-flight. + Zeroes the shared ledger so each top-level ``run()``/``arun()`` gets its + own ``max_budget_per_run`` allowance rather than inheriting earlier runs' + spend. A run that inherited a parent's budget shares the parent's ledger + and must not reseed it mid-flight. """ with self._ledger.lock: - self._ledger.settled = max(0.0, self._cost_source()) + self._ledger.settled = 0.0 self._ledger.reserved = 0.0 def try_reserve(self, amount: float) -> BudgetReservation | None: diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py index 807f19a897b..4b6ba77f1f8 100644 --- a/tests/sdk/conversation/local/test_agent_status_transition.py +++ b/tests/sdk/conversation/local/test_agent_status_transition.py @@ -27,6 +27,7 @@ from openhands.sdk.event import MessageEvent from openhands.sdk.event.conversation_error import ConversationErrorEvent from openhands.sdk.llm import ImageContent, Message, MessageToolCall, TextContent +from openhands.sdk.llm.utils.metrics import Metrics from openhands.sdk.testing import TestLLM from openhands.sdk.tool import ( Action, @@ -59,10 +60,15 @@ class StatusCheckingExecutor( ): """Executor that captures the agent status when executed.""" - def __init__(self, status_during_execution: list[ConversationExecutionStatus]): + def __init__( + self, + status_during_execution: list[ConversationExecutionStatus], + cost_per_call: float = 0.0, + ): self.status_during_execution: list[ConversationExecutionStatus] = ( status_during_execution ) + self.cost_per_call = cost_per_call def __call__( self, action: StatusTransitionMockAction, conversation=None @@ -70,6 +76,11 @@ def __call__( # Capture the agent status during execution if conversation: self.status_during_execution.append(conversation.state.execution_status) + if self.cost_per_call: + # Accrue real spend mid-run, as a live LLM would, so the + # per-run budget check has something to trip on. + metrics = conversation.conversation_stats.usage_to_metrics + metrics.setdefault("spend", Metrics()).add_cost(self.cost_per_call) return StatusTransitionMockObservation(result=f"Executed: {action.command}") @@ -547,10 +558,13 @@ def test_execution_status_error_on_max_budget(tmp_path): """Run halts with ERROR + MaxBudgetReached when the cost budget is exceeded, even before the iteration cap is reached.""" from openhands.sdk.conversation.impl.local_conversation import LocalConversation - from openhands.sdk.llm.utils.metrics import Metrics events_received: list = [] - test_tool = StatusTransitionTestTool.create(executor=StatusCheckingExecutor([]))[0] + # Each executed tool call accrues $0.60 of real spend mid-run, so the second + # step pushes the run past the $1.00 ceiling. + test_tool = StatusTransitionTestTool.create( + executor=StatusCheckingExecutor([], cost_per_call=0.6) + )[0] register_tool("test_tool", test_tool) tool_call_message = Message( @@ -580,10 +594,6 @@ def test_execution_status_error_on_max_budget(tmp_path): conversation.send_message( Message(role="user", content=[TextContent(text="Execute command")]) ) - # Pre-seed spend over the budget (TestLLM accrues no real cost). - conversation.conversation_stats.usage_to_metrics["spend"] = Metrics( - accumulated_cost=5.0 - ) conversation.run() assert conversation.state.execution_status == ConversationExecutionStatus.ERROR diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py index d87be7d1375..18591537977 100644 --- a/tests/sdk/conversation/local/test_cost_budget_enforcement.py +++ b/tests/sdk/conversation/local/test_cost_budget_enforcement.py @@ -31,6 +31,7 @@ def _conversation(tmp: Path, *, max_budget_per_run: float | None) -> LocalConver num_retries=0, input_cost_per_token=1e-5, output_cost_per_token=1e-5, + max_output_tokens=2000, ) return Conversation( agent=Agent(llm=llm, tools=[]), @@ -82,3 +83,32 @@ def fake_completion(**kwargs): assert conv.state.execution_status == ConversationExecutionStatus.FINISHED cost = conv.conversation_stats.get_combined_metrics().accumulated_cost assert cost == pytest.approx(0.02) + + +def test_budget_is_per_run_not_lifetime(): + """A second run gets a fresh allowance; earlier spend does not drain it.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + # Each run records 0.02; one call reserves ~0.06 worst case (agent system + # prompt + 2000 output tokens). 0.07 admits a run only if the prior + # run's 0.02 does not count against it: a lifetime budget would see 0.02 + # already spent plus the next ~0.06 reservation and refuse. + conv = _conversation(tmp, max_budget_per_run=0.07) + + def fake_completion(**kwargs): + return _response_with_cost(kwargs.get("model", "gpt-4o-mini")) + + with patch( + "openhands.sdk.llm.llm.litellm_completion", side_effect=fake_completion + ): + conv.send_message(Message(role="user", content=[TextContent(text="one")])) + conv.run() + assert conv.state.execution_status == ConversationExecutionStatus.FINISHED + + conv.send_message(Message(role="user", content=[TextContent(text="two")])) + conv.run() + + assert conv.state.execution_status == ConversationExecutionStatus.FINISHED + # Lifetime cost still accumulates across runs, for reporting. + cost = conv.conversation_stats.get_combined_metrics().accumulated_cost + assert cost == pytest.approx(0.04) diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index bce5585febc..f717063e21b 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -5,18 +5,15 @@ from openhands.sdk.llm.cost_budget import BudgetReservation, CostBudget -class _Costs: - """A mutable stand-in for a conversation's recorded-cost source.""" - - def __init__(self, value: float = 0.0) -> None: - self.value = value - - def __call__(self) -> float: - return self.value +def _settle(budget: CostBudget, amount: float) -> None: + """Reserve and immediately settle ``amount``, as a completed call would.""" + reservation = budget.try_reserve(amount) + assert reservation is not None + budget.settle(reservation, amount) def test_reserve_denies_when_it_would_exceed_limit(): - budget = CostBudget(1.0, _Costs()) + budget = CostBudget(1.0) assert budget.try_reserve(0.4) is not None assert budget.try_reserve(0.4) is not None @@ -26,7 +23,7 @@ def test_reserve_denies_when_it_would_exceed_limit(): def test_settle_records_actual_cost_and_frees_capacity(): - budget = CostBudget(1.0, _Costs()) + budget = CostBudget(1.0) reservation = budget.try_reserve(0.5) assert reservation is not None @@ -43,37 +40,31 @@ def test_settle_records_actual_cost_and_frees_capacity(): def test_live_total_includes_in_flight_reservations(): - budget = CostBudget(10.0, _Costs()) - budget.start_run() - seed = budget.try_reserve(0.0) - assert seed is not None - budget.settle(seed, 3.0) - reservation = budget.try_reserve(2.0) - assert reservation is not None + budget = CostBudget(10.0) + _settle(budget, 3.0) + assert budget.try_reserve(2.0) is not None assert budget.live_total == pytest.approx(5.0) -def test_start_run_seeds_spend_from_recorded_cost(): - costs = _Costs(4.0) - budget = CostBudget(10.0, costs) - budget.start_run() - assert budget.spent == pytest.approx(4.0) - # 4.0 recorded + 7.0 attempted > 10.0 - assert budget.try_reserve(7.0) is None - assert budget.try_reserve(6.0) is not None +def test_reseed_clears_prior_run_spend(): + budget = CostBudget(10.0) + _settle(budget, 7.0) + assert budget.spent == pytest.approx(7.0) + + budget.reseed() + assert budget.spent == 0.0 + assert budget.reserved == 0.0 + # A fresh run gets the full allowance again. + assert budget.try_reserve(7.0) is not None def test_child_shares_reservation_pool_but_bounds_its_own_spend(): - parent_costs = _Costs() - child_costs = _Costs() - parent = CostBudget(10.0, parent_costs) - parent.start_run() - child = parent.child(1.0, child_costs) + parent = CostBudget(10.0) + child = parent.child(1.0) # Child spend draws on the shared pool. reservation = child.try_reserve(0.9) assert reservation is not None - child_costs.value = 0.9 child.settle(reservation, 0.9) assert parent.reserved == pytest.approx(0.0) assert parent.live_total == pytest.approx(0.9) @@ -86,14 +77,10 @@ def test_child_shares_reservation_pool_but_bounds_its_own_spend(): def test_child_cannot_exceed_a_tighter_ancestor_ceiling(): """A delegate with a larger budget still cannot breach its parent's cap.""" - parent = CostBudget(1.0, _Costs()) - parent.start_run() + parent = CostBudget(1.0) # The child declares a *larger* budget than the parent. - child = parent.child(5.0, _Costs()) - - grant = parent.try_reserve(0.0) - assert grant is not None - parent.settle(grant, 0.8) + child = parent.child(5.0) + _settle(parent, 0.8) # The child's own $5 limit would admit $0.50, but the parent's $1 cap sees # $1.30 total and refuses. `limit` reports the tightest enforced ceiling. @@ -105,7 +92,7 @@ def test_child_cannot_exceed_a_tighter_ancestor_ceiling(): def test_concurrent_reservations_cannot_overshoot(): """A shared budget admits at most the calls that fit, even in parallel.""" - budget = CostBudget(1.0, _Costs()) + budget = CostBudget(1.0) barrier = threading.Barrier(8) granted: list[BudgetReservation] = [] lock = threading.Lock() @@ -129,11 +116,8 @@ def worker(): def test_denial_detail_names_the_limit_and_attempted_call(): - budget = CostBudget(1.0, _Costs()) - budget.start_run() - seed = budget.try_reserve(0.0) - assert seed is not None - budget.settle(seed, 0.9) + budget = CostBudget(1.0) + _settle(budget, 0.9) detail = budget.denial_detail(0.5) assert "$1.0000" in detail assert "$0.9000" in detail @@ -143,6 +127,6 @@ def test_denial_detail_names_the_limit_and_attempted_call(): def test_non_positive_limit_is_rejected(): with pytest.raises(ValueError): - CostBudget(0.0, _Costs()) + CostBudget(0.0) with pytest.raises(ValueError): - CostBudget(1.0, _Costs()).child(0.0, _Costs()) + CostBudget(1.0).child(0.0) From 9636919afe6f3ad3d9e7fe985cf2cac4dff92c5f Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 8 Oct 2026 17:34:32 +0000 Subject: [PATCH 04/24] fix(sdk): reject cost budget on ACP conversations; map to HTTP 422 ACP prompts run in an external process that reports usage only after the turn, so the SDK cannot reserve a call's worst-case cost before sending and a single prompt can exceed max_budget_per_run. Rather than accept an unenforceable post-hoc cap, raise CostBudgetUnsupportedError when a budget is set on an ACP conversation, surface it as HTTP 422 on the agent-server start route, and update the public field docs to state the limitation. Co-authored-by: openhands --- .../agent_server/conversation_router.py | 5 +++ .../openhands/agent_server/models.py | 6 ++-- .../openhands/sdk/conversation/exceptions.py | 12 +++++++ .../conversation/impl/local_conversation.py | 21 ++++++++++- .../openhands/sdk/conversation/request.py | 13 ++++--- .../agent_server/test_conversation_router.py | 25 +++++++++++++ .../agent_server/test_conversation_service.py | 23 ++++++++++++ .../local/test_cost_budget_enforcement.py | 36 +++++++++++++++++++ 8 files changed, 130 insertions(+), 11 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py index e57ae2b1260..56569541ed7 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py @@ -52,6 +52,7 @@ ) from openhands.agent_server.persistence import get_llm_profile_store from openhands.sdk import LLM +from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.conversation.types import ( ConversationObservabilityMetadata, @@ -345,6 +346,10 @@ async def start_conversation( raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) ) from e + except CostBudgetUnsupportedError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) + ) from e except InvalidParentConversation as e: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py index c7406b9b969..559417fa619 100644 --- a/openhands-agent-server/openhands/agent_server/models.py +++ b/openhands-agent-server/openhands/agent_server/models.py @@ -174,9 +174,9 @@ class _ConversationInfoBase(BaseModel): description=( "Maximum cost in USD the agent may spend in a single run before " "stopping. Pre-call and reservation-based on the SDK's LLM call " - "path; agents that delegate to an external process (for example " - "ACP) stop once recorded cost reaches the limit instead. ``None`` " - "leaves the run unbounded." + "path; not supported for ACP agents, whose prompts bypass that path " + "(a budget supplied with an ACP agent is rejected). ``None`` leaves " + "the run unbounded." ), ) stuck_detection: bool = Field( diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py index 8fcc9b9bb7f..83ebab66d3f 100644 --- a/openhands-sdk/openhands/sdk/conversation/exceptions.py +++ b/openhands-sdk/openhands/sdk/conversation/exceptions.py @@ -5,6 +5,18 @@ ISSUE_URL = "https://github.com/OpenHands/software-agent-sdk/issues/new" +class CostBudgetUnsupportedError(ValueError): + """Raised when a cost budget is requested for an agent that cannot enforce it. + + Pre-call budget enforcement reserves a call's worst-case cost on the SDK's + LLM call path. An :class:`~openhands.sdk.agent.acp_agent.ACPAgent` delegates + each prompt to an external process that owns the model and reports usage only + after the turn, so the SDK cannot reserve before sending and a single prompt + can exceed the ceiling. The budget is therefore rejected rather than accepted + as an unenforceable post-hoc cap. + """ + + class WebSocketConnectionError(RuntimeError): """Raised when WebSocket connection fails to establish within the timeout.""" diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 5487ff004ed..65b53c17983 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -18,7 +18,10 @@ from openhands.sdk.conversation.base import BaseConversation from openhands.sdk.conversation.cancellation import CancellationToken from openhands.sdk.conversation.event_store import EventLog -from openhands.sdk.conversation.exceptions import ConversationRunError +from openhands.sdk.conversation.exceptions import ( + ConversationRunError, + CostBudgetUnsupportedError, +) from openhands.sdk.conversation.secret_registry import SecretValue from openhands.sdk.conversation.state import ( ConversationExecutionStatus, @@ -275,6 +278,11 @@ def __init__( hook_config: Optional hook configuration to auto-wire session hooks. If plugins are loaded, their hooks are combined with this config. max_iteration_per_run: Maximum number of iterations per run + max_budget_per_run: Maximum cost in USD per run. Enforced before each + LLM call by reserving the call's worst-case cost. Not + supported for ACP agents (their prompts bypass the SDK LLM + call path); passing a budget with one raises + :class:`CostBudgetUnsupportedError`. visualizer: Visualization configuration. Can be: - ConversationVisualizerBase subclass: Class to instantiate (default: ConversationVisualizer) @@ -508,6 +516,17 @@ def _default_callback(e): # that inherited a parent's budget shares its ledger and must never # re-seed it mid-flight. self._owns_budget = False + if max_budget_per_run is not None and isinstance(self.agent, ACPAgent): + # ACP prompts run in an external process that reports usage only + # after the turn, so the SDK cannot reserve a worst-case cost before + # sending and a single prompt can exceed the ceiling. Reject the + # budget rather than accept an unenforceable cap. + raise CostBudgetUnsupportedError( + "max_budget_per_run is not supported for ACP agents: their " + "prompts bypass the SDK's pre-call cost reservation, so a single " + "prompt can exceed the budget before it is recorded. Use a " + "regular (LLM) agent to enforce a per-conversation budget." + ) if max_budget_per_run is not None: if inherited_budget is not None: # A subagent shares its parent's reservation pool so parent and diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py index c651be642ac..18d16c5f245 100644 --- a/openhands-sdk/openhands/sdk/conversation/request.py +++ b/openhands-sdk/openhands/sdk/conversation/request.py @@ -151,13 +151,12 @@ class ConversationConfig(BaseModel): gt=0, description=( "Maximum cost in USD the agent may spend in a single run before " - "stopping. Enforcement is pre-call and reservation-based on the " - "SDK's LLM call path: each LLM call reserves its worst-case cost " - "before being sent, so a run cannot overshoot the budget by a whole " - "step. Agents that delegate to an external process (for example " - "ACP) do not reserve before sending; for those the run stops once " - "recorded cost reaches the limit. ``None`` (the default) leaves the " - "run unbounded." + "stopping. Enforcement is pre-call and reservation-based: each LLM " + "call reserves its worst-case cost before being sent, so a run " + "cannot overshoot the budget by a whole step. Not supported for ACP " + "agents, whose prompts bypass the SDK's LLM call path — supplying a " + "budget with an ACP agent is rejected rather than accepted as an " + "unenforceable cap. ``None`` (the default) leaves the run unbounded." ), ) stuck_detection: bool = Field( diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py index 837e637cc59..b5314fa0a50 100644 --- a/tests/agent_server/test_conversation_router.py +++ b/tests/agent_server/test_conversation_router.py @@ -29,6 +29,7 @@ from openhands.sdk import LLM, Agent, TextContent, Tool from openhands.sdk.agent.acp_agent import ACPAgent from openhands.sdk.agent.base import AgentBase +from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.launch import LaunchRuntime, LaunchStores, finalize from openhands.sdk.llm import llm_profile_store @@ -568,6 +569,30 @@ def test_start_conversation_new( client.app.dependency_overrides.clear() +def test_start_conversation_acp_budget_returns_422(client, mock_conversation_service): + """A budget on an ACP conversation surfaces as a 422, not a 500.""" + mock_conversation_service.start_conversation.side_effect = ( + CostBudgetUnsupportedError("max_budget_per_run is not supported for ACP") + ) + + client.app.dependency_overrides[get_conversation_service] = lambda: ( + mock_conversation_service + ) + + try: + request_data = { + "agent": {"kind": "ACPAgent", "acp_command": ["echo", "test"]}, + "workspace": {"working_dir": "/tmp/test"}, + "max_budget_per_run": 1.0, + } + response = client.post("/api/conversations", json=request_data) + + assert response.status_code == 422 + assert "max_budget_per_run" in response.json()["detail"] + finally: + client.app.dependency_overrides.clear() + + def test_start_conversation_merges_observability_headers( client, mock_conversation_service, sample_conversation_info ): diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py index 1ac36b3a05f..92dfb87b1d1 100644 --- a/tests/agent_server/test_conversation_service.py +++ b/tests/agent_server/test_conversation_service.py @@ -37,6 +37,7 @@ from openhands.agent_server.utils import safe_rmtree as _safe_rmtree from openhands.sdk import LLM, Agent, AgentBase, Message, Tool from openhands.sdk.agent.acp_agent import ACPAgent +from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.conversation.state import ( ConversationExecutionStatus, @@ -188,6 +189,28 @@ async def test_start_conversation_rejects_non_positive_budget(tmp_path): ) +@pytest.mark.asyncio +async def test_start_acp_conversation_rejects_budget(tmp_path): + """An ACP conversation cannot enforce a pre-call budget, so creation fails. + + The SDK raises before launching the ACP process (the budget check runs in + ``LocalConversation.__init__``), so no subprocess is needed here. + """ + conversations_dir = tmp_path / "conversations" + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + request = StartConversationRequest( + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=LocalWorkspace(working_dir=str(workspace_dir)), + confirmation_policy=NeverConfirm(), + max_budget_per_run=1.0, + ) + + async with ConversationService(conversations_dir=conversations_dir) as service: + with pytest.raises(CostBudgetUnsupportedError): + await service.start_conversation(request) + + def _create_running_terminal_action(tool_call_id: str = "call_1") -> ActionEvent: tool_call = MessageToolCall.from_chat_tool_call( ChatCompletionMessageToolCall( diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py index 18591537977..ddc1d4ca3f6 100644 --- a/tests/sdk/conversation/local/test_cost_budget_enforcement.py +++ b/tests/sdk/conversation/local/test_cost_budget_enforcement.py @@ -112,3 +112,39 @@ def fake_completion(**kwargs): # Lifetime cost still accumulates across runs, for reporting. cost = conv.conversation_stats.get_combined_metrics().accumulated_cost assert cost == pytest.approx(0.04) + + +def test_acp_agent_rejects_budget(): + """Pre-call reservation is impossible for out-of-process ACP prompts, so a + budget on an ACP conversation is rejected instead of silently unenforced. + """ + from openhands.sdk.agent.acp_agent import ACPAgent + from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + with pytest.raises(CostBudgetUnsupportedError, match="ACP agents"): + Conversation( + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=str(tmp), + persistence_dir=str(tmp / "persist"), + max_budget_per_run=1.0, + visualizer=None, + delete_on_close=False, + ) + + +def test_acp_agent_without_budget_is_allowed(): + from openhands.sdk.agent.acp_agent import ACPAgent + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + conv = Conversation( + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=str(tmp), + persistence_dir=str(tmp / "persist"), + visualizer=None, + delete_on_close=False, + ) + assert conv.max_budget_per_run is None + assert conv._cost_budget is None From 6e98b6a96a4d6ac78fdf295c856b845e2737486c Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 8 Oct 2026 17:37:59 +0000 Subject: [PATCH 05/24] refactor(agent-server): group start-route 422 handlers Co-authored-by: openhands --- .../openhands/agent_server/conversation_router.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py index 56569541ed7..0b641b18142 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py @@ -342,15 +342,11 @@ async def start_conversation( info, is_new = await conversation_service.start_conversation(request) except (ProfileNotFound, AgentLaunchError, LaunchStoreError) as e: raise launch_http_exception(e) from e - except ClientToolRegistrationError as e: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) - ) from e - except CostBudgetUnsupportedError as e: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) - ) from e - except InvalidParentConversation as e: + except ( + ClientToolRegistrationError, + CostBudgetUnsupportedError, + InvalidParentConversation, + ) as e: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) ) from e From 36a744fbf590b9e7367a02c1731175b95be262d0 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 8 Oct 2026 18:41:23 +0000 Subject: [PATCH 06/24] fix(sdk): reject ACP budget before state write to avoid orphaned conversation An ACP budget rejection previously ran after ConversationState.create() had already written base_state.json. A retry with the same conversation id but a regular agent then resumed the rejected ACP agent from that orphaned file instead of creating the requested conversation. Move the guard ahead of ConversationState.create() for a caller-supplied agent, and re-check after the persisted agent is loaded on resume (where no state file is created, so nothing can be orphaned). Co-authored-by: openhands --- .../conversation/impl/local_conversation.py | 43 ++++++++++++++----- .../agent_server/test_conversation_service.py | 40 +++++++++++++++++ .../local/test_cost_budget_enforcement.py | 28 ++++++++++++ 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 65b53c17983..224a424edda 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -181,6 +181,26 @@ def _copy_event_for_fork(event: Event) -> Event: return Event.model_validate_json(event.model_dump_json(exclude_none=True)) +def _reject_acp_budget( + agent: AgentBase | None, max_budget_per_run: float | None +) -> None: + """Reject a per-run budget on an ACP agent, which cannot enforce one. + + ACP prompts run in an external process that reports usage only after the + turn, so the SDK cannot reserve a call's worst-case cost before sending and + a single prompt can exceed the ceiling. Accepting the budget would silently + promise enforcement the SDK cannot deliver. + """ + if max_budget_per_run is None or not isinstance(agent, ACPAgent): + return + raise CostBudgetUnsupportedError( + "max_budget_per_run is not supported for ACP agents: their " + "prompts bypass the SDK's pre-call cost reservation, so a single " + "prompt can exceed the budget before it is recorded. Use a " + "regular (LLM) agent to enforce a per-conversation budget." + ) + + class LocalConversation(BaseConversation): agent: AgentBase workspace: LocalWorkspace @@ -399,6 +419,14 @@ def __init__( ws_path = Path(self.workspace.working_dir) if not ws_path.exists(): ws_path.mkdir(parents=True, exist_ok=True) + # Reject an ACP budget before ``ConversationState.create`` writes + # ``base_state.json``. A later rejection would leave an orphaned state + # file behind, so a retry with the same id but a different agent would + # resume the rejected ACP agent instead of creating the requested one. + # The resumed agent is not known until after ``create``, so only a + # caller-supplied agent can be checked here; the resume path re-checks + # once the persisted agent is loaded. + _reject_acp_budget(agent, max_budget_per_run) self._state = ConversationState.create( id=desired_id, agent=agent, @@ -417,6 +445,10 @@ def __init__( # ``self.agent`` and ``self._state.agent`` are the same object. if agent is None: agent = self._state.agent + # Resume path: the persisted agent is known only now. No state file + # is created here (base_state.json already exists), so re-checking + # the budget cannot orphan anything. + _reject_acp_budget(agent, max_budget_per_run) # The persisted agent carries client-tool ``Tool`` specs, but in a # fresh process the ``ClientTool`` *classes* are absent from the # global registry. Re-register them from the persisted specs so @@ -516,17 +548,6 @@ def _default_callback(e): # that inherited a parent's budget shares its ledger and must never # re-seed it mid-flight. self._owns_budget = False - if max_budget_per_run is not None and isinstance(self.agent, ACPAgent): - # ACP prompts run in an external process that reports usage only - # after the turn, so the SDK cannot reserve a worst-case cost before - # sending and a single prompt can exceed the ceiling. Reject the - # budget rather than accept an unenforceable cap. - raise CostBudgetUnsupportedError( - "max_budget_per_run is not supported for ACP agents: their " - "prompts bypass the SDK's pre-call cost reservation, so a single " - "prompt can exceed the budget before it is recorded. Use a " - "regular (LLM) agent to enforce a per-conversation budget." - ) if max_budget_per_run is not None: if inherited_budget is not None: # A subagent shares its parent's reservation pool so parent and diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py index 92dfb87b1d1..a74b44b9e17 100644 --- a/tests/agent_server/test_conversation_service.py +++ b/tests/agent_server/test_conversation_service.py @@ -211,6 +211,46 @@ async def test_start_acp_conversation_rejects_budget(tmp_path): await service.start_conversation(request) +@pytest.mark.asyncio +async def test_rejected_acp_budget_does_not_orphan_state_for_retry(tmp_path): + """A rejected ACP budget must not leave base_state.json behind. + + Otherwise a retry with the same id but a regular agent would resume the + rejected ACP agent from that orphaned file instead of creating the + requested conversation. + """ + conversations_dir = tmp_path / "conversations" + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + conversation_id = uuid4() + workspace = LocalWorkspace(working_dir=str(workspace_dir)) + rejected = StartConversationRequest( + conversation_id=conversation_id, + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=workspace, + confirmation_policy=NeverConfirm(), + max_budget_per_run=1.0, + ) + + async with ConversationService(conversations_dir=conversations_dir) as service: + with pytest.raises(CostBudgetUnsupportedError): + await service.start_conversation(rejected) + assert not ( + conversations_dir / conversation_id.hex / "base_state.json" + ).exists() + + retry = StartConversationRequest( + conversation_id=conversation_id, + agent=Agent(llm=LLM(model="gpt-4o", usage_id="test-llm"), tools=[]), + workspace=workspace, + confirmation_policy=NeverConfirm(), + ) + info, is_new = await service.start_conversation(retry) + assert is_new + assert info.id == conversation_id + assert isinstance(info.agent, Agent) + + def _create_running_terminal_action(tool_call_id: str = "call_1") -> ActionEvent: tool_call = MessageToolCall.from_chat_tool_call( ChatCompletionMessageToolCall( diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py index ddc1d4ca3f6..5615543fcae 100644 --- a/tests/sdk/conversation/local/test_cost_budget_enforcement.py +++ b/tests/sdk/conversation/local/test_cost_budget_enforcement.py @@ -148,3 +148,31 @@ def test_acp_agent_without_budget_is_allowed(): ) assert conv.max_budget_per_run is None assert conv._cost_budget is None + + +def test_acp_budget_rejection_leaves_no_orphan_state(): + """A rejected ACP budget must not write base_state.json. + + If it did, retrying the same conversation id without a budget would resume + the rejected ACP agent instead of creating the requested conversation. + """ + from uuid import uuid4 + + from openhands.sdk.agent.acp_agent import ACPAgent + from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + persist = tmp / "persist" + cid = uuid4() + with pytest.raises(CostBudgetUnsupportedError, match="ACP agents"): + Conversation( + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=str(tmp), + persistence_dir=str(persist), + conversation_id=cid, + max_budget_per_run=1.0, + visualizer=None, + delete_on_close=False, + ) + assert not (persist / cid.hex / "base_state.json").exists() From 091882a47b3b1f235cb01dfab23b8eb29ca09bdc Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 06:20:53 +0200 Subject: [PATCH 07/24] fix(budget): preserve child limits and client compatibility Report the tightest enforced ceiling in budget denials. Preserve existing positional RemoteConversation arguments and document the existing ACP budget rejection consistently. Co-authored-by: openhands --- .../src/conversation/conversation-manager.ts | 6 +++--- .../sdk/conversation/impl/local_conversation.py | 2 -- .../sdk/conversation/impl/remote_conversation.py | 2 +- openhands-sdk/openhands/sdk/llm/cost_budget.py | 6 ++---- .../remote/test_remote_conversation.py | 16 ++++++++++++++++ tests/sdk/llm/test_cost_budget.py | 7 +++++++ 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts index 7b7722a1ff5..3f3c66fc741 100644 --- a/clients/typescript/src/conversation/conversation-manager.ts +++ b/clients/typescript/src/conversation/conversation-manager.ts @@ -342,9 +342,9 @@ export class ConversationManager { workingDir?: string; userId?: string; /** - * Per-conversation cost ceiling in USD. Must be > 0 when set. Note that - * ACP prompts are enforced post-hoc, since pre-call reservation cannot - * cover the external provider path. + * Per-run cost ceiling in USD. The server rejects a budget on ACP + * conversations with HTTP 422 because their prompts bypass the SDK's + * pre-call cost enforcement. */ maxBudgetPerRun?: number; } = {} diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 8301370dffa..447231fb6f8 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -825,8 +825,6 @@ def _budget_exceeded_detail(self) -> str | None: bounds step count. Uses live cost (recorded + in-flight reservations) so a run in progress cannot slip past the ceiling between checks. - ACP prompts bypass the pre-call reservation path, so for those the - recorded-cost check below is the backstop that still stops the run. """ if self._cost_budget is None: return None diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index 6f406f8ded0..ca4d8b0fd19 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -759,7 +759,6 @@ def __init__( conversation_id: ConversationID | None = None, callbacks: list[ConversationCallbackType] | None = None, max_iteration_per_run: int = 500, - max_budget_per_run: float | None = None, stuck_detection: bool = True, stuck_detection_thresholds: ( StuckDetectionThresholds | Mapping[str, int] | None @@ -778,6 +777,7 @@ def __init__( observability_span_name: str = "conversation", observability_parent_span_context: str | None = None, token_callbacks: list[ConversationTokenCallbackType] | None = None, + max_budget_per_run: float | None = None, **_: object, ) -> None: """Remote conversation proxy that talks to an agent server. diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py index d162c1779ac..82295963df7 100644 --- a/openhands-sdk/openhands/sdk/llm/cost_budget.py +++ b/openhands-sdk/openhands/sdk/llm/cost_budget.py @@ -20,9 +20,7 @@ reservation (``LLM.completion``/``acompletion``/...). Agents that delegate to an external process -- notably :class:`~openhands.sdk.agent.acp_agent.ACPAgent`, whose prompts never pass through those wrappers -- do not reserve, so a budget on -such a conversation is a *post-hoc* ceiling: the run loop checks recorded cost -after each step, which still stops the run rather than admitting an unbounded -call. +such a conversation is rejected rather than accepted without pre-call enforcement. """ from __future__ import annotations @@ -85,7 +83,7 @@ def child(self, limit: float) -> CostBudget: @property def limit(self) -> float: """Tightest ceiling this view enforces, including ancestor limits.""" - return min(self._ancestor_limits) if self._ancestor_limits else self._limit + return min((self._limit, *self._ancestor_limits)) def _breaches(self, total: float) -> bool: """True if ``total`` would exceed this view's ceiling or an ancestor's.""" diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py index 86914e25e1e..5b48fe9b747 100644 --- a/tests/sdk/conversation/remote/test_remote_conversation.py +++ b/tests/sdk/conversation/remote/test_remote_conversation.py @@ -414,6 +414,22 @@ def test_failed_explicit_operation_does_not_try_the_other_operation( expected_method ] + @patch( + "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient" + ) + def test_constructor_preserves_positional_stuck_detection(self, mock_ws_client): + client = self.setup_mock_client() + + RemoteConversation(self.agent, self.workspace, None, None, None, 100, False) + + create_call = next( + call + for call in client.request.call_args_list + if call.args == ("POST", "/api/conversations") + ) + assert create_call.kwargs["json"]["stuck_detection"] is False + assert create_call.kwargs["json"]["max_budget_per_run"] is None + @patch( "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient" ) diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index f717063e21b..07d6993020f 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -90,6 +90,13 @@ def test_child_cannot_exceed_a_tighter_ancestor_ceiling(): assert child.try_reserve(0.1) is not None +def test_child_denial_reports_its_own_tighter_ceiling(): + child = CostBudget(10.0).child(1.0) + + assert child.try_reserve(2.0) is None + assert "maximum budget limit ($1.0000)" in child.denial_detail(2.0) + + def test_concurrent_reservations_cannot_overshoot(): """A shared budget admits at most the calls that fit, even in parallel.""" budget = CostBudget(1.0) From 94039bc709d2b54e6c622d1444e7f2d0722348d5 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 06:51:21 +0200 Subject: [PATCH 08/24] fix(budget): refuse unbounded calls and retain uncertain work Reserve against published provider input ceilings and the actual outgoing output cap using finite directional prices, including context and cache tiers. Reject unknown bounds and request modes whose cost or route is not bounded. Keep SDK retries inside admission and preserve failed or unknown-cost work in the budget ledger without treating that usage as provider billing. Co-authored-by: openhands --- .../openhands/sdk/llm/cost_budget.py | 15 +- openhands-sdk/openhands/sdk/llm/llm.py | 246 +++++++++--- .../local/test_cost_budget_enforcement.py | 13 +- tests/sdk/llm/test_budget_denial_http.py | 118 +++++- tests/sdk/llm/test_cost_budget.py | 374 ++++++++++++++++++ 5 files changed, 689 insertions(+), 77 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py index 82295963df7..da9142417b6 100644 --- a/openhands-sdk/openhands/sdk/llm/cost_budget.py +++ b/openhands-sdk/openhands/sdk/llm/cost_budget.py @@ -5,8 +5,9 @@ the same total and pass, and a single call can still overshoot by its own worst case. ``CostBudget`` therefore *reserves* each call's worst-case cost before it is sent and settles it once the call completes, releasing the -remainder. Settled + reserved is a live upper bound on what the current run can -owe. +remainder when actual cost is known. Failed or unknown-cost attempts retain +their bound; these conservative charges are budget usage, not billed cost. +Settled + reserved is a live upper bound on what the current run can owe. Settled cost is carried on a ledger shared by a parent and its subagents, so the whole tree contends on one running total even though a subagent's metrics join @@ -99,7 +100,7 @@ def _reached(self, total: float) -> bool: @property def spent(self) -> float: - """Cost settled so far (excludes in-flight work), floored at 0.""" + """Settled budget usage, including bounds retained for unknown work.""" with self._ledger.lock: return max(0.0, self._ledger.settled) @@ -144,7 +145,11 @@ def try_reserve(self, amount: float) -> BudgetReservation | None: return BudgetReservation(self, amount) def settle(self, reservation: BudgetReservation, actual: float) -> None: - """Release a reservation and add the call's actual recorded cost.""" + """Release a reservation and add its settled budget usage. + + The caller supplies recorded cost or retains a conservative bound for + failed/unknown work. This ledger does not replace billing metrics. + """ with self._ledger.lock: self._ledger.reserved = max(0.0, self._ledger.reserved - reservation.amount) self._ledger.settled += max(0.0, actual) @@ -171,7 +176,7 @@ def _limit_detail( ) -> str: detail = ( f"Agent reached maximum budget limit (${self.limit:.4f}); " - f"accumulated cost ${accumulated:.4f}." + f"budget usage ${accumulated:.4f} (settled and reserved)." ) if attempted is not None: detail += f" Next call would need ${attempted:.4f}." diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index fffe66d968d..cb3ff82b3f6 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -4,6 +4,7 @@ import copy import inspect import json +import math import os import threading import warnings @@ -173,14 +174,6 @@ litellm.modify_params = True -# Budget reservations price ``input + output`` at these conservative fallbacks -# when a route publishes no per-token rate or no token cap, so an unpriced or -# uncapped model cannot silently spend past a hard ceiling. They only matter -# when the model metadata is silent; known models use their real rates/caps. -_DEFAULT_INPUT_COST_PER_TOKEN: Final[float] = 1e-5 # $10 / 1M tokens -_DEFAULT_OUTPUT_COST_PER_TOKEN: Final[float] = 3e-5 # $30 / 1M tokens -_DEFAULT_BUDGET_TOKEN_PROJECTION: Final[int] = 32768 - _serialized_is_subscription = ContextVar( "serialized_is_subscription", default=False, @@ -1762,10 +1755,23 @@ async def agenerate( # Chat Completion API # ========================================================================= - def _cap_projection(self, tokens: int | None) -> int: - if tokens is None or tokens <= 0: - return _DEFAULT_BUDGET_TOKEN_PROJECTION - return tokens + def _budget_bound_error(self, detail: str) -> LLMBudgetExceededError: + return LLMBudgetExceededError( + f"Cannot enforce cost budget for {self.model}: {detail}." + ) + + def _budget_token_bound(self, value: object, direction: str) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int | float) + or not math.isfinite(value) + or value <= 0 + or int(value) != value + ): + raise self._budget_bound_error( + f"unknown or invalid {direction} token bound" + ) + return int(value) def worst_case_cost( self, @@ -1773,57 +1779,142 @@ def worst_case_cost( input_tokens: int | None = None, max_output_tokens: int | None = None, ) -> float: - """Upper-bound USD for one call, used to reserve against a budget. + """Upper-bound USD using finite token bounds and published price tiers. - Prices ``input_tokens + output_upper`` at the highest per-token rate - the model exposes, so a reservation never under-counts a - context-tiered or cache-priced call. Unknown pricing falls back to a - conservative rate so an unpriced model cannot silently spend past a - hard ceiling. + Unknown pricing or token limits cannot establish a hard ceiling and are + refused. Each direction uses its highest known context/cache tier, or + the paired custom prices also used by telemetry. """ - input_upper = self._cap_projection(input_tokens) - output_upper = self._cap_projection(max_output_tokens) + input_upper = self._budget_token_bound(input_tokens, "input") + output_upper = self._budget_token_bound(max_output_tokens, "output") in_cost, out_cost = self._cost_per_token_bounds() - return input_upper * in_cost + output_upper * out_cost + total = input_upper * in_cost + output_upper * out_cost + if not math.isfinite(total): + raise self._budget_bound_error("nonfinite price projection") + return total + + def _budget_price(self, value: object) -> float: + if ( + isinstance(value, bool) + or not isinstance(value, int | float) + or not math.isfinite(value) + or value < 0 + ): + raise self._budget_bound_error("unknown or invalid token price") + return float(value) def _cost_per_token_bounds(self) -> tuple[float, float]: - """Highest input/output per-token rates known for this route.""" - input_costs = [c for c in (self.input_cost_per_token,) if c is not None] - output_costs = [c for c in (self.output_cost_per_token,) if c is not None] + """Conservative directional rates, including context/cache tiers. + + A single custom rate is not a complete override: telemetry uses custom + pricing only when both input and output prices are supplied. Explicit + zero is a valid known price; missing/nonfinite prices are not. + """ + if ( + self.input_cost_per_token is not None + and self.output_cost_per_token is not None + ): + return ( + self._budget_price(self.input_cost_per_token), + self._budget_price(self.output_cost_per_token), + ) info = self._model_info or {} - extra = ( - info.get("input_cost_per_token"), - info.get("output_cost_per_token"), - info.get("cache_read_input_token_cost"), - info.get("cache_creation_input_token_cost"), - ) - for value in extra: - if isinstance(value, int | float) and value > 0: - input_costs.append(float(value)) - output_costs.append(float(value)) - in_cost = max(input_costs) if input_costs else _DEFAULT_INPUT_COST_PER_TOKEN - out_cost = max(output_costs) if output_costs else _DEFAULT_OUTPUT_COST_PER_TOKEN - return in_cost, out_cost + input_costs = [self._budget_price(info.get("input_cost_per_token"))] + output_costs = [self._budget_price(info.get("output_cost_per_token"))] + uplifts = [1.0] + for key, value in info.items(): + if value is None: + continue + if key.startswith( + ( + "input_cost_per_token", + "cache_read_input_token_cost", + "cache_creation_input_token_cost", + ) + ): + input_costs.append(self._budget_price(value)) + elif key.startswith( + ("output_cost_per_token", "output_cost_per_reasoning_token") + ): + output_costs.append(self._budget_price(value)) + elif key.startswith("regional_processing_uplift_multiplier"): + uplifts.append(self._budget_price(value)) + uplift = max(uplifts) + return max(input_costs) * uplift, max(output_costs) * uplift def _reserve_budget( self, call_context: LLMCallContext | None, - messages: list[Message], - tools: Sequence[ToolDefinition] | None, + request_kwargs: dict[str, Any], ) -> BudgetReservation | None: - """Reserve this call's worst-case cost, or raise if it cannot be met. + """Atomically reserve the bound for this exact prepared attempt. - The reservation is taken atomically per conversation, so concurrent - callers sharing one budget cannot all pass the same check. Returns - ``None`` when no budget is bound to the call. + Unsupported request overrides are refused only for budgeted calls: + opaque provider bodies, multiple completions and route changes can + invalidate the configured model's prices or the outgoing token cap. """ budget = call_context.cost_budget if call_context else None if budget is None: return None - estimated_input = self.get_token_count(messages, tools) or None + if ( + request_kwargs.get("audio") is not None + or request_kwargs.get("modalities") not in (None, ["text"]) + or "web_search_options" in request_kwargs + or (self._model_info or {}).get("search_context_cost_per_query") is not None + ): + raise self._budget_bound_error( + "non-token audio/search request costs are unsupported" + ) + if litellm.num_retries or litellm.model_fallbacks or litellm.drop_params: + raise self._budget_bound_error( + "global LiteLLM retries/fallbacks/parameter dropping are unsupported" + ) + if any( + request_kwargs.get(key) + for key in ( + "retry_policy", + "additional_drop_params", + "context_window_fallback_dict", + ) + ): + raise self._budget_bound_error("unsupported transport retry/drop override") + if request_kwargs.get("extra_body") or any( + key in request_kwargs + for key in ( + "model", + "api_base", + "base_url", + "custom_llm_provider", + "client", + "fallbacks", + "model_list", + ) + ): + raise self._budget_bound_error("unsupported request route/body override") + for key in ("n", "best_of"): + value = request_kwargs.get(key) + if value is not None and self._budget_token_bound(value, "output") != 1: + raise self._budget_bound_error("multiple completions are unsupported") + output_bounds = [ + self._budget_token_bound(request_kwargs[key], "output") + for key in ("max_tokens", "max_completion_tokens", "max_output_tokens") + if key in request_kwargs + ] + if not output_bounds: + raise self._budget_bound_error("unknown output token bound") + # Token counters estimate formatted prompts. A hard pre-call bound + # instead uses the published provider input ceiling for every API. + # max_input_tokens is only a context-management hint and cannot lower + # this reservation because it does not constrain the outgoing request. + provider_cap = (self._model_info or {}).get("max_input_tokens") + metadata = cached_metadata( + self._runtime_metadata, self._runtime_metadata_fetched_at + ) + if metadata is not None and metadata.confidence == "exact": + provider_cap = metadata.max_input_tokens + input_bound = self._budget_token_bound(provider_cap, "input") worst_case = self.worst_case_cost( - input_tokens=estimated_input, - max_output_tokens=self.effective_max_output_tokens, + input_tokens=input_bound, max_output_tokens=max(output_bounds) ) reservation = budget.try_reserve(worst_case) if reservation is None: @@ -1833,16 +1924,15 @@ def _reserve_budget( def _budget_guarded_attempt( self, call_context: LLMCallContext | None, - messages: list[Message], - tools: Sequence[ToolDefinition] | None, + request_kwargs: dict[str, Any], ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """Decorator reserving this attempt's worst-case cost before sending. + """Reserve inside SDK retry, before each bounded transport attempt. - Stacked *inside* the retry decorator (closest to the transport call) so - each attempt -- including retries -- is admitted and settled on its own - and a retry loop cannot spend past the budget invisibly. On success the - reservation settles to the cost this LLM actually recorded; on failure - it is released. A no-op when no budget is bound. + Failed/cancelled attempts retain their bound because the provider may + have processed work without returning complete usage. Successful calls + settle to recorded positive cost; absent cost telemetry retains the + bound too. This ledger is budget usage, not a provider billing record. + Unbudgeted calls keep their existing transport options and behavior. """ def decorate(attempt: Callable[..., Any]) -> Callable[..., Any]: @@ -1850,17 +1940,31 @@ def decorate(attempt: Callable[..., Any]) -> Callable[..., Any]: @wraps(attempt) async def _reserved_async(*args: Any, **kwargs: Any) -> Any: - reservation = self._reserve_budget(call_context, messages, tools) + reservation = self._reserve_budget( + call_context, {**request_kwargs, **kwargs} + ) if reservation is None: return await attempt(*args, **kwargs) cost_before = self.metrics.accumulated_cost + # Keep retries in the SDK guard and never drop the cap as + # an unsupported parameter inside LiteLLM. + kwargs = { + **kwargs, + "drop_params": False, + "max_retries": 0, + "num_retries": 0, + } try: result = await attempt(*args, **kwargs) except BaseException: - reservation.budget.settle(reservation, 0.0) + reservation.budget.settle( + reservation, + max(reservation.amount, self._cost_delta(cost_before)), + ) raise + cost = self._cost_delta(cost_before) reservation.budget.settle( - reservation, self._cost_delta(cost_before) + reservation, cost if cost > 0 else reservation.amount ) return result @@ -1868,16 +1972,30 @@ async def _reserved_async(*args: Any, **kwargs: Any) -> Any: @wraps(attempt) def _reserved_sync(*args: Any, **kwargs: Any) -> Any: - reservation = self._reserve_budget(call_context, messages, tools) + reservation = self._reserve_budget( + call_context, {**request_kwargs, **kwargs} + ) if reservation is None: return attempt(*args, **kwargs) cost_before = self.metrics.accumulated_cost + kwargs = { + **kwargs, + "drop_params": False, + "max_retries": 0, + "num_retries": 0, + } try: result = attempt(*args, **kwargs) except BaseException: - reservation.budget.settle(reservation, 0.0) + reservation.budget.settle( + reservation, + max(reservation.amount, self._cost_delta(cost_before)), + ) raise - reservation.budget.settle(reservation, self._cost_delta(cost_before)) + cost = self._cost_delta(cost_before) + reservation.budget.settle( + reservation, cost if cost > 0 else reservation.amount + ) return result return _reserved_sync @@ -1954,7 +2072,7 @@ def completion( ) @self._make_retry_decorator() - @self._budget_guarded_attempt(call_context, messages, tools) + @self._budget_guarded_attempt(call_context, call_kwargs) def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) @@ -2077,7 +2195,7 @@ async def acompletion( @self._make_retry_decorator() @self._async_hard_timeout_decorator() - @self._budget_guarded_attempt(call_context, messages, tools) + @self._budget_guarded_attempt(call_context, call_kwargs) async def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) @@ -2222,7 +2340,7 @@ def responses( ) @self._make_retry_decorator() - @self._budget_guarded_attempt(call_context, messages, tools) + @self._budget_guarded_attempt(call_context, call_kwargs) def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) @@ -2391,7 +2509,7 @@ async def aresponses( @self._make_retry_decorator() @self._async_hard_timeout_decorator() - @self._budget_guarded_attempt(call_context, messages, tools) + @self._budget_guarded_attempt(call_context, call_kwargs) async def _one_attempt( **retry_kwargs: Any, ) -> ResponsesAPIResponse: diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py index 5615543fcae..eed344f1dfa 100644 --- a/tests/sdk/conversation/local/test_cost_budget_enforcement.py +++ b/tests/sdk/conversation/local/test_cost_budget_enforcement.py @@ -62,14 +62,14 @@ def fake_completion(**kwargs): e.code for e in conv.state.events if isinstance(e, ConversationErrorEvent) ] assert "MaxBudgetReached" in codes - # The cheapest call (0.002 USD) never fits a 0.001 ceiling. + # The provider input/output upper bound never fits a 0.001 ceiling. assert conv.conversation_stats.get_combined_metrics().accumulated_cost == 0.0 def test_run_completes_and_exposes_accumulated_cost_within_budget(): with tempfile.TemporaryDirectory() as d: tmp = Path(d) - conv = _conversation(tmp, max_budget_per_run=1.0) + conv = _conversation(tmp, max_budget_per_run=2.0) conv.send_message(Message(role="user", content=[TextContent(text="go")])) def fake_completion(**kwargs): @@ -89,11 +89,10 @@ def test_budget_is_per_run_not_lifetime(): """A second run gets a fresh allowance; earlier spend does not drain it.""" with tempfile.TemporaryDirectory() as d: tmp = Path(d) - # Each run records 0.02; one call reserves ~0.06 worst case (agent system - # prompt + 2000 output tokens). 0.07 admits a run only if the prior - # run's 0.02 does not count against it: a lifetime budget would see 0.02 - # already spent plus the next ~0.06 reservation and refuse. - conv = _conversation(tmp, max_budget_per_run=0.07) + # Each run records 0.02; the provider's 128k input ceiling plus 2000 + # output tokens reserves 1.30. A 1.31 allowance admits the later run + # only when its prior 0.02 does not count against the new reservation. + conv = _conversation(tmp, max_budget_per_run=1.31) def fake_completion(**kwargs): return _response_with_cost(kwargs.get("model", "gpt-4o-mini")) diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py index 5f626dded37..0d1ebce911d 100644 --- a/tests/sdk/llm/test_budget_denial_http.py +++ b/tests/sdk/llm/test_budget_denial_http.py @@ -7,7 +7,10 @@ from pydantic import SecretStr from openhands.sdk.llm import LLM, Message, TextContent -from openhands.sdk.llm.exceptions import LLMRateLimitError +from openhands.sdk.llm.call_context import LLMCallContext +from openhands.sdk.llm.cost_budget import CostBudget +from openhands.sdk.llm.exceptions import LLMBudgetExceededError, LLMRateLimitError +from tests.conftest import create_mock_litellm_response @pytest.fixture @@ -145,3 +148,116 @@ async def call(): response = await call() assert response.message.content == [TextContent(text="resumed")] assert len(requests) == 2 + + +@pytest.mark.parametrize("api_mode", ["chat", "responses"]) +@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) +async def test_budgeted_request_keeps_output_cap_at_http_boundary( + provider, api_mode, use_async +): + base_url, replies, requests = provider + response = { + "id": "bounded-response", + "model": "gpt-4o", + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + if api_mode == "chat": + response.update( + object="chat.completion", + created=1, + choices=[ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "ok"}, + } + ], + ) + else: + response.update( + object="response", + created_at=1, + status="completed", + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + output=[ + { + "type": "message", + "id": "bounded-message", + "role": "assistant", + "status": "completed", + "content": [ + {"type": "output_text", "text": "ok", "annotations": []} + ], + } + ], + usage={"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + replies.append((200, response)) + llm = LLM( + model="openai/gpt-4o", + base_url=base_url, + api_key=SecretStr("fixture-key"), + api_mode=api_mode, + max_output_tokens=100, + input_cost_per_token=0.000001, + output_cost_per_token=0.000001, + num_retries=0, + ) + budget = CostBudget(1) + context = LLMCallContext(cost_budget=budget) + messages = [ + Message(role="system", content=[TextContent(text="Reply briefly.")]), + Message(role="user", content=[TextContent(text="hi")]), + ] + + result = ( + await llm.agenerate(messages, call_context=context, num_retries=9) + if use_async + else llm.generate(messages, call_context=context, num_retries=9) + ) + + assert result.message.content == [TextContent(text="ok")] + assert len(requests) == 1 + cap = "max_completion_tokens" if api_mode == "chat" else "max_output_tokens" + assert requests[0][cap] == 100 + assert budget.spent == pytest.approx(0.000002) + + +@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) +async def test_budget_owns_retries_at_http_boundary(provider, use_async): + base_url, replies, requests = provider + replies.extend( + [ + (503, {"error": {"type": "server_error", "message": "Fixture failure"}}), + (200, create_mock_litellm_response("unguarded retry").model_dump()), + ] + ) + llm = LLM( + model="openai/gpt-4o", + base_url=base_url, + api_key=SecretStr("fixture-key"), + max_output_tokens=100, + input_cost_per_token=0.0000005, + output_cost_per_token=0.001, + num_retries=2, + retry_min_wait=0, + retry_max_wait=0, + ) + budget = CostBudget(0.2) + context = LLMCallContext(cost_budget=budget) + messages = [ + Message(role="system", content=[TextContent(text="Reply briefly.")]), + Message(role="user", content=[TextContent(text="hi")]), + ] + + with pytest.raises(LLMBudgetExceededError): + if use_async: + await llm.acompletion(messages, call_context=context, num_retries=2) + else: + llm.completion(messages, call_context=context, num_retries=2) + + assert len(requests) == 1 + assert budget.reserved == 0 + assert 0 < budget.spent <= budget.limit diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index 07d6993020f..f147ae122d4 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -1,8 +1,21 @@ +import asyncio +import inspect +import json import threading +from importlib.resources import files +from unittest.mock import AsyncMock, Mock import pytest +from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse +from openai.types.responses import ResponseOutputMessage, ResponseOutputText +import openhands.sdk.llm.llm as llm_module +import openhands.sdk.llm.utils.telemetry as telemetry_module +from openhands.sdk import LLM, Message, TextContent +from openhands.sdk.llm.call_context import LLMCallContext from openhands.sdk.llm.cost_budget import BudgetReservation, CostBudget +from openhands.sdk.llm.exceptions import LLMBudgetExceededError +from tests.conftest import create_mock_litellm_response def _settle(budget: CostBudget, amount: float) -> None: @@ -137,3 +150,364 @@ def test_non_positive_limit_is_rejected(): CostBudget(0.0) with pytest.raises(ValueError): CostBudget(1.0).child(0.0) + + +@pytest.fixture +def budget_call(monkeypatch): + info = { + "max_input_tokens": 20000, + "max_output_tokens": 100, + "input_cost_per_token": 0.0000005, + "output_cost_per_token": 0.001, + } + monkeypatch.setattr(llm_module, "get_litellm_model_info", lambda **_: info) + monkeypatch.setattr(llm_module, "token_counter", lambda **_: 10) + chat_response = create_mock_litellm_response("ok") + response = ResponsesAPIResponse( + id="budget-response", + created_at=0, + output=[ + ResponseOutputMessage( + id="budget-message", + type="message", + role="assistant", + status="completed", + content=[ + ResponseOutputText(type="output_text", text="ok", annotations=[]) + ], + ) + ], + parallel_tool_calls=False, + tool_choice="auto", + top_p=None, + tools=[], + usage=ResponseAPIUsage(input_tokens=10, output_tokens=5, total_tokens=15), + status="completed", + ) + providers = ( + Mock(return_value=chat_response), + AsyncMock(return_value=chat_response), + Mock(return_value=response), + AsyncMock(return_value=response), + ) + for name, provider in zip( + ( + "litellm_completion", + "litellm_acompletion", + "litellm_responses", + "litellm_aresponses", + ), + providers, + strict=True, + ): + monkeypatch.setattr(llm_module, name, provider) + + def make_llm(**kwargs): + options = { + "model": "openai/budget-fixture", + "api_key": "test", + "max_input_tokens": 20000, + "max_output_tokens": 100, + "num_retries": 0, + } + return LLM(**(options | kwargs)) + + messages = [ + Message(role="system", content=[TextContent(text="Reply briefly.")]), + Message(role="user", content=[TextContent(text="hi")]), + ] + return info, make_llm, messages, providers + + +@pytest.mark.parametrize("custom_prices", [{}, {"input_cost_per_token": 0.0}]) +def test_budget_refuses_unknown_directional_prices(budget_call, custom_prices): + info, make_llm, messages, providers = budget_call + info.pop("input_cost_per_token") + info.pop("output_cost_per_token") + llm = make_llm(**custom_prices) + + with pytest.raises(LLMBudgetExceededError, match="price"): + llm.completion( + messages, call_context=LLMCallContext(cost_budget=CostBudget(100)) + ) + + providers[0].assert_not_called() + + +@pytest.mark.parametrize( + "field,value", + [("input_cost_per_token", float("inf")), ("output_cost_per_token", float("nan"))], +) +def test_budget_refuses_nonfinite_prices(budget_call, field, value): + info, make_llm, messages, providers = budget_call + info[field] = value + llm = make_llm() + + with pytest.raises(LLMBudgetExceededError, match="price"): + llm.completion( + messages, call_context=LLMCallContext(cost_budget=CostBudget(100)) + ) + + providers[0].assert_not_called() + + +@pytest.mark.parametrize("axis", ["input", "output"]) +def test_budget_refuses_unknown_token_bound(budget_call, axis): + info, make_llm, messages, providers = budget_call + info.pop(f"max_{axis}_tokens") + llm = make_llm(**{f"max_{axis}_tokens": None}) + + with pytest.raises(LLMBudgetExceededError, match=axis): + llm.completion( + messages, call_context=LLMCallContext(cost_budget=CostBudget(100)) + ) + + providers[0].assert_not_called() + + +@pytest.mark.parametrize( + "invoke,cap", + [ + (LLM.completion, "max_completion_tokens"), + (LLM.acompletion, "max_completion_tokens"), + (LLM.responses, "max_output_tokens"), + (LLM.aresponses, "max_output_tokens"), + ], +) +async def test_budget_prices_actual_request_output_cap(budget_call, invoke, cap): + _, make_llm, messages, providers = budget_call + llm = make_llm() + + with pytest.raises(LLMBudgetExceededError): + result = invoke( + llm, + messages, + call_context=LLMCallContext(cost_budget=CostBudget(0.2)), + **{cap: 1000}, + ) + if inspect.isawaitable(result): + await result + + for provider in providers: + provider.assert_not_called() + + +@pytest.mark.parametrize("limit,admitted", [(0.2, False), (1.1, True)]) +def test_budget_accounts_for_published_context_tiers( + budget_call, monkeypatch, limit, admitted +): + info, make_llm, messages, providers = budget_call + packaged = files("litellm").joinpath("model_prices_and_context_window_backup.json") + info.update(json.loads(packaged.read_text())["xai/grok-4-fast-reasoning"]) + monkeypatch.setattr(llm_module, "token_counter", lambda **_: 128001) + llm = make_llm( + model="xai/grok-4-fast-reasoning", + max_input_tokens=2000000, + max_output_tokens=200000, + ) + providers[0].return_value = create_mock_litellm_response( + "ok", + model="xai/grok-4-fast-reasoning", + prompt_tokens=128001, + completion_tokens=200000, + ) + budget = CostBudget(limit) + + if admitted: + result = llm.completion( + messages, call_context=LLMCallContext(cost_budget=budget) + ) + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() + else: + with pytest.raises(LLMBudgetExceededError): + llm.completion(messages, call_context=LLMCallContext(cost_budget=budget)) + providers[0].assert_not_called() + + +def test_budget_accounts_for_published_cache_write_tiers(budget_call): + info, make_llm, messages, providers = budget_call + info["cache_creation_input_token_cost_above_1hr"] = 0.05 + llm = make_llm() + + with pytest.raises(LLMBudgetExceededError): + llm.completion( + messages, call_context=LLMCallContext(cost_budget=CostBudget(0.2)) + ) + + providers[0].assert_not_called() + + +@pytest.mark.parametrize("invoke", [LLM.completion, LLM.responses]) +def test_budget_does_not_treat_input_hint_as_provider_limit(budget_call, invoke): + _, make_llm, messages, providers = budget_call + llm = make_llm(max_input_tokens=16384) + + with pytest.raises(LLMBudgetExceededError): + invoke( + llm, messages, call_context=LLMCallContext(cost_budget=CostBudget(0.109)) + ) + + for provider in providers: + provider.assert_not_called() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"n": 2}, + {"extra_body": {"max_tokens": 1000}}, + {"model": "another-model"}, + {"client": Mock()}, + {"web_search_options": {}}, + {"retry_policy": {"TimeoutErrorRetries": 2}}, + {"additional_drop_params": ["max_completion_tokens"]}, + ], +) +def test_budget_refuses_unbounded_request_overrides(budget_call, kwargs): + _, make_llm, messages, providers = budget_call + llm = make_llm() + + with pytest.raises(LLMBudgetExceededError): + llm.completion( + messages, + call_context=LLMCallContext(cost_budget=CostBudget(0.2)), + **kwargs, + ) + + providers[0].assert_not_called() + + +@pytest.mark.parametrize( + "model,kwargs,output_cap,limit", + [ + ( + "gpt-4o-mini-audio-preview", + { + "modalities": ["text", "audio"], + "audio": {"voice": "alloy", "format": "wav"}, + }, + 16384, + 0.05, + ), + ("gpt-4o-search-preview", {}, 100, 1), + ], +) +def test_budget_refuses_non_token_request_costs( + budget_call, model, kwargs, output_cap, limit +): + info, make_llm, messages, providers = budget_call + packaged = files("litellm").joinpath("model_prices_and_context_window_backup.json") + info.update(json.loads(packaged.read_text())[model]) + llm = make_llm(model=f"openai/{model}", max_output_tokens=output_cap) + + with pytest.raises(LLMBudgetExceededError): + llm.completion( + messages, + call_context=LLMCallContext(cost_budget=CostBudget(limit)), + **kwargs, + ) + + providers[0].assert_not_called() + + +@pytest.mark.parametrize( + "name,value", + [("num_retries", 2), ("model_fallbacks", ["another-model"]), ("drop_params", True)], +) +def test_budget_refuses_unsafe_global_transport_options( + budget_call, monkeypatch, name, value +): + _, make_llm, messages, providers = budget_call + monkeypatch.setattr(llm_module.litellm, name, value) + llm = make_llm() + + with pytest.raises(LLMBudgetExceededError, match="global LiteLLM"): + llm.completion( + messages, call_context=LLMCallContext(cost_budget=CostBudget(0.2)) + ) + + providers[0].assert_not_called() + + +def test_budget_allows_known_free_custom_prices(budget_call): + info, make_llm, messages, providers = budget_call + info.pop("input_cost_per_token") + info.pop("output_cost_per_token") + llm = make_llm(input_cost_per_token=0.0, output_cost_per_token=0.0) + + result = llm.completion( + messages, call_context=LLMCallContext(cost_budget=CostBudget(0.01)) + ) + + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() + + +def test_unbudgeted_call_does_not_require_price_or_caps(budget_call): + info, make_llm, messages, providers = budget_call + info.clear() + llm = make_llm(max_input_tokens=None, max_output_tokens=None) + + result = llm.completion(messages) + + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() + + +def test_budget_retains_recorded_cost_before_validation_retry(budget_call): + _, make_llm, messages, providers = budget_call + response = create_mock_litellm_response("", prompt_tokens=10, completion_tokens=100) + response.choices = [] + providers[0].return_value = response + llm = make_llm( + input_cost_per_token=0.0000005, + output_cost_per_token=0.001, + num_retries=2, + retry_min_wait=0, + retry_max_wait=0, + ) + budget = CostBudget(0.15) + + with pytest.raises(LLMBudgetExceededError): + llm.completion(messages, call_context=LLMCallContext(cost_budget=budget)) + + providers[0].assert_called_once() + assert llm.metrics.accumulated_cost == pytest.approx(0.100005) + assert budget.spent == pytest.approx(0.11) + assert budget.reserved == 0 + + +async def test_budget_retains_unknown_work_on_cancellation(budget_call): + _, make_llm, messages, providers = budget_call + providers[1].side_effect = asyncio.CancelledError() + llm = make_llm() + budget = CostBudget(0.15) + + with pytest.raises(asyncio.CancelledError): + await llm.acompletion(messages, call_context=LLMCallContext(cost_budget=budget)) + + providers[1].assert_called_once() + assert budget.spent == pytest.approx(0.11) + assert budget.reserved == 0 + + +def test_budget_retains_successful_work_when_cost_is_unknown(budget_call, monkeypatch): + _, make_llm, messages, providers = budget_call + monkeypatch.setattr( + telemetry_module, + "litellm_completion_cost", + Mock(side_effect=ValueError("Fixture: response price unavailable")), + ) + llm = make_llm() + budget = CostBudget(0.15) + context = LLMCallContext(cost_budget=budget) + + result = llm.completion(messages, call_context=context) + with pytest.raises(LLMBudgetExceededError): + llm.completion(messages, call_context=context) + + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() + assert llm.metrics.accumulated_cost == 0 + assert budget.spent == pytest.approx(0.11) From 0e5c7a6c23ff181af2f5126f930da07d61ed2640 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 06:56:33 +0200 Subject: [PATCH 09/24] docs(pr): explain per-run budget admission and evidence Co-authored-by: openhands --- .pr/budget-design.html | 2690 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2690 insertions(+) create mode 100644 .pr/budget-design.html diff --git a/.pr/budget-design.html b/.pr/budget-design.html new file mode 100644 index 00000000000..71b8f21490e --- /dev/null +++ b/.pr/budget-design.html @@ -0,0 +1,2690 @@ + +A budget belongs to a run · PR #5603 +
+
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ 94039bc709d2b54e6c622d1444e7f2d0722348d5
30 changed files · +1826 / −20 against merge base · Admission repair included
+ +

01The option now crosses the public API

Source manifest
+

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

+
Before · merge base
# Local implementation only
+LocalConversation(..., max_budget_per_run=None)
+
+# REST create/info and typed clients:
+# no conversation budget field
After · additive contract
Conversation(..., max_budget_per_run=2.5)
+
+POST /api/conversations
+{ ... , "max_budget_per_run": 2.5 }
+
+// TypeScript remote constructor or start option
+{ maxBudgetPerRun: 2.5 }
+

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

+
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
+
+ +

02Reserve before each SDK LLM attempt

Source manifest
+
The admission check moves before each SDK LLM attemptBefore: a local run checks accumulated lifetime metrics after a step. After: SDK LLM attempts reserve against one shared ledger before transport, then settle each attempt. Authoritative bounds, transport controls, and conservative settlement are the focused repair described in section 3.LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1924CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2080new gateCostBudget.settle()positive success: cost; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
+

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

+

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

+

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost. After an attempt starts, failed, cancelled, or unknown-cost successful work retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1158 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

+
+ +

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

+
⚠ The original PR's admission projection at 091882a invented rates for missing price data and a 32,768-token cap for missing limits. A finite fallback is an estimate, not an upper bound; published context tiers can also exceed the selected base rates.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:180 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1765 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1789 @ 091882a

+

A deterministic probe at 091882a, using packaged LiteLLM metadata for xai/grok-4-fast-reasoning, admitted a $0.1640005 reservation under a $0.20 cap while the published tier arithmetic was $0.2512004. This was metadata arithmetic with no provider request or billed usage.

+
Reproduction inputs and arithmetic · original PR at 091882a
input tokens:          128,001
+maximum output tokens: 200,000
+selected bound:        (128001 + 200000) × 0.0000005 = $0.1640005
+published tier cost:   128001 × 0.0000004 + 200000 × 0.000001
+                     = $0.2512004
+cap:                   $0.20
+
+No credentials, network, or model completion used.
+Metadata snapshot SHA-256:
+cc1955f7f1cdc2cdd9ebf216e7eebbc90bbb3528b62439327f7e8b893eda7c12

Observed against the pre-repair admission helper; the focused regression tests belong with the repair.

+

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1776 · openhands-sdk/openhands/sdk/llm/llm.py:1806 · openhands-sdk/openhands/sdk/llm/llm.py:1845

+

Failed attempts cannot receive a blind refund

A separate deterministic fixture at 091882a followed the real SDK completion, telemetry, validation, and retry path. Two invalid-response attempts each reserved $0.11; under a $0.15 cap, SDK telemetry recorded $0.22 while the budget ledger finished at zero. A timeout fixture separately reproduced reuse of allowance for uncertain attempted work. These probes establish SDK accounting behavior, without a provider request or billing claim.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:1833 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1887 @ 091882a

+
  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Limit the bound to standard token-priced text requests. Refuse non-text output modalities, audio options, web_search_options even when empty, and intrinsic search_context_cost_per_query fee metadata. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost. Failed, cancelled, or unknown-cost successful attempts retain at least the reserved bound; a larger recorded delta also counts. Setup or refusal before admission remains uncharged.
+
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/llm/llm.py:1806 · openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

+

The refusal boundary is exercised before the transport: opaque clients, an empty web_search_options object, audio/non-text output options, and a model’s intrinsic search fee all fail admission. These cases do not add fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:367 · tests/sdk/llm/test_cost_budget.py:396 · openhands-sdk/openhands/sdk/llm/llm.py:1866

+

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/llm/call_context.py:29

+
+ +

04Each top-level run gets a fresh allowance

Source manifest
+
Each top-level run receives a fresh allowance; delegates share itA new top-level run resets its owned ledger while lifetime metrics remain cumulative. Delegated children share the ledger and ancestor ceilings. A fork copies the configured limit but owns a separate ledger.ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

+
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
+
+ +

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
+index c508fef15..510ac4b9a 100644
+--- a/openhands-sdk/openhands/sdk/conversation/conversation.py
++++ b/openhands-sdk/openhands/sdk/conversation/conversation.py
+@@ -73,6 +73,7 @@ class Conversation:
+         token_callbacks: list[ConversationTokenCallbackType] | None = None,
+         hook_config: HookConfig | None = None,
+         max_iteration_per_run: int = 500,
++        max_budget_per_run: float | None = None,
+         stuck_detection: bool = True,
+         stuck_detection_thresholds: (
+             StuckDetectionThresholds | Mapping[str, int] | None
+@@ -103,6 +104,7 @@ class Conversation:
+         token_callbacks: list[ConversationTokenCallbackType] | None = None,
+         hook_config: HookConfig | None = None,
+         max_iteration_per_run: int = 500,
++        max_budget_per_run: float | None = None,
+         stuck_detection: bool = True,
+         stuck_detection_thresholds: (
+             StuckDetectionThresholds | Mapping[str, int] | None
+@@ -133,6 +135,7 @@ class Conversation:
+         token_callbacks: list[ConversationTokenCallbackType] | None = None,
+         hook_config: HookConfig | None = None,
+         max_iteration_per_run: int = 500,
++        max_budget_per_run: float | None = None,
+         stuck_detection: bool = True,
+         stuck_detection_thresholds: (
+             StuckDetectionThresholds | Mapping[str, int] | None
+@@ -200,6 +203,7 @@ class Conversation:
+                 token_callbacks=token_callbacks,
+                 hook_config=hook_config,
+                 max_iteration_per_run=max_iteration_per_run,
++                max_budget_per_run=max_budget_per_run,
+                 stuck_detection=stuck_detection,
+                 stuck_detection_thresholds=stuck_detection_thresholds,
+                 visualizer=visualizer,
+@@ -223,6 +227,7 @@ class Conversation:
+             token_callbacks=token_callbacks,
+             hook_config=hook_config,
+             max_iteration_per_run=max_iteration_per_run,
++            max_budget_per_run=max_budget_per_run,
+             stuck_detection=stuck_detection,
+             stuck_detection_thresholds=stuck_detection_thresholds,
+             visualizer=visualizer,
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
+index 4885b35ef..64ad8aff9 100644
+--- a/openhands-sdk/openhands/sdk/conversation/request.py
++++ b/openhands-sdk/openhands/sdk/conversation/request.py
+@@ -207,6 +207,19 @@ class ConversationConfig(BaseModel):
+         description="If set, the max number of iterations the agent will run "
+         "before stopping. This is useful to prevent infinite loops.",
+     )
++    max_budget_per_run: float | None = Field(
++        default=None,
++        gt=0,
++        description=(
++            "Maximum cost in USD the agent may spend in a single run before "
++            "stopping. Enforcement is pre-call and reservation-based: each LLM "
++            "call reserves its worst-case cost before being sent, so a run "
++            "cannot overshoot the budget by a whole step. Not supported for ACP "
++            "agents, whose prompts bypass the SDK's LLM call path — supplying a "
++            "budget with an ACP agent is rejected rather than accepted as an "
++            "unenforceable cap. ``None`` (the default) leaves the run unbounded."
++        ),
++    )
+     stuck_detection: bool = Field(
+         default=True,
+         description="If true, the conversation will use stuck detection to "
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
+index 907b700d3..ca4d8b0fd 100644
+--- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
++++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
+@@ -777,6 +777,7 @@ class RemoteConversation(BaseConversation):
+         observability_span_name: str = "conversation",
+         observability_parent_span_context: str | None = None,
+         token_callbacks: list[ConversationTokenCallbackType] | None = None,
++        max_budget_per_run: float | None = None,
+         **_: object,
+     ) -> None:
+         """Remote conversation proxy that talks to an agent server.
+@@ -881,6 +882,7 @@ class RemoteConversation(BaseConversation):
+                 ),
+                 "initial_message": None,
+                 "max_iterations": max_iteration_per_run,
++                "max_budget_per_run": max_budget_per_run,
+                 "stuck_detection": stuck_detection,
+                 # We need to convert RemoteWorkspace to LocalWorkspace for the server
+                 "workspace": LocalWorkspace(
+@@ -955,6 +957,7 @@ class RemoteConversation(BaseConversation):
+             callbacks=callbacks,
+             token_callbacks=token_callbacks,
+             max_iteration_per_run=max_iteration_per_run,
++            max_budget_per_run=max_budget_per_run,
+             client_tools=[*(client_tools or []), *attached_client_tools],
+             visualizer=visualizer,
+         )
+@@ -1074,6 +1077,7 @@ class RemoteConversation(BaseConversation):
+             conversation_id=uuid.UUID(info["id"]),
+             callbacks=callbacks,
+             max_iteration_per_run=info["max_iterations"],
++            max_budget_per_run=info.get("max_budget_per_run"),
+             client_tools=[
+                 ClientToolSpec.model_validate(spec)
+                 for spec in info.get("client_tools") or []
+@@ -1094,6 +1098,7 @@ class RemoteConversation(BaseConversation):
+         visualizer: (
+             type[ConversationVisualizerBase] | ConversationVisualizerBase | None
+         ),
++        max_budget_per_run: float | None = None,
+         token_callbacks: list[ConversationTokenCallbackType] | None = None,
+     ) -> None:
+         super().__init__()  # Initialize base class with span tracking
+@@ -1102,6 +1107,7 @@ class RemoteConversation(BaseConversation):
+         if token_callbacks:
+             self._callbacks.append(_token_delta_callback(token_callbacks))
+         self.max_iteration_per_run = max_iteration_per_run
++        self.max_budget_per_run = max_budget_per_run
+         self.workspace = workspace
+         self._client = workspace.client
+         self._cleanup_initiated = False
+
✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
+index ea2d8d332..30b317ac8 100644
+--- a/openhands-agent-server/openhands/agent_server/models.py
++++ b/openhands-agent-server/openhands/agent_server/models.py
+@@ -170,6 +170,17 @@ class _ConversationInfoBase(BaseModel):
+             "Maximum number of iterations the agent can perform in a single run."
+         ),
+     )
++    max_budget_per_run: float | None = Field(
++        default=None,
++        gt=0,
++        description=(
++            "Maximum cost in USD the agent may spend in a single run before "
++            "stopping. Pre-call and reservation-based on the SDK's LLM call "
++            "path; not supported for ACP agents, whose prompts bypass that path "
++            "(a budget supplied with an ACP agent is rejected). ``None`` leaves "
++            "the run unbounded."
++        ),
++    )
+     stuck_detection: bool = Field(
+         default=True,
+         description="Whether to enable stuck detection for the agent.",
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
+index e7df77d9a..49a2d879f 100644
+--- a/openhands-agent-server/openhands/agent_server/conversation_service.py
++++ b/openhands-agent-server/openhands/agent_server/conversation_service.py
+@@ -344,6 +344,7 @@ def _compose_conversation_info(
+     )
+     return ConversationInfo(
+         **state.model_dump(mode="json"),
++        max_budget_per_run=stored.max_budget_per_run,
+         title=stored.title,
+         metrics=stored.metrics,
+         created_at=stored.created_at,
+
✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
+index 83721a6da..923713fbe 100644
+--- a/openhands-agent-server/openhands/agent_server/event_service.py
++++ b/openhands-agent-server/openhands/agent_server/event_service.py
+@@ -1300,6 +1300,7 @@ class EventService:
+             token_callbacks=([_token_streaming_callback] if streaming_enabled else []),
+             stream_callbacks=[_publish_stream_progress],
+             max_iteration_per_run=self.stored.max_iterations,
++            max_budget_per_run=self.stored.max_budget_per_run,
+             stuck_detection=self.stored.stuck_detection,
+             visualizer=None,
+             secrets=self.stored.secrets,
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
+index 23968b2d8..dc8e39b81 100644
+--- a/openhands-agent-server/openhands/agent_server/conversation_router.py
++++ b/openhands-agent-server/openhands/agent_server/conversation_router.py
+@@ -55,6 +55,7 @@ from openhands.agent_server.models import (
+ )
+ from openhands.agent_server.persistence import get_llm_profile_store
+ from openhands.sdk import LLM
++from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
+ from openhands.sdk.conversation.types import (
+     ConversationObservabilityMetadata,
+@@ -343,7 +344,7 @@ async def start_conversation(
+         info, is_new = await conversation_service.start_conversation(request)
+     except (ProfileNotFound, AgentLaunchError, LaunchStoreError) as e:
+         raise launch_http_exception(e) from e
+-    except ClientToolRegistrationError as e:
++    except (ClientToolRegistrationError, CostBudgetUnsupportedError) as e:
+         raise HTTPException(
+             status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
+         ) from e
+
✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
+index bfd1b6eec..f887053d8 100644
+--- a/clients/typescript/src/models/conversation.ts
++++ b/clients/typescript/src/models/conversation.ts
+@@ -57,6 +57,11 @@ export interface ConversationInfo {
+   workspace: unknown;
+   persistence_dir: string;
+   max_iterations?: number;
++  /**
++   * Per-conversation cost ceiling in USD, or null/undefined when unbounded.
++   * Enforced server-side before each LLM call.
++   */
++  max_budget_per_run?: number | null;
+   stuck_detection?: boolean;
+   conversation_stats?: ConversationStats;
+   /** API may return stats instead of conversation_stats */
+@@ -118,6 +123,12 @@ export interface CreateConversationRequest {
+   agent: AgentBase;
+   initial_message?: Message;
+   max_iterations: number;
++  /**
++   * Per-conversation cost ceiling in USD. Must be > 0 when set; omit or pass
++   * null for an unbounded conversation. Enforced server-side before each LLM
++   * call.
++   */
++  max_budget_per_run?: number | null;
+   stuck_detection: boolean;
+   workspace: Record<string, unknown>;
+   hook_config?: HookConfig | null;
+@@ -128,6 +139,8 @@ export interface CreateACPConversationRequest {
+   agent: ACPAgentConfig;
+   initial_message?: Message;
+   max_iterations: number;
++  /** Per-conversation cost ceiling in USD; see {@link CreateConversationRequest}. */
++  max_budget_per_run?: number | null;
+   stuck_detection: boolean;
+   workspace: Record<string, unknown>;
+   hook_config?: HookConfig | null;
+
✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
+index 04091d075..9a61d6257 100644
+--- a/clients/typescript/src/conversation/remote-conversation.ts
++++ b/clients/typescript/src/conversation/remote-conversation.ts
+@@ -52,6 +52,11 @@ export interface RemoteConversationOptions extends BaseConversationOptions {
+    * (PreToolUse, PostToolUse, UserPromptSubmit, Stop, etc.).
+    */
+   hookConfig?: HookConfig;
++  /**
++   * Optional per-conversation cost ceiling in USD. Must be > 0 when set.
++   * Enforced server-side before each LLM call.
++   */
++  maxBudgetPerRun?: number;
+   /**
+    * Optional error callback for non-fatal errors (WebSocket issues, state update failures).
+    * If not provided, these errors are silently ignored.
+@@ -92,6 +97,7 @@ export class RemoteConversation implements IConversation {
+   private onError?: ErrorCallbackType;
+   private hookConfig?: HookConfig;
+   private userId?: string;
++  private maxBudgetPerRun?: number;
+   private maxIterations?: number;
+   private stuckDetection?: boolean;
+ 
+@@ -107,6 +113,7 @@ export class RemoteConversation implements IConversation {
+     this._conversationId = options.conversationId;
+     this.hookConfig = options.hookConfig;
+     this.userId = options.userId;
++    this.maxBudgetPerRun = options.maxBudgetPerRun;
+     this.maxIterations = options.maxIterations;
+     this.stuckDetection = options.stuckDetection;
+ 
+@@ -147,6 +154,8 @@ export class RemoteConversation implements IConversation {
+       stuckDetection?: boolean;
+       hookConfig?: HookConfig;
+       userId?: string;
++      /** Per-conversation cost ceiling in USD. Must be > 0 when set. */
++      maxBudgetPerRun?: number;
+     } = {}
+   ): Promise<void> {
+     if (this._conversationId) {
+@@ -170,11 +179,13 @@ export class RemoteConversation implements IConversation {
+     // Start options take precedence over the constructor options
+     const hookConfig = options.hookConfig ?? this.hookConfig ?? undefined;
+     const userId = options.userId ?? this.userId ?? undefined;
++    const maxBudgetPerRun = options.maxBudgetPerRun ?? this.maxBudgetPerRun;
+ 
+     const request: CreateConversationRequest = {
+       agent: this.agent,
+       initial_message: initialMessage,
+       max_iterations: options.maxIterations || this.maxIterations || 500,
++      max_budget_per_run: maxBudgetPerRun ?? null,
+       stuck_detection: options.stuckDetection ?? this.stuckDetection ?? true,
+       workspace: { type: 'local', working_dir: this.workspace.workingDir },
+       hook_config: hookConfig ?? null,
+
✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
+index 8ac58e347..3f3c66fc7 100644
+--- a/clients/typescript/src/conversation/conversation-manager.ts
++++ b/clients/typescript/src/conversation/conversation-manager.ts
+@@ -341,6 +341,12 @@ export class ConversationManager {
+       stuckDetection?: boolean;
+       workingDir?: string;
+       userId?: string;
++      /**
++       * Per-run cost ceiling in USD. The server rejects a budget on ACP
++       * conversations with HTTP 422 because their prompts bypass the SDK's
++       * pre-call cost enforcement.
++       */
++      maxBudgetPerRun?: number;
+     } = {}
+   ): Promise<ACPConversationInfo> {
+     let initialMessage: CreateACPConversationRequest['initial_message'];
+@@ -355,6 +361,7 @@ export class ConversationManager {
+       agent,
+       initial_message: initialMessage,
+       max_iterations: options.maxIterations || 500,
++      max_budget_per_run: options.maxBudgetPerRun ?? null,
+       stuck_detection: options.stuckDetection ?? true,
+       workspace: { type: 'local', working_dir: options.workingDir || '/tmp' },
+       user_id: options.userId ?? null,
+

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
+index 9c56cf0b1..447231fb6 100644
+--- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
++++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
+@@ -5,6 +5,7 @@ import copy
+ import json
+ import uuid
+ from collections.abc import Mapping, Sequence
++from dataclasses import replace
+ from pathlib import Path, PurePath
+ from typing import Any, Final, TypeGuard, cast
+ 
+@@ -17,7 +18,10 @@ from openhands.sdk.context.prompts.prompt import render_template
+ from openhands.sdk.conversation.base import BaseConversation
+ from openhands.sdk.conversation.cancellation import CancellationToken
+ from openhands.sdk.conversation.event_store import EventLog
+-from openhands.sdk.conversation.exceptions import ConversationRunError
++from openhands.sdk.conversation.exceptions import (
++    ConversationRunError,
++    CostBudgetUnsupportedError,
++)
+ from openhands.sdk.conversation.secret_registry import SecretValue
+ from openhands.sdk.conversation.state import (
+     ConversationExecutionStatus,
+@@ -56,7 +60,8 @@ from openhands.sdk.io import FileStore, LocalFileStore
+ from openhands.sdk.llm import LLM, Message, TextContent, content_to_str
+ from openhands.sdk.llm.auth.openai import create_subscription_llm_from_config
+ from openhands.sdk.llm.call_context import LLMCallContext, llm_call_context_scope
+-from openhands.sdk.llm.exceptions import LLMAuthenticationError
++from openhands.sdk.llm.cost_budget import CostBudget
++from openhands.sdk.llm.exceptions import LLMAuthenticationError, LLMBudgetExceededError
+ from openhands.sdk.llm.llm_profile_store import LLMProfileStore
+ from openhands.sdk.llm.llm_registry import LLMRegistry
+ from openhands.sdk.logger import get_logger
+@@ -177,6 +182,26 @@ def _copy_event_for_fork(event: Event) -> Event:
+     return Event.model_validate_json(event.model_dump_json(exclude_none=True))
+ 
+ 
++def _reject_acp_budget(
++    agent: AgentBase | None, max_budget_per_run: float | None
++) -> None:
++    """Reject a per-run budget on an ACP agent, which cannot enforce one.
++
++    ACP prompts run in an external process that reports usage only after the
++    turn, so the SDK cannot reserve a call's worst-case cost before sending and
++    a single prompt can exceed the ceiling. Accepting the budget would silently
++    promise enforcement the SDK cannot deliver.
++    """
++    if max_budget_per_run is None or not isinstance(agent, ACPAgent):
++        return
++    raise CostBudgetUnsupportedError(
++        "max_budget_per_run is not supported for ACP agents: their "
++        "prompts bypass the SDK's pre-call cost reservation, so a single "
++        "prompt can exceed the budget before it is recorded. Use a "
++        "regular (LLM) agent to enforce a per-conversation budget."
++    )
++
++
+ def _same_llm_config(a: LLM, b: LLM) -> bool:
+     """Return whether two LLMs serialize to the same config, secrets included."""
+     if a is b:
+@@ -217,6 +242,9 @@ class LocalConversation(BaseConversation):
+     _mcp_tool_provider: MCPToolProvider
+     _mcp_config_from_parent: bool
+     _llm_call_context: LLMCallContext
++    # Lifetime recorded cost at the last run start; ``max_budget_per_run`` is a
++    # per-run ceiling measured from here.
++    _budget_run_baseline: float
+ 
+     def __init__(
+         self,
+@@ -283,6 +311,11 @@ class LocalConversation(BaseConversation):
+             hook_config: Optional hook configuration to auto-wire session hooks.
+                 If plugins are loaded, their hooks are combined with this config.
+             max_iteration_per_run: Maximum number of iterations per run
++            max_budget_per_run: Maximum cost in USD per run. Enforced before each
++                      LLM call by reserving the call's worst-case cost. Not
++                      supported for ACP agents (their prompts bypass the SDK LLM
++                      call path); passing a budget with one raises
++                      :class:`CostBudgetUnsupportedError`.
+             visualizer: Visualization configuration. Can be:
+                        - ConversationVisualizerBase subclass: Class to instantiate
+                          (default: ConversationVisualizer)
+@@ -403,6 +436,14 @@ class LocalConversation(BaseConversation):
+         ws_path = Path(self.workspace.working_dir)
+         if not ws_path.exists():
+             ws_path.mkdir(parents=True, exist_ok=True)
++        # Reject an ACP budget before ``ConversationState.create`` writes
++        # ``base_state.json``. A later rejection would leave an orphaned state
++        # file behind, so a retry with the same id but a different agent would
++        # resume the rejected ACP agent instead of creating the requested one.
++        # The resumed agent is not known until after ``create``, so only a
++        # caller-supplied agent can be checked here; the resume path re-checks
++        # once the persisted agent is loaded.
++        _reject_acp_budget(agent, max_budget_per_run)
+         self._state = ConversationState.create(
+             id=desired_id,
+             agent=agent,
+@@ -421,6 +462,10 @@ class LocalConversation(BaseConversation):
+         # ``self.agent`` and ``self._state.agent`` are the same object.
+         if agent is None:
+             agent = self._state.agent
++            # Resume path: the persisted agent is known only now. No state file
++            # is created here (base_state.json already exists), so re-checking
++            # the budget cannot orphan anything.
++            _reject_acp_budget(agent, max_budget_per_run)
+             # The persisted agent carries client-tool ``Tool`` specs, but in a
+             # fresh process the ``ClientTool`` *classes* are absent from the
+             # global registry. Re-register them from the persisted specs so
+@@ -507,8 +552,34 @@ class LocalConversation(BaseConversation):
+         )
+ 
+         self.max_iteration_per_run = max_iteration_per_run
+-        # Hard cost ceiling (USD) for a run; None disables the budget check.
++        # Hard cost ceiling (USD) per run; None disables it.
+         self.max_budget_per_run = max_budget_per_run
++        self._budget_run_baseline = 0.0
++        inherited_budget = (
++            _parent_llm_call_context.cost_budget
++            if _parent_llm_call_context is not None
++            else None
++        )
++        self._cost_budget: CostBudget | None = None
++        # True only for the conversation that created the budget; a conversation
++        # that inherited a parent's budget shares its ledger and must never
++        # re-seed it mid-flight.
++        self._owns_budget = False
++        if max_budget_per_run is not None:
++            if inherited_budget is not None:
++                # A subagent shares its parent's reservation pool so parent and
++                # children contend on one ceiling; the tightest limit applies.
++                self._cost_budget = inherited_budget.child(max_budget_per_run)
++            else:
++                self._cost_budget = CostBudget(max_budget_per_run)
++                self._owns_budget = True
++            self._llm_call_context = replace(
++                self._llm_call_context, cost_budget=self._cost_budget
++            )
++        elif inherited_budget is not None:
++            # No own ceiling, but inherit the parent's shared budget so a
++            # subagent still draws down the parent's allowance.
++            self._cost_budget = inherited_budget
+ 
+         # Initialize stuck detector
+         if stuck_detection:
+@@ -725,6 +796,18 @@ class LocalConversation(BaseConversation):
+     def conversation_stats(self):
+         return self._state.stats
+ 
++    def _combined_cost(self) -> float:
++        """Recorded cost across the conversation's LLMs in the current run (USD).
++
++        Subtracts the run-start baseline so ``max_budget_per_run`` bounds the
++        current run rather than draining every later run with earlier spend.
++        """
++        return self._combined_cost_total() - self._budget_run_baseline
++
++    def _combined_cost_total(self) -> float:
++        """Lifetime recorded cost across the conversation's LLMs (USD)."""
++        return self.conversation_stats.get_combined_metrics().accumulated_cost
++
+     def _latest_acp_prompt_message_id(self) -> str | None:
+         """Id of the most recent ACP prompt message, or None if there is none."""
+         acp_prompt_messages = [
+@@ -737,19 +820,43 @@ class LocalConversation(BaseConversation):
+     def _budget_exceeded_detail(self) -> str | None:
+         """Error detail if the run has hit its cost budget, else None.
+ 
+-        Bounds total spend across all of the run's LLMs (agent, condenser, ...),
+-        complementing the iteration cap which only bounds step count.
++        Bounds spend across all of the conversation's LLMs (agent, condenser,
++        ...) in the current run, complementing the iteration cap which only
++        bounds step count. Uses live cost (recorded + in-flight reservations) so
++        a run in progress cannot slip past the ceiling between checks.
++
+         """
++        if self._cost_budget is None:
++            return None
++        detail = self._cost_budget.exceeded_detail()
++        if detail is not None:
++            return detail
+         if self.max_budget_per_run is None:
++            # Inherited a parent's ceiling without one of its own; the shared
++            # ledger already enforces it.
+             return None
+-        spent = self.conversation_stats.get_combined_metrics().accumulated_cost
++        spent = self._combined_cost()
+         if spent < self.max_budget_per_run:
+             return None
+         return (
+-            f"Agent reached maximum budget limit "
+-            f"(${self.max_budget_per_run:.4f}); accumulated cost ${spent:.4f}."
++            f"Agent reached maximum budget limit (${self.max_budget_per_run:.4f}); "
++            f"accumulated cost ${spent:.4f}."
+         )
+ 
++    def _begin_budget_run(self) -> None:
++        """Start a fresh per-run budget allowance for a top-level run.
++
++        Records the run-start baseline and reseeds the ledger, so each
++        ``run()``/``arun()`` gets its own ``max_budget_per_run`` allowance rather
++        than inheriting earlier runs' spend. A conversation that only inherited a
++        parent's budget shares the parent's ledger and must not reseed it
++        mid-flight.
++        """
++        self._budget_run_baseline = self._combined_cost_total()
++        if self._owns_budget:
++            assert self._cost_budget is not None
++            self._cost_budget.reseed()
++
+     def _emit_run_limit_error(self, code: str, detail: str) -> None:
+         """Mark the run failed with a run-limit ConversationErrorEvent."""
+         logger.error(detail)
+@@ -900,11 +1007,18 @@ class LocalConversation(BaseConversation):
+                 persistence_dir=fork_persistence,
+                 conversation_id=fork_id,
+                 max_iteration_per_run=self.max_iteration_per_run,
++                max_budget_per_run=self.max_budget_per_run,
+                 stuck_detection=self._stuck_detector is not None,
+                 visualizer=type(self._visualizer) if self._visualizer else None,
+                 delete_on_close=self.delete_on_close,
+                 tags=tags,
+-                _parent_llm_call_context=self._llm_call_context,
++                # A fork is an independent conversation: inherit the source's
++                # non-budget call context but not its ceiling, so the fork's
++                # calls never reserve against the source's ledger. Its own
++                # max_budget_per_run (copied from the source) starts fresh.
++                _parent_llm_call_context=replace(
++                    self._llm_call_context, cost_budget=None
++                ),
+             )
+ 
+             # Branch slice copies path_to_root(event) (root-first, re-rootable);
+@@ -1963,6 +2077,7 @@ class LocalConversation(BaseConversation):
+         # Ensure agent is fully initialized (loads plugins and initializes agent)
+         self._ensure_agent_ready()
+         self._cancel_token = CancellationToken()
++        self._begin_budget_run()
+ 
+         with self._state:
+             if self._state.execution_status in [
+@@ -2042,6 +2157,11 @@ class LocalConversation(BaseConversation):
+                     finally:
+                         self._step_holds_state_lock = False
+                     iteration += 1
++                    # Metrics accumulate in place on the shared stats, bypassing
++                    # __setattr__ autosave; flag a flush so base_state.json —
++                    # which the API's listing/GET snapshot reads — reports live
++                    # accumulated_cost during the run, not just at completion.
++                    self._state.mark_dirty()
+ 
+                     # Check for non-finished terminal conditions
+                     # Note: We intentionally do NOT check for FINISHED status here.
+@@ -2103,6 +2223,12 @@ class LocalConversation(BaseConversation):
+             raise ConversationRunError(
+                 self._state.id, e, persistence_dir=self._state.persistence_dir
+             ) from e
++        except LLMBudgetExceededError as e:
++            # A pre-call reservation refusal is a normal terminal condition —
++            # like the iteration cap — not a crash: surface it as MaxBudgetReached
++            # and stop without raising ConversationRunError.
++            with self._state:
++                self._emit_run_limit_error("MaxBudgetReached", str(e))
+         except Exception as e:
+             with self._state:
+                 self._state.execution_status = ConversationExecutionStatus.ERROR
+@@ -2167,6 +2293,7 @@ class LocalConversation(BaseConversation):
+         # self-deadlock that ReadTimeouts after 30s (agent-canvas#1072).
+         # _ensure_agent_ready is thread-safe and already runs off-loop in run().
+         await asyncio.to_thread(self._ensure_agent_ready)
++        self._begin_budget_run()
+ 
+         with self._state:
+             if isinstance(self.agent, ACPAgent) and self._state.execution_status in (
+@@ -2321,6 +2448,11 @@ class LocalConversation(BaseConversation):
+                         finally:
+                             self._step_holds_state_lock = False
+                         iteration += 1
++                        # Metrics accumulate in place on the shared stats,
++                        # bypassing __setattr__ autosave; flag a flush so the
++                        # persisted snapshot reports live accumulated_cost during
++                        # the run, not just at completion.
++                        self._state.mark_dirty()
+ 
+                         # astep releases the state lock for the LLM call, so a
+                         # message can land mid-step with status still RUNNING and
+@@ -2473,6 +2605,10 @@ class LocalConversation(BaseConversation):
+                 )
+                 with self._state:
+                     iteration += 1
++                    # Metrics accumulate in place on the shared stats, bypassing
++                    # __setattr__ autosave; flag a flush so the persisted
++                    # snapshot reports live accumulated_cost during the run.
++                    self._state.mark_dirty()
+                     pause_requested_during_acp_step = any(
+                         isinstance(event, PauseEvent)
+                         for event in self._state.events[acp_step_start_event_count:]
+@@ -2623,6 +2759,12 @@ class LocalConversation(BaseConversation):
+             raise ConversationRunError(
+                 self._state.id, e, persistence_dir=self._state.persistence_dir
+             ) from e
++        except LLMBudgetExceededError as e:
++            # A pre-call reservation refusal is a normal terminal condition —
++            # like the iteration cap — not a crash: surface it as MaxBudgetReached
++            # and stop without raising ConversationRunError.
++            with self._state:
++                self._emit_run_limit_error("MaxBudgetReached", str(e))
+         except Exception as e:
+             with self._state:
+                 updated_agent_state = dict(self._state.agent_state)
+
🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
+new file mode 100644
+index 000000000..da9142417
+--- /dev/null
++++ b/openhands-sdk/openhands/sdk/llm/cost_budget.py
+@@ -0,0 +1,186 @@
++"""Per-conversation cost budget with pre-call reservation.
++
++A conversation budget must not merely compare "spent so far" before a call:
++concurrent callers (parallel delegates, subagents under one budget) all read
++the same total and pass, and a single call can still overshoot by its own
++worst case. ``CostBudget`` therefore *reserves* each call's worst-case cost
++before it is sent and settles it once the call completes, releasing the
++remainder when actual cost is known. Failed or unknown-cost attempts retain
++their bound; these conservative charges are budget usage, not billed cost.
++Settled + reserved is a live upper bound on what the current run can owe.
++
++Settled cost is carried on a ledger shared by a parent and its subagents, so the
++whole tree contends on one running total even though a subagent's metrics join
++the parent only after it finishes. The ledger is reset at the start of each
++top-level run (see :meth:`reseed`), which makes ``max_budget_per_run`` a
++per-run ceiling rather than a lifetime cap. A derived child view keeps every
++ancestor ceiling and enforces the tightest one, so a delegate declaring a larger
++budget than its parent cannot spend past the parent's cap.
++
++Pre-call reservation only covers the LLM call wrappers that install a
++reservation (``LLM.completion``/``acompletion``/...). Agents that delegate to an
++external process -- notably :class:`~openhands.sdk.agent.acp_agent.ACPAgent`,
++whose prompts never pass through those wrappers -- do not reserve, so a budget on
++such a conversation is rejected rather than accepted without pre-call enforcement.
++"""
++
++from __future__ import annotations
++
++import threading
++from dataclasses import dataclass
++
++
++class _Ledger:
++    """Shared settled/reserved totals for one budget tree, guarded by one lock."""
++
++    __slots__ = ("lock", "settled", "reserved")
++
++    def __init__(self) -> None:
++        self.lock = threading.Lock()
++        self.settled = 0.0
++        self.reserved = 0.0
++
++
++@dataclass(frozen=True)
++class BudgetReservation:
++    """A reservation of ``amount`` USD held against a :class:`CostBudget`."""
++
++    budget: CostBudget
++    amount: float
++
++
++class CostBudget:
++    """A cost ceiling (USD) enforced by pre-call reservation.
++
++    ``limit`` bounds the cost accrued in the current run (see :meth:`reseed`).
++    The ledger is shared by derived child views, each of which honours the
++    tightest ``limit`` among itself and its ancestors over the same running
++    total.
++    """
++
++    def __init__(self, limit: float) -> None:
++        if limit <= 0:
++            raise ValueError("Cost budget limit must be strictly positive")
++        self._limit = limit
++        self._ledger = _Ledger()
++        self._ancestor_limits: tuple[float, ...] = ()
++
++    def child(self, limit: float) -> CostBudget:
++        """Derive a subagent view with its own (usually smaller) ceiling.
++
++        Shares the parent's ledger, so the whole tree contends on one running
++        total, and enforces its own limit *and* every ancestor ceiling. A child
++        cannot declare a larger budget than its parent and spend past the
++        parent's cap.
++        """
++        if limit <= 0:
++            raise ValueError("Cost budget limit must be strictly positive")
++        view = CostBudget.__new__(CostBudget)
++        view._limit = limit
++        view._ledger = self._ledger
++        view._ancestor_limits = (*self._ancestor_limits, self._limit)
++        return view
++
++    @property
++    def limit(self) -> float:
++        """Tightest ceiling this view enforces, including ancestor limits."""
++        return min((self._limit, *self._ancestor_limits))
++
++    def _breaches(self, total: float) -> bool:
++        """True if ``total`` would exceed this view's ceiling or an ancestor's."""
++        if total > self._limit:
++            return True
++        return any(total > ceiling for ceiling in self._ancestor_limits)
++
++    def _reached(self, total: float) -> bool:
++        """True if ``total`` has already reached a ceiling (``>=`` for reporting)."""
++        if total >= self._limit:
++            return True
++        return any(total >= ceiling for ceiling in self._ancestor_limits)
++
++    @property
++    def spent(self) -> float:
++        """Settled budget usage, including bounds retained for unknown work."""
++        with self._ledger.lock:
++            return max(0.0, self._ledger.settled)
++
++    @property
++    def reserved(self) -> float:
++        with self._ledger.lock:
++            return self._ledger.reserved
++
++    @property
++    def live_total(self) -> float:
++        """Live upper bound on cost: settled + in-flight reservations."""
++        with self._ledger.lock:
++            return self._ledger.settled + self._ledger.reserved
++
++    def reseed(self) -> None:
++        """Start a fresh per-run allowance.
++
++        Zeroes the shared ledger so each top-level ``run()``/``arun()`` gets its
++        own ``max_budget_per_run`` allowance rather than inheriting earlier runs'
++        spend. A run that inherited a parent's budget shares the parent's ledger
++        and must not reseed it mid-flight.
++        """
++        with self._ledger.lock:
++            self._ledger.settled = 0.0
++            self._ledger.reserved = 0.0
++
++    def try_reserve(self, amount: float) -> BudgetReservation | None:
++        """Atomically reserve ``amount`` or return ``None`` if it would exceed.
++
++        The comparison and the reservation happen under one lock, so parallel
++        callers cannot all pass the same check. Every ancestor ceiling is checked
++        alongside this view's own, so a child view cannot admit a call that
++        breaches a parent's cap.
++        """
++        if amount < 0:
++            raise ValueError("Reservation amount cannot be negative")
++        with self._ledger.lock:
++            total = self._ledger.settled + self._ledger.reserved + amount
++            if self._breaches(total):
++                return None
++            self._ledger.reserved += amount
++            return BudgetReservation(self, amount)
++
++    def settle(self, reservation: BudgetReservation, actual: float) -> None:
++        """Release a reservation and add its settled budget usage.
++
++        The caller supplies recorded cost or retains a conservative bound for
++        failed/unknown work. This ledger does not replace billing metrics.
++        """
++        with self._ledger.lock:
++            self._ledger.reserved = max(0.0, self._ledger.reserved - reservation.amount)
++            self._ledger.settled += max(0.0, actual)
++
++    def exceeded_detail(self) -> str | None:
++        """Error detail if the live total has reached a ceiling, else ``None``."""
++        with self._ledger.lock:
++            total = self._ledger.settled + self._ledger.reserved
++            reached = self._reached(total)
++        if not reached:
++            return None
++        return self._limit_detail(accumulated=max(total, 0.0))
++
++    def denial_detail(self, amount: float) -> str:
++        """Message for a reservation of ``amount`` that would exceed a ceiling."""
++        with self._ledger.lock:
++            total = self._ledger.settled + self._ledger.reserved
++        return self._limit_detail(
++            accumulated=max(total, 0.0), attempted=max(total, 0.0) + amount
++        )
++
++    def _limit_detail(
++        self, *, accumulated: float, attempted: float | None = None
++    ) -> str:
++        detail = (
++            f"Agent reached maximum budget limit (${self.limit:.4f}); "
++            f"budget usage ${accumulated:.4f} (settled and reserved)."
++        )
++        if attempted is not None:
++            detail += f" Next call would need ${attempted:.4f}."
++        return detail
++
++
++__all__ = ["BudgetReservation", "CostBudget"]
+
✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
+index 6c886595e..8dd7cb5d8 100644
+--- a/openhands-sdk/openhands/sdk/llm/call_context.py
++++ b/openhands-sdk/openhands/sdk/llm/call_context.py
+@@ -6,7 +6,11 @@ from collections.abc import Iterator, MutableMapping
+ from contextlib import contextmanager
+ from contextvars import ContextVar
+ from dataclasses import dataclass, replace
+-from typing import Any
++from typing import TYPE_CHECKING, Any
++
++
++if TYPE_CHECKING:
++    from openhands.sdk.llm.cost_budget import CostBudget
+ 
+ 
+ @dataclass(frozen=True)
+@@ -21,6 +25,8 @@ class LLMCallContext:
+ 
+     prompt_cache_key: str | None = None
+     session_id: str | None = None
++    # Per-conversation cost ceiling. ``None`` disables budget enforcement.
++    cost_budget: CostBudget | None = None
+ 
+     def for_conversation(
+         self,
+
✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+269 / −0

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
+index 3e304d2ee..cb3ff82b3 100644
+--- a/openhands-sdk/openhands/sdk/llm/llm.py
++++ b/openhands-sdk/openhands/sdk/llm/llm.py
+@@ -2,7 +2,9 @@ from __future__ import annotations
+ 
+ import asyncio
+ import copy
++import inspect
+ import json
++import math
+ import os
+ import threading
+ import warnings
+@@ -16,6 +18,7 @@ from collections.abc import (
+     Sequence,
+ )
+ from contextvars import ContextVar
++from functools import wraps
+ from typing import (
+     TYPE_CHECKING,
+     Any,
+@@ -77,6 +80,7 @@ if TYPE_CHECKING:  # type hints only, avoid runtime import cycle
+     from openhands.sdk.tool.tool import ToolDefinition
+ 
+ from openhands.sdk.llm.auth.openai import transform_for_subscription
++from openhands.sdk.llm.call_context import resolve_llm_call_context
+ 
+ 
+ with warnings.catch_warnings():
+@@ -123,7 +127,9 @@ from litellm.utils import (
+ )
+ from tenacity import retry_if_exception, retry_if_exception_type
+ 
++from openhands.sdk.llm.cost_budget import BudgetReservation
+ from openhands.sdk.llm.exceptions import (
++    LLMBudgetExceededError,
+     LLMContextWindowTooSmallError,
+     LLMNoResponseError,
+     is_prompt_cache_too_small,
+@@ -1749,6 +1755,257 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+     # Chat Completion API
+     # =========================================================================
+ 
++    def _budget_bound_error(self, detail: str) -> LLMBudgetExceededError:
++        return LLMBudgetExceededError(
++            f"Cannot enforce cost budget for {self.model}: {detail}."
++        )
++
++    def _budget_token_bound(self, value: object, direction: str) -> int:
++        if (
++            isinstance(value, bool)
++            or not isinstance(value, int | float)
++            or not math.isfinite(value)
++            or value <= 0
++            or int(value) != value
++        ):
++            raise self._budget_bound_error(
++                f"unknown or invalid {direction} token bound"
++            )
++        return int(value)
++
++    def worst_case_cost(
++        self,
++        *,
++        input_tokens: int | None = None,
++        max_output_tokens: int | None = None,
++    ) -> float:
++        """Upper-bound USD using finite token bounds and published price tiers.
++
++        Unknown pricing or token limits cannot establish a hard ceiling and are
++        refused. Each direction uses its highest known context/cache tier, or
++        the paired custom prices also used by telemetry.
++        """
++        input_upper = self._budget_token_bound(input_tokens, "input")
++        output_upper = self._budget_token_bound(max_output_tokens, "output")
++        in_cost, out_cost = self._cost_per_token_bounds()
++        total = input_upper * in_cost + output_upper * out_cost
++        if not math.isfinite(total):
++            raise self._budget_bound_error("nonfinite price projection")
++        return total
++
++    def _budget_price(self, value: object) -> float:
++        if (
++            isinstance(value, bool)
++            or not isinstance(value, int | float)
++            or not math.isfinite(value)
++            or value < 0
++        ):
++            raise self._budget_bound_error("unknown or invalid token price")
++        return float(value)
++
++    def _cost_per_token_bounds(self) -> tuple[float, float]:
++        """Conservative directional rates, including context/cache tiers.
++
++        A single custom rate is not a complete override: telemetry uses custom
++        pricing only when both input and output prices are supplied. Explicit
++        zero is a valid known price; missing/nonfinite prices are not.
++        """
++        if (
++            self.input_cost_per_token is not None
++            and self.output_cost_per_token is not None
++        ):
++            return (
++                self._budget_price(self.input_cost_per_token),
++                self._budget_price(self.output_cost_per_token),
++            )
++        info = self._model_info or {}
++        input_costs = [self._budget_price(info.get("input_cost_per_token"))]
++        output_costs = [self._budget_price(info.get("output_cost_per_token"))]
++        uplifts = [1.0]
++        for key, value in info.items():
++            if value is None:
++                continue
++            if key.startswith(
++                (
++                    "input_cost_per_token",
++                    "cache_read_input_token_cost",
++                    "cache_creation_input_token_cost",
++                )
++            ):
++                input_costs.append(self._budget_price(value))
++            elif key.startswith(
++                ("output_cost_per_token", "output_cost_per_reasoning_token")
++            ):
++                output_costs.append(self._budget_price(value))
++            elif key.startswith("regional_processing_uplift_multiplier"):
++                uplifts.append(self._budget_price(value))
++        uplift = max(uplifts)
++        return max(input_costs) * uplift, max(output_costs) * uplift
++
++    def _reserve_budget(
++        self,
++        call_context: LLMCallContext | None,
++        request_kwargs: dict[str, Any],
++    ) -> BudgetReservation | None:
++        """Atomically reserve the bound for this exact prepared attempt.
++
++        Unsupported request overrides are refused only for budgeted calls:
++        opaque provider bodies, multiple completions and route changes can
++        invalidate the configured model's prices or the outgoing token cap.
++        """
++        budget = call_context.cost_budget if call_context else None
++        if budget is None:
++            return None
++        if (
++            request_kwargs.get("audio") is not None
++            or request_kwargs.get("modalities") not in (None, ["text"])
++            or "web_search_options" in request_kwargs
++            or (self._model_info or {}).get("search_context_cost_per_query") is not None
++        ):
++            raise self._budget_bound_error(
++                "non-token audio/search request costs are unsupported"
++            )
++        if litellm.num_retries or litellm.model_fallbacks or litellm.drop_params:
++            raise self._budget_bound_error(
++                "global LiteLLM retries/fallbacks/parameter dropping are unsupported"
++            )
++        if any(
++            request_kwargs.get(key)
++            for key in (
++                "retry_policy",
++                "additional_drop_params",
++                "context_window_fallback_dict",
++            )
++        ):
++            raise self._budget_bound_error("unsupported transport retry/drop override")
++        if request_kwargs.get("extra_body") or any(
++            key in request_kwargs
++            for key in (
++                "model",
++                "api_base",
++                "base_url",
++                "custom_llm_provider",
++                "client",
++                "fallbacks",
++                "model_list",
++            )
++        ):
++            raise self._budget_bound_error("unsupported request route/body override")
++        for key in ("n", "best_of"):
++            value = request_kwargs.get(key)
++            if value is not None and self._budget_token_bound(value, "output") != 1:
++                raise self._budget_bound_error("multiple completions are unsupported")
++        output_bounds = [
++            self._budget_token_bound(request_kwargs[key], "output")
++            for key in ("max_tokens", "max_completion_tokens", "max_output_tokens")
++            if key in request_kwargs
++        ]
++        if not output_bounds:
++            raise self._budget_bound_error("unknown output token bound")
++        # Token counters estimate formatted prompts. A hard pre-call bound
++        # instead uses the published provider input ceiling for every API.
++        # max_input_tokens is only a context-management hint and cannot lower
++        # this reservation because it does not constrain the outgoing request.
++        provider_cap = (self._model_info or {}).get("max_input_tokens")
++        metadata = cached_metadata(
++            self._runtime_metadata, self._runtime_metadata_fetched_at
++        )
++        if metadata is not None and metadata.confidence == "exact":
++            provider_cap = metadata.max_input_tokens
++        input_bound = self._budget_token_bound(provider_cap, "input")
++        worst_case = self.worst_case_cost(
++            input_tokens=input_bound, max_output_tokens=max(output_bounds)
++        )
++        reservation = budget.try_reserve(worst_case)
++        if reservation is None:
++            raise LLMBudgetExceededError(budget.denial_detail(worst_case))
++        return reservation
++
++    def _budget_guarded_attempt(
++        self,
++        call_context: LLMCallContext | None,
++        request_kwargs: dict[str, Any],
++    ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
++        """Reserve inside SDK retry, before each bounded transport attempt.
++
++        Failed/cancelled attempts retain their bound because the provider may
++        have processed work without returning complete usage. Successful calls
++        settle to recorded positive cost; absent cost telemetry retains the
++        bound too. This ledger is budget usage, not a provider billing record.
++        Unbudgeted calls keep their existing transport options and behavior.
++        """
++
++        def decorate(attempt: Callable[..., Any]) -> Callable[..., Any]:
++            if inspect.iscoroutinefunction(attempt):
++
++                @wraps(attempt)
++                async def _reserved_async(*args: Any, **kwargs: Any) -> Any:
++                    reservation = self._reserve_budget(
++                        call_context, {**request_kwargs, **kwargs}
++                    )
++                    if reservation is None:
++                        return await attempt(*args, **kwargs)
++                    cost_before = self.metrics.accumulated_cost
++                    # Keep retries in the SDK guard and never drop the cap as
++                    # an unsupported parameter inside LiteLLM.
++                    kwargs = {
++                        **kwargs,
++                        "drop_params": False,
++                        "max_retries": 0,
++                        "num_retries": 0,
++                    }
++                    try:
++                        result = await attempt(*args, **kwargs)
++                    except BaseException:
++                        reservation.budget.settle(
++                            reservation,
++                            max(reservation.amount, self._cost_delta(cost_before)),
++                        )
++                        raise
++                    cost = self._cost_delta(cost_before)
++                    reservation.budget.settle(
++                        reservation, cost if cost > 0 else reservation.amount
++                    )
++                    return result
++
++                return _reserved_async
++
++            @wraps(attempt)
++            def _reserved_sync(*args: Any, **kwargs: Any) -> Any:
++                reservation = self._reserve_budget(
++                    call_context, {**request_kwargs, **kwargs}
++                )
++                if reservation is None:
++                    return attempt(*args, **kwargs)
++                cost_before = self.metrics.accumulated_cost
++                kwargs = {
++                    **kwargs,
++                    "drop_params": False,
++                    "max_retries": 0,
++                    "num_retries": 0,
++                }
++                try:
++                    result = attempt(*args, **kwargs)
++                except BaseException:
++                    reservation.budget.settle(
++                        reservation,
++                        max(reservation.amount, self._cost_delta(cost_before)),
++                    )
++                    raise
++                cost = self._cost_delta(cost_before)
++                reservation.budget.settle(
++                    reservation, cost if cost > 0 else reservation.amount
++                )
++                return result
++
++            return _reserved_sync
++
++        return decorate
++
++    def _cost_delta(self, cost_before: float) -> float:
++        """Cost added to this LLM's own metrics by the call just completed."""
++        return max(0.0, self.metrics.accumulated_cost - cost_before)
++
+     def completion(
+         self,
+         messages: list[Message],
+@@ -1785,6 +2042,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+         # no-op for providers without runtime metadata.
+         self.resolve_runtime_metadata()
+ 
++        call_context = resolve_llm_call_context(call_context)
+         _caller_kwargs = kwargs.copy()
+         enable_streaming = bool(kwargs.get("stream", False)) or self.stream
+         if enable_streaming and on_token is None:
+@@ -1814,6 +2072,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+         )
+ 
+         @self._make_retry_decorator()
++        @self._budget_guarded_attempt(call_context, call_kwargs)
+         def _one_attempt(**retry_kwargs: Any) -> ModelResponse:
+             assert self._telemetry is not None
+             self._telemetry.on_request(telemetry_ctx=telemetry_ctx)
+@@ -1850,6 +2109,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+                     tools,
+                     add_security_risk_prediction=add_security_risk_prediction,
+                     on_token=on_token,
++                    call_context=call_context,
+                     **_caller_kwargs,
+                 )
+             # If the credential was rejected (401) and a refresh hook can supply
+@@ -1904,6 +2164,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+         # calls are cheap.
+         await self.aresolve_runtime_metadata()
+ 
++        call_context = resolve_llm_call_context(call_context)
+         _caller_kwargs = kwargs.copy()
+         enable_streaming = bool(kwargs.get("stream", False)) or self.stream
+         if enable_streaming and on_token is None:
+@@ -1934,6 +2195,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+ 
+         @self._make_retry_decorator()
+         @self._async_hard_timeout_decorator()
++        @self._budget_guarded_attempt(call_context, call_kwargs)
+         async def _one_attempt(**retry_kwargs: Any) -> ModelResponse:
+             assert self._telemetry is not None
+             self._telemetry.on_request(telemetry_ctx=telemetry_ctx)
+@@ -1970,6 +2232,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+                     tools,
+                     add_security_risk_prediction=add_security_risk_prediction,
+                     on_token=on_token,
++                    call_context=call_context,
+                     **_caller_kwargs,
+                 )
+             # If the credential was rejected (401) and a refresh hook can supply
+@@ -2043,6 +2306,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+         # metadata.
+         self.resolve_runtime_metadata()
+ 
++        call_context = resolve_llm_call_context(call_context)
+         _caller_kwargs = kwargs.copy()
+         user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream
+         if user_enable_streaming and on_token is None and not self.requires_streaming:
+@@ -2076,6 +2340,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+         )
+ 
+         @self._make_retry_decorator()
++        @self._budget_guarded_attempt(call_context, call_kwargs)
+         def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse:
+             assert self._telemetry is not None
+             self._telemetry.on_request(telemetry_ctx=telemetry_ctx)
+@@ -2151,6 +2416,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+                     store,
+                     add_security_risk_prediction=add_security_risk_prediction,
+                     on_token=on_token,
++                    call_context=call_context,
+                     **_caller_kwargs,
+                 )
+             # If the credential was rejected (401) and a refresh hook can supply
+@@ -2208,6 +2474,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+         # limits. No blocking network I/O in-process.
+         await self.aresolve_runtime_metadata()
+ 
++        call_context = resolve_llm_call_context(call_context)
+         _caller_kwargs = kwargs.copy()
+         user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream
+         if user_enable_streaming and on_token is None and not self.requires_streaming:
+@@ -2242,6 +2509,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+ 
+         @self._make_retry_decorator()
+         @self._async_hard_timeout_decorator()
++        @self._budget_guarded_attempt(call_context, call_kwargs)
+         async def _one_attempt(
+             **retry_kwargs: Any,
+         ) -> ResponsesAPIResponse:
+@@ -2343,6 +2611,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
+                     store,
+                     add_security_risk_prediction=add_security_risk_prediction,
+                     on_token=on_token,
++                    call_context=call_context,
+                     **_caller_kwargs,
+                 )
+             # If the credential was rejected (401) and a refresh hook can supply
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
+index da9da3806..8a34d4045 100644
+--- a/openhands-sdk/openhands/sdk/llm/exceptions/types.py
++++ b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
+@@ -131,6 +131,19 @@ class LLMBadRequestError(LLMError):
+         super().__init__(message)
+ 
+ 
++class LLMBudgetExceededError(LLMError):
++    """The conversation's cost budget cannot admit this call.
++
++    Raised *before* the provider request is sent, so a budget ceiling is never
++    crossed by a call that would exceed it. Deterministic in the reservation
++    state: retrying the same call cannot succeed, so the LLM retry loop must not
++    retry it and model fallback must not mask it.
++    """
++
++    def __init__(self, message: str = "Conversation cost budget exceeded") -> None:
++        super().__init__(message)
++
++
+ class LLMContentPolicyViolationError(LLMBadRequestError):
+     """Provider blocked the request/response via its content filter.
+ 
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
+index 878fced0d..40452d159 100644
+--- a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
++++ b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
+@@ -17,6 +17,7 @@ from litellm.exceptions import (
+ )
+ 
+ from .types import (
++    LLMBudgetExceededError,
+     LLMContextWindowExceedError,
+     LLMMalformedConversationHistoryError,
+     LLMRateLimitError,
+@@ -142,7 +143,7 @@ def is_prompt_cache_too_small(exception: Exception) -> bool:
+ 
+ def is_budget_exceeded_error(exception: BaseException) -> bool:
+     """Recognize explicit budget denials before or after SDK error mapping."""
+-    if isinstance(exception, BudgetExceededError):
++    if isinstance(exception, (BudgetExceededError, LLMBudgetExceededError)):
+         return True
+     if not isinstance(exception, (RateLimitError, LLMRateLimitError)):
+         return False
+
⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
+index 218c93a05..4e9123725 100644
+--- a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
++++ b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
+@@ -13,6 +13,7 @@ from .types import (
+     FunctionCallValidationError,
+     LLMAuthenticationError,
+     LLMBadRequestError,
++    LLMBudgetExceededError,
+     LLMContentPolicyViolationError,
+     LLMContextWindowExceedError,
+     LLMContextWindowTooSmallError,
+@@ -49,6 +50,7 @@ __all__ = [
+     "LLMTimeoutError",
+     "LLMServiceUnavailableError",
+     "LLMBadRequestError",
++    "LLMBudgetExceededError",
+     "UserCancelledError",
+     "OperationCancelled",
+     # Helpers
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
+index 8fcc9b9bb..83ebab66d 100644
+--- a/openhands-sdk/openhands/sdk/conversation/exceptions.py
++++ b/openhands-sdk/openhands/sdk/conversation/exceptions.py
+@@ -5,6 +5,18 @@ from openhands.sdk.event.conversation_error import ConversationErrorEvent
+ ISSUE_URL = "https://github.com/OpenHands/software-agent-sdk/issues/new"
+ 
+ 
++class CostBudgetUnsupportedError(ValueError):
++    """Raised when a cost budget is requested for an agent that cannot enforce it.
++
++    Pre-call budget enforcement reserves a call's worst-case cost on the SDK's
++    LLM call path. An :class:`~openhands.sdk.agent.acp_agent.ACPAgent` delegates
++    each prompt to an external process that owns the model and reports usage only
++    after the turn, so the SDK cannot reserve before sending and a single prompt
++    can exceed the ceiling. The budget is therefore rejected rather than accepted
++    as an unenforceable post-hoc cap.
++    """
++
++
+ class WebSocketConnectionError(RuntimeError):
+     """Raised when WebSocket connection fails to establish within the timeout."""
+ 
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
+index ed7741759..aa0b9785f 100644
+--- a/openhands-sdk/openhands/sdk/conversation/state.py
++++ b/openhands-sdk/openhands/sdk/conversation/state.py
+@@ -769,6 +769,16 @@ class ConversationState(OpenHandsModel):
+         """
+         return self._lock.locked()
+ 
++    def mark_dirty(self) -> None:
++        """Flag ``base_state.json`` for rewrite on the next flush.
++
++        In-place mutations (e.g. the LLM metrics accumulating cost) bypass
++        ``__setattr__`` autosave, so the persisted snapshot can lag the live
++        state. Callers that mutate nested state in place call this so the run
++        loop's ``with self._state`` block persists one fresh snapshot per step.
++        """
++        self._dirty = True
++
+     def owned(self) -> bool:
+         """
+         Return True if the lock is currently held by the calling thread.
+

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
+index ee6aef487..f74030443 100644
+--- a/clients/typescript/src/__tests__/api-clients.test.ts
++++ b/clients/typescript/src/__tests__/api-clients.test.ts
+@@ -1982,6 +1982,46 @@ describe('Auxiliary API clients', () => {
+     });
+   });
+ 
++  it('RemoteConversation.start sends the per-conversation cost budget', async () => {
++    global.fetch = vi.fn().mockResolvedValue(
++      new Response(JSON.stringify({ id: 'conv-123' }), {
++        status: 200,
++        headers: { 'content-type': 'application/json' },
++      })
++    ) as typeof fetch;
++
++    const agent = new Agent({ llm: { model: 'gpt-4o', api_key: 'k' } });
++    const workspace = new RemoteWorkspace({ host: 'http://example.com', workingDir: '/tmp' });
++    const conversation = new RemoteConversation(agent, workspace);
++
++    await conversation.start({ initialMessage: 'hello', maxBudgetPerRun: 2.5 });
++
++    const [, init] = (global.fetch as Mock).mock.calls[0];
++    expect(JSON.parse(init.body)).toMatchObject({
++      max_budget_per_run: 2.5,
++    });
++  });
++
++  it('RemoteConversation.start omits the budget when unset', async () => {
++    global.fetch = vi.fn().mockResolvedValue(
++      new Response(JSON.stringify({ id: 'conv-123' }), {
++        status: 200,
++        headers: { 'content-type': 'application/json' },
++      })
++    ) as typeof fetch;
++
++    const agent = new Agent({ llm: { model: 'gpt-4o', api_key: 'k' } });
++    const workspace = new RemoteWorkspace({ host: 'http://example.com', workingDir: '/tmp' });
++    const conversation = new RemoteConversation(agent, workspace);
++
++    await conversation.start({ initialMessage: 'hello' });
++
++    const [, init] = (global.fetch as Mock).mock.calls[0];
++    expect(JSON.parse(init.body)).toMatchObject({
++      max_budget_per_run: null,
++    });
++  });
++
+   it('HttpClient can parse blob responses when requested', async () => {
+     global.fetch = vi.fn().mockResolvedValue(
+       new Response(new Blob(['zip-data']), {
+
✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
+index eff6ce516..26687efd9 100644
+--- a/tests/agent_server/test_conversation_router.py
++++ b/tests/agent_server/test_conversation_router.py
+@@ -29,6 +29,7 @@ from openhands.agent_server.utils import utc_now
+ from openhands.sdk import LLM, Agent, TextContent, Tool
+ from openhands.sdk.agent.acp_agent import ACPAgent
+ from openhands.sdk.agent.base import AgentBase
++from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
+ from openhands.sdk.launch import LaunchRuntime, LaunchStores, finalize
+ from openhands.sdk.llm import llm_profile_store
+@@ -578,6 +579,30 @@ def test_start_conversation_new(
+         client.app.dependency_overrides.clear()
+ 
+ 
++def test_start_conversation_acp_budget_returns_422(client, mock_conversation_service):
++    """A budget on an ACP conversation surfaces as a 422, not a 500."""
++    mock_conversation_service.start_conversation.side_effect = (
++        CostBudgetUnsupportedError("max_budget_per_run is not supported for ACP")
++    )
++
++    client.app.dependency_overrides[get_conversation_service] = lambda: (
++        mock_conversation_service
++    )
++
++    try:
++        request_data = {
++            "agent": {"kind": "ACPAgent", "acp_command": ["echo", "test"]},
++            "workspace": {"working_dir": "/tmp/test"},
++            "max_budget_per_run": 1.0,
++        }
++        response = client.post("/api/conversations", json=request_data)
++
++        assert response.status_code == 422
++        assert "max_budget_per_run" in response.json()["detail"]
++    finally:
++        client.app.dependency_overrides.clear()
++
++
+ def test_start_conversation_merges_observability_headers(
+     client, mock_conversation_service, sample_conversation_info
+ ):
+
✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
+index 54824cde2..56394feba 100644
+--- a/tests/agent_server/test_conversation_service.py
++++ b/tests/agent_server/test_conversation_service.py
+@@ -38,6 +38,7 @@ from openhands.agent_server.models import (
+ from openhands.agent_server.utils import safe_rmtree as _safe_rmtree
+ from openhands.sdk import LLM, Agent, AgentBase, Message, Tool
+ from openhands.sdk.agent.acp_agent import ACPAgent
++from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError
+ from openhands.sdk.conversation.impl.local_conversation import LocalConversation
+ from openhands.sdk.conversation.state import (
+     ConversationExecutionStatus,
+@@ -146,6 +147,111 @@ async def test_server_resolved_tool_modules_are_persisted(tmp_path):
+     )
+ 
+ 
++@pytest.mark.asyncio
++async def test_max_budget_per_run_persisted_and_exposed(tmp_path):
++    """A budget set at creation persists in meta.json and surfaces on GET.
++
++    Reloading through a fresh service (server restart) must keep the bound so a
++    resumed run is still capped, and the composed ConversationInfo must expose
++    it alongside max_iterations.
++    """
++    conversations_dir = tmp_path / "conversations"
++    workspace_dir = tmp_path / "workspace"
++    workspace_dir.mkdir()
++    request = StartConversationRequest(
++        agent=Agent(llm=LLM(model="gpt-4o", usage_id="test-llm"), tools=[]),
++        workspace=LocalWorkspace(working_dir=str(workspace_dir)),
++        confirmation_policy=NeverConfirm(),
++        max_budget_per_run=2.5,
++    )
++
++    async with ConversationService(conversations_dir=conversations_dir) as service:
++        info, _ = await service.start_conversation(request)
++        assert info.max_budget_per_run == pytest.approx(2.5)
++
++    meta = json.loads((conversations_dir / info.id.hex / "meta.json").read_text())
++    assert meta["max_budget_per_run"] == pytest.approx(2.5)
++
++    async with ConversationService(conversations_dir=conversations_dir) as service2:
++        reloaded = await service2.get_conversation(info.id)
++        assert reloaded is not None
++        assert reloaded.max_budget_per_run == pytest.approx(2.5)
++
++
++@pytest.mark.asyncio
++async def test_start_conversation_rejects_non_positive_budget(tmp_path):
++    """The creation contract validates the budget as strictly positive."""
++    with pytest.raises(ValueError):
++        StartConversationRequest(
++            agent=Agent(llm=LLM(model="gpt-4o", usage_id="test-llm"), tools=[]),
++            workspace=LocalWorkspace(working_dir=str(tmp_path)),
++            confirmation_policy=NeverConfirm(),
++            max_budget_per_run=0.0,
++        )
++
++
++@pytest.mark.asyncio
++async def test_start_acp_conversation_rejects_budget(tmp_path):
++    """An ACP conversation cannot enforce a pre-call budget, so creation fails.
++
++    The SDK raises before launching the ACP process (the budget check runs in
++    ``LocalConversation.__init__``), so no subprocess is needed here.
++    """
++    conversations_dir = tmp_path / "conversations"
++    workspace_dir = tmp_path / "workspace"
++    workspace_dir.mkdir()
++    request = StartConversationRequest(
++        agent=ACPAgent(acp_command=["echo", "test"]),
++        workspace=LocalWorkspace(working_dir=str(workspace_dir)),
++        confirmation_policy=NeverConfirm(),
++        max_budget_per_run=1.0,
++    )
++
++    async with ConversationService(conversations_dir=conversations_dir) as service:
++        with pytest.raises(CostBudgetUnsupportedError):
++            await service.start_conversation(request)
++
++
++@pytest.mark.asyncio
++async def test_rejected_acp_budget_does_not_orphan_state_for_retry(tmp_path):
++    """A rejected ACP budget must not leave base_state.json behind.
++
++    Otherwise a retry with the same id but a regular agent would resume the
++    rejected ACP agent from that orphaned file instead of creating the
++    requested conversation.
++    """
++    conversations_dir = tmp_path / "conversations"
++    workspace_dir = tmp_path / "workspace"
++    workspace_dir.mkdir()
++    conversation_id = uuid4()
++    workspace = LocalWorkspace(working_dir=str(workspace_dir))
++    rejected = StartConversationRequest(
++        conversation_id=conversation_id,
++        agent=ACPAgent(acp_command=["echo", "test"]),
++        workspace=workspace,
++        confirmation_policy=NeverConfirm(),
++        max_budget_per_run=1.0,
++    )
++
++    async with ConversationService(conversations_dir=conversations_dir) as service:
++        with pytest.raises(CostBudgetUnsupportedError):
++            await service.start_conversation(rejected)
++        assert not (
++            conversations_dir / conversation_id.hex / "base_state.json"
++        ).exists()
++
++        retry = StartConversationRequest(
++            conversation_id=conversation_id,
++            agent=Agent(llm=LLM(model="gpt-4o", usage_id="test-llm"), tools=[]),
++            workspace=workspace,
++            confirmation_policy=NeverConfirm(),
++        )
++        info, is_new = await service.start_conversation(retry)
++        assert is_new
++        assert info.id == conversation_id
++        assert isinstance(info.agent, Agent)
++
++
+ @pytest.mark.asyncio
+ async def test_agent_tool_from_unimportable_module_rejects_create(tmp_path):
+     """The create fails with a client error instead of every message failing."""
+
✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
+index dc1245e3e..44d2228b1 100644
+--- a/tests/cross/test_remote_conversation_live_server.py
++++ b/tests/cross/test_remote_conversation_live_server.py
+@@ -884,6 +884,35 @@ def test_remote_conversation_created_from_agent_settings(server_env):
+     conversation.close()
+ 
+ 
++def test_conversation_budget_round_trips_over_real_server(server_env):
++    """A budget set at creation survives the REST round-trip.
++
++    Creates via ``POST /api/conversations`` with ``max_budget_per_run`` and
++    reads it back from ``GET /api/conversations/{id}``, proving the response
++    schema exposes the bound a watchdog would enforce against.
++    """
++    host = server_env["host"]
++    with httpx.Client(base_url=host, timeout=10.0) as client:
++        create_resp = client.post(
++            "/api/conversations",
++            json={
++                "agent": {
++                    "llm": {"model": "gpt-4o-mini", "api_key": "test"},
++                    "tools": [],
++                },
++                "workspace": {"working_dir": "/tmp/workspace/project"},
++                "max_budget_per_run": 3.5,
++            },
++        )
++        assert create_resp.status_code == 201, create_resp.text
++        assert create_resp.json()["max_budget_per_run"] == 3.5
++        conv_id = create_resp.json()["id"]
++
++        get_resp = client.get(f"/api/conversations/{conv_id}")
++        assert get_resp.status_code == 200, get_resp.text
++        assert get_resp.json()["max_budget_per_run"] == 3.5
++
++
+ def test_openai_chat_completions_gateway_over_real_server(
+     tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patched_llm
+ ):
+
✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
+index 807f19a89..4b6ba77f1 100644
+--- a/tests/sdk/conversation/local/test_agent_status_transition.py
++++ b/tests/sdk/conversation/local/test_agent_status_transition.py
+@@ -27,6 +27,7 @@ from openhands.sdk.conversation.state import ConversationExecutionStatus
+ from openhands.sdk.event import MessageEvent
+ from openhands.sdk.event.conversation_error import ConversationErrorEvent
+ from openhands.sdk.llm import ImageContent, Message, MessageToolCall, TextContent
++from openhands.sdk.llm.utils.metrics import Metrics
+ from openhands.sdk.testing import TestLLM
+ from openhands.sdk.tool import (
+     Action,
+@@ -59,10 +60,15 @@ class StatusCheckingExecutor(
+ ):
+     """Executor that captures the agent status when executed."""
+ 
+-    def __init__(self, status_during_execution: list[ConversationExecutionStatus]):
++    def __init__(
++        self,
++        status_during_execution: list[ConversationExecutionStatus],
++        cost_per_call: float = 0.0,
++    ):
+         self.status_during_execution: list[ConversationExecutionStatus] = (
+             status_during_execution
+         )
++        self.cost_per_call = cost_per_call
+ 
+     def __call__(
+         self, action: StatusTransitionMockAction, conversation=None
+@@ -70,6 +76,11 @@ class StatusCheckingExecutor(
+         # Capture the agent status during execution
+         if conversation:
+             self.status_during_execution.append(conversation.state.execution_status)
++            if self.cost_per_call:
++                # Accrue real spend mid-run, as a live LLM would, so the
++                # per-run budget check has something to trip on.
++                metrics = conversation.conversation_stats.usage_to_metrics
++                metrics.setdefault("spend", Metrics()).add_cost(self.cost_per_call)
+         return StatusTransitionMockObservation(result=f"Executed: {action.command}")
+ 
+ 
+@@ -547,10 +558,13 @@ def test_execution_status_error_on_max_budget(tmp_path):
+     """Run halts with ERROR + MaxBudgetReached when the cost budget is exceeded,
+     even before the iteration cap is reached."""
+     from openhands.sdk.conversation.impl.local_conversation import LocalConversation
+-    from openhands.sdk.llm.utils.metrics import Metrics
+ 
+     events_received: list = []
+-    test_tool = StatusTransitionTestTool.create(executor=StatusCheckingExecutor([]))[0]
++    # Each executed tool call accrues $0.60 of real spend mid-run, so the second
++    # step pushes the run past the $1.00 ceiling.
++    test_tool = StatusTransitionTestTool.create(
++        executor=StatusCheckingExecutor([], cost_per_call=0.6)
++    )[0]
+     register_tool("test_tool", test_tool)
+ 
+     tool_call_message = Message(
+@@ -580,10 +594,6 @@ def test_execution_status_error_on_max_budget(tmp_path):
+     conversation.send_message(
+         Message(role="user", content=[TextContent(text="Execute command")])
+     )
+-    # Pre-seed spend over the budget (TestLLM accrues no real cost).
+-    conversation.conversation_stats.usage_to_metrics["spend"] = Metrics(
+-        accumulated_cost=5.0
+-    )
+     conversation.run()
+ 
+     assert conversation.state.execution_status == ConversationExecutionStatus.ERROR
+
🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
+new file mode 100644
+index 000000000..eed344f1d
+--- /dev/null
++++ b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
+@@ -0,0 +1,177 @@
++"""Per-conversation cost budget: server-enforced reservation + live cost."""
++
++import tempfile
++from itertools import count
++from pathlib import Path
++from unittest.mock import patch
++
++import pytest
++from litellm.types.utils import Choices, Message as LiteLLMMessage, ModelResponse, Usage
++
++from openhands.sdk import LLM, Agent, Conversation, Message, TextContent
++from openhands.sdk.conversation.impl.local_conversation import LocalConversation
++from openhands.sdk.conversation.state import ConversationExecutionStatus
++from openhands.sdk.event.conversation_error import ConversationErrorEvent
++
++
++def _response_with_cost(model: str) -> ModelResponse:
++    # 1000 in + 1000 out tokens priced at the model's per-token rates.
++    return ModelResponse(
++        id=f"resp-{next(count())}",
++        choices=[Choices(message=LiteLLMMessage(role="assistant", content="done"))],
++        usage=Usage(prompt_tokens=1000, completion_tokens=1000, total_tokens=2000),
++        model=model,
++    )
++
++
++def _conversation(tmp: Path, *, max_budget_per_run: float | None) -> LocalConversation:
++    llm = LLM(
++        model="gpt-4o-mini",
++        usage_id="main",
++        num_retries=0,
++        input_cost_per_token=1e-5,
++        output_cost_per_token=1e-5,
++        max_output_tokens=2000,
++    )
++    return Conversation(
++        agent=Agent(llm=llm, tools=[]),
++        workspace=str(tmp),
++        persistence_dir=str(tmp / "persist"),
++        max_budget_per_run=max_budget_per_run,
++        visualizer=None,
++        delete_on_close=False,
++    )
++
++
++def test_run_aborts_with_max_budget_reached_when_reservation_would_exceed():
++    with tempfile.TemporaryDirectory() as d:
++        tmp = Path(d)
++        conv = _conversation(tmp, max_budget_per_run=0.001)
++        conv.send_message(Message(role="user", content=[TextContent(text="go")]))
++
++        def fake_completion(**kwargs):
++            return _response_with_cost(kwargs.get("model", "gpt-4o-mini"))
++
++        with patch(
++            "openhands.sdk.llm.llm.litellm_completion", side_effect=fake_completion
++        ):
++            conv.run()
++
++        assert conv.state.execution_status == ConversationExecutionStatus.ERROR
++        codes = [
++            e.code for e in conv.state.events if isinstance(e, ConversationErrorEvent)
++        ]
++        assert "MaxBudgetReached" in codes
++        # The provider input/output upper bound never fits a 0.001 ceiling.
++        assert conv.conversation_stats.get_combined_metrics().accumulated_cost == 0.0
++
++
++def test_run_completes_and_exposes_accumulated_cost_within_budget():
++    with tempfile.TemporaryDirectory() as d:
++        tmp = Path(d)
++        conv = _conversation(tmp, max_budget_per_run=2.0)
++        conv.send_message(Message(role="user", content=[TextContent(text="go")]))
++
++        def fake_completion(**kwargs):
++            return _response_with_cost(kwargs.get("model", "gpt-4o-mini"))
++
++        with patch(
++            "openhands.sdk.llm.llm.litellm_completion", side_effect=fake_completion
++        ):
++            conv.run()
++
++        assert conv.state.execution_status == ConversationExecutionStatus.FINISHED
++        cost = conv.conversation_stats.get_combined_metrics().accumulated_cost
++        assert cost == pytest.approx(0.02)
++
++
++def test_budget_is_per_run_not_lifetime():
++    """A second run gets a fresh allowance; earlier spend does not drain it."""
++    with tempfile.TemporaryDirectory() as d:
++        tmp = Path(d)
++        # Each run records 0.02; the provider's 128k input ceiling plus 2000
++        # output tokens reserves 1.30. A 1.31 allowance admits the later run
++        # only when its prior 0.02 does not count against the new reservation.
++        conv = _conversation(tmp, max_budget_per_run=1.31)
++
++        def fake_completion(**kwargs):
++            return _response_with_cost(kwargs.get("model", "gpt-4o-mini"))
++
++        with patch(
++            "openhands.sdk.llm.llm.litellm_completion", side_effect=fake_completion
++        ):
++            conv.send_message(Message(role="user", content=[TextContent(text="one")]))
++            conv.run()
++            assert conv.state.execution_status == ConversationExecutionStatus.FINISHED
++
++            conv.send_message(Message(role="user", content=[TextContent(text="two")]))
++            conv.run()
++
++        assert conv.state.execution_status == ConversationExecutionStatus.FINISHED
++        # Lifetime cost still accumulates across runs, for reporting.
++        cost = conv.conversation_stats.get_combined_metrics().accumulated_cost
++        assert cost == pytest.approx(0.04)
++
++
++def test_acp_agent_rejects_budget():
++    """Pre-call reservation is impossible for out-of-process ACP prompts, so a
++    budget on an ACP conversation is rejected instead of silently unenforced.
++    """
++    from openhands.sdk.agent.acp_agent import ACPAgent
++    from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError
++
++    with tempfile.TemporaryDirectory() as d:
++        tmp = Path(d)
++        with pytest.raises(CostBudgetUnsupportedError, match="ACP agents"):
++            Conversation(
++                agent=ACPAgent(acp_command=["echo", "test"]),
++                workspace=str(tmp),
++                persistence_dir=str(tmp / "persist"),
++                max_budget_per_run=1.0,
++                visualizer=None,
++                delete_on_close=False,
++            )
++
++
++def test_acp_agent_without_budget_is_allowed():
++    from openhands.sdk.agent.acp_agent import ACPAgent
++
++    with tempfile.TemporaryDirectory() as d:
++        tmp = Path(d)
++        conv = Conversation(
++            agent=ACPAgent(acp_command=["echo", "test"]),
++            workspace=str(tmp),
++            persistence_dir=str(tmp / "persist"),
++            visualizer=None,
++            delete_on_close=False,
++        )
++        assert conv.max_budget_per_run is None
++        assert conv._cost_budget is None
++
++
++def test_acp_budget_rejection_leaves_no_orphan_state():
++    """A rejected ACP budget must not write base_state.json.
++
++    If it did, retrying the same conversation id without a budget would resume
++    the rejected ACP agent instead of creating the requested conversation.
++    """
++    from uuid import uuid4
++
++    from openhands.sdk.agent.acp_agent import ACPAgent
++    from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError
++
++    with tempfile.TemporaryDirectory() as d:
++        tmp = Path(d)
++        persist = tmp / "persist"
++        cid = uuid4()
++        with pytest.raises(CostBudgetUnsupportedError, match="ACP agents"):
++            Conversation(
++                agent=ACPAgent(acp_command=["echo", "test"]),
++                workspace=str(tmp),
++                persistence_dir=str(persist),
++                conversation_id=cid,
++                max_budget_per_run=1.0,
++                visualizer=None,
++                delete_on_close=False,
++            )
++        assert not (persist / cid.hex / "base_state.json").exists()
+
✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
+index b9b93141c..e6d326e8e 100644
+--- a/tests/sdk/conversation/local/test_fork.py
++++ b/tests/sdk/conversation/local/test_fork.py
+@@ -178,6 +178,26 @@ def test_fork_preserves_metrics_when_requested():
+         assert combined.accumulated_cost == pytest.approx(1.5)
+ 
+ 
++def test_fork_does_not_share_source_budget_ledger():
++    """A fork is independent: it must not reserve against the source's budget."""
++    with tempfile.TemporaryDirectory() as tmpdir:
++        src = Conversation(
++            agent=_agent(),
++            persistence_dir=tmpdir,
++            workspace=tmpdir,
++            max_budget_per_run=1.0,
++        )
++        fork = src.fork()
++
++        # The fork carries the source's ceiling...
++        assert fork.max_budget_per_run == pytest.approx(1.0)
++        assert fork._cost_budget is not None
++        # ...but owns its own ledger rather than sharing the source's.
++        assert src._cost_budget is not None
++        assert fork._cost_budget is not src._cost_budget
++        assert fork._cost_budget._ledger is not src._cost_budget._ledger
++
++
+ def test_fork_copies_agent_state():
+     """agent_state dict should be carried over to the fork."""
+     with tempfile.TemporaryDirectory() as tmpdir:
+
✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
+index 16457eb93..075574f3a 100644
+--- a/tests/sdk/conversation/local/test_state_serialization.py
++++ b/tests/sdk/conversation/local/test_state_serialization.py
+@@ -814,6 +814,50 @@ def test_conversation_state_stats_preserved_on_resume():
+         ), "Context window should be preserved after resume"
+ 
+ 
++def test_mark_dirty_flushes_in_place_stats_mutation():
++    """mark_dirty persists in-place metrics that bypass __setattr__ autosave.
++
++    The run loop mutates ``stats.usage_to_metrics[*].accumulated_cost`` in place
++    (same Metrics objects the LLM holds), so no public-field assignment fires.
++    ``mark_dirty()`` + the enclosing ``with state:`` block must still flush a
++    fresh ``base_state.json`` so live cost is observable mid-run.
++    """
++    with tempfile.TemporaryDirectory() as temp_dir:
++        llm = LLM(
++            model="gpt-4o-mini", api_key=SecretStr("test-key"), usage_id="test-llm"
++        )
++        agent = Agent(llm=llm, tools=[])
++        conv_id = uuid.UUID("12345678-1234-5678-9abc-123456789020")
++        persist_path = LocalConversation.get_persistence_dir(temp_dir, conv_id)
++        state = ConversationState.create(
++            workspace=LocalWorkspace(working_dir="/tmp"),
++            persistence_dir=persist_path,
++            agent=agent,
++            id=conv_id,
++        )
++        state.stats.register_llm(RegistryEvent(llm=llm))
++
++        base_state_path = Path(persist_path) / "base_state.json"
++        with state:
++            state.mark_dirty()
++        before = json.loads(base_state_path.read_text())["stats"]["usage_to_metrics"]
++        assert before["test-llm"]["accumulated_cost"] == pytest.approx(0.0)
++
++        # In-place cost mutation on the shared Metrics. No public-field
++        # assignment fires, so the snapshot still reads the old value until a
++        # mark_dirty() flush runs.
++        assert llm.metrics is not None
++        llm.metrics.add_cost(0.25)
++        stale = json.loads(base_state_path.read_text())["stats"]["usage_to_metrics"]
++        assert stale["test-llm"]["accumulated_cost"] == pytest.approx(0.0)
++
++        with state:
++            state.mark_dirty()
++
++        after = json.loads(base_state_path.read_text())["stats"]["usage_to_metrics"]
++        assert after["test-llm"]["accumulated_cost"] == pytest.approx(0.25)
++
++
+ def test_resume_with_conversation_id_mismatch_raises_error():
+     """Test that resuming with mismatched conversation ID raises error."""
+     with tempfile.TemporaryDirectory() as temp_dir:
+
✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
+index 86914e25e..5b48fe9b7 100644
+--- a/tests/sdk/conversation/remote/test_remote_conversation.py
++++ b/tests/sdk/conversation/remote/test_remote_conversation.py
+@@ -414,6 +414,22 @@ class TestRemoteConversation:
+             expected_method
+         ]
+ 
++    @patch(
++        "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient"
++    )
++    def test_constructor_preserves_positional_stuck_detection(self, mock_ws_client):
++        client = self.setup_mock_client()
++
++        RemoteConversation(self.agent, self.workspace, None, None, None, 100, False)
++
++        create_call = next(
++            call
++            for call in client.request.call_args_list
++            if call.args == ("POST", "/api/conversations")
++        )
++        assert create_call.kwargs["json"]["stuck_detection"] is False
++        assert create_call.kwargs["json"]["max_budget_per_run"] is None
++
+     @patch(
+         "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient"
+     )
+
✏️ changedtests/sdk/llm/test_budget_denial_http.py+117 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
+index 5f626dded..0d1ebce91 100644
+--- a/tests/sdk/llm/test_budget_denial_http.py
++++ b/tests/sdk/llm/test_budget_denial_http.py
+@@ -7,7 +7,10 @@ import pytest
+ from pydantic import SecretStr
+ 
+ from openhands.sdk.llm import LLM, Message, TextContent
+-from openhands.sdk.llm.exceptions import LLMRateLimitError
++from openhands.sdk.llm.call_context import LLMCallContext
++from openhands.sdk.llm.cost_budget import CostBudget
++from openhands.sdk.llm.exceptions import LLMBudgetExceededError, LLMRateLimitError
++from tests.conftest import create_mock_litellm_response
+ 
+ 
+ @pytest.fixture
+@@ -145,3 +148,116 @@ async def test_http_rate_limit_retry_and_budget_recovery(
+     response = await call()
+     assert response.message.content == [TextContent(text="resumed")]
+     assert len(requests) == 2
++
++
++@pytest.mark.parametrize("api_mode", ["chat", "responses"])
++@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
++async def test_budgeted_request_keeps_output_cap_at_http_boundary(
++    provider, api_mode, use_async
++):
++    base_url, replies, requests = provider
++    response = {
++        "id": "bounded-response",
++        "model": "gpt-4o",
++        "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
++    }
++    if api_mode == "chat":
++        response.update(
++            object="chat.completion",
++            created=1,
++            choices=[
++                {
++                    "index": 0,
++                    "finish_reason": "stop",
++                    "message": {"role": "assistant", "content": "ok"},
++                }
++            ],
++        )
++    else:
++        response.update(
++            object="response",
++            created_at=1,
++            status="completed",
++            parallel_tool_calls=False,
++            tool_choice="auto",
++            tools=[],
++            output=[
++                {
++                    "type": "message",
++                    "id": "bounded-message",
++                    "role": "assistant",
++                    "status": "completed",
++                    "content": [
++                        {"type": "output_text", "text": "ok", "annotations": []}
++                    ],
++                }
++            ],
++            usage={"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
++        )
++    replies.append((200, response))
++    llm = LLM(
++        model="openai/gpt-4o",
++        base_url=base_url,
++        api_key=SecretStr("fixture-key"),
++        api_mode=api_mode,
++        max_output_tokens=100,
++        input_cost_per_token=0.000001,
++        output_cost_per_token=0.000001,
++        num_retries=0,
++    )
++    budget = CostBudget(1)
++    context = LLMCallContext(cost_budget=budget)
++    messages = [
++        Message(role="system", content=[TextContent(text="Reply briefly.")]),
++        Message(role="user", content=[TextContent(text="hi")]),
++    ]
++
++    result = (
++        await llm.agenerate(messages, call_context=context, num_retries=9)
++        if use_async
++        else llm.generate(messages, call_context=context, num_retries=9)
++    )
++
++    assert result.message.content == [TextContent(text="ok")]
++    assert len(requests) == 1
++    cap = "max_completion_tokens" if api_mode == "chat" else "max_output_tokens"
++    assert requests[0][cap] == 100
++    assert budget.spent == pytest.approx(0.000002)
++
++
++@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
++async def test_budget_owns_retries_at_http_boundary(provider, use_async):
++    base_url, replies, requests = provider
++    replies.extend(
++        [
++            (503, {"error": {"type": "server_error", "message": "Fixture failure"}}),
++            (200, create_mock_litellm_response("unguarded retry").model_dump()),
++        ]
++    )
++    llm = LLM(
++        model="openai/gpt-4o",
++        base_url=base_url,
++        api_key=SecretStr("fixture-key"),
++        max_output_tokens=100,
++        input_cost_per_token=0.0000005,
++        output_cost_per_token=0.001,
++        num_retries=2,
++        retry_min_wait=0,
++        retry_max_wait=0,
++    )
++    budget = CostBudget(0.2)
++    context = LLMCallContext(cost_budget=budget)
++    messages = [
++        Message(role="system", content=[TextContent(text="Reply briefly.")]),
++        Message(role="user", content=[TextContent(text="hi")]),
++    ]
++
++    with pytest.raises(LLMBudgetExceededError):
++        if use_async:
++            await llm.acompletion(messages, call_context=context, num_retries=2)
++        else:
++            llm.completion(messages, call_context=context, num_retries=2)
++
++    assert len(requests) == 1
++    assert budget.reserved == 0
++    assert 0 < budget.spent <= budget.limit
+
🟢 addedtests/sdk/llm/test_cost_budget.py+513 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
+new file mode 100644
+index 000000000..f147ae122
+--- /dev/null
++++ b/tests/sdk/llm/test_cost_budget.py
+@@ -0,0 +1,513 @@
++import asyncio
++import inspect
++import json
++import threading
++from importlib.resources import files
++from unittest.mock import AsyncMock, Mock
++
++import pytest
++from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
++from openai.types.responses import ResponseOutputMessage, ResponseOutputText
++
++import openhands.sdk.llm.llm as llm_module
++import openhands.sdk.llm.utils.telemetry as telemetry_module
++from openhands.sdk import LLM, Message, TextContent
++from openhands.sdk.llm.call_context import LLMCallContext
++from openhands.sdk.llm.cost_budget import BudgetReservation, CostBudget
++from openhands.sdk.llm.exceptions import LLMBudgetExceededError
++from tests.conftest import create_mock_litellm_response
++
++
++def _settle(budget: CostBudget, amount: float) -> None:
++    """Reserve and immediately settle ``amount``, as a completed call would."""
++    reservation = budget.try_reserve(amount)
++    assert reservation is not None
++    budget.settle(reservation, amount)
++
++
++def test_reserve_denies_when_it_would_exceed_limit():
++    budget = CostBudget(1.0)
++
++    assert budget.try_reserve(0.4) is not None
++    assert budget.try_reserve(0.4) is not None
++    # 0.4 + 0.4 + 0.4 > 1.0
++    assert budget.try_reserve(0.4) is None
++    assert budget.reserved == pytest.approx(0.8)
++
++
++def test_settle_records_actual_cost_and_frees_capacity():
++    budget = CostBudget(1.0)
++
++    reservation = budget.try_reserve(0.5)
++    assert reservation is not None
++    assert budget.reserved == pytest.approx(0.5)
++
++    # The provider charged less than the worst-case reservation.
++    budget.settle(reservation, 0.2)
++    assert budget.reserved == 0.0
++    assert budget.spent == pytest.approx(0.2)
++    assert budget.live_total == pytest.approx(0.2)
++
++    # Freed capacity is usable again.
++    assert budget.try_reserve(0.7) is not None
++
++
++def test_live_total_includes_in_flight_reservations():
++    budget = CostBudget(10.0)
++    _settle(budget, 3.0)
++    assert budget.try_reserve(2.0) is not None
++    assert budget.live_total == pytest.approx(5.0)
++
++
++def test_reseed_clears_prior_run_spend():
++    budget = CostBudget(10.0)
++    _settle(budget, 7.0)
++    assert budget.spent == pytest.approx(7.0)
++
++    budget.reseed()
++    assert budget.spent == 0.0
++    assert budget.reserved == 0.0
++    # A fresh run gets the full allowance again.
++    assert budget.try_reserve(7.0) is not None
++
++
++def test_child_shares_reservation_pool_but_bounds_its_own_spend():
++    parent = CostBudget(10.0)
++    child = parent.child(1.0)
++
++    # Child spend draws on the shared pool.
++    reservation = child.try_reserve(0.9)
++    assert reservation is not None
++    child.settle(reservation, 0.9)
++    assert parent.reserved == pytest.approx(0.0)
++    assert parent.live_total == pytest.approx(0.9)
++
++    # The child's own tighter ceiling now refuses more even though the parent
++    # has plenty of capacity left.
++    assert child.try_reserve(0.5) is None
++    assert parent.try_reserve(0.5) is not None
++
++
++def test_child_cannot_exceed_a_tighter_ancestor_ceiling():
++    """A delegate with a larger budget still cannot breach its parent's cap."""
++    parent = CostBudget(1.0)
++    # The child declares a *larger* budget than the parent.
++    child = parent.child(5.0)
++    _settle(parent, 0.8)
++
++    # The child's own $5 limit would admit $0.50, but the parent's $1 cap sees
++    # $1.30 total and refuses. `limit` reports the tightest enforced ceiling.
++    assert child.try_reserve(0.5) is None
++    assert child.limit == pytest.approx(1.0)
++    # Within the shared cap the child is admitted.
++    assert child.try_reserve(0.1) is not None
++
++
++def test_child_denial_reports_its_own_tighter_ceiling():
++    child = CostBudget(10.0).child(1.0)
++
++    assert child.try_reserve(2.0) is None
++    assert "maximum budget limit ($1.0000)" in child.denial_detail(2.0)
++
++
++def test_concurrent_reservations_cannot_overshoot():
++    """A shared budget admits at most the calls that fit, even in parallel."""
++    budget = CostBudget(1.0)
++    barrier = threading.Barrier(8)
++    granted: list[BudgetReservation] = []
++    lock = threading.Lock()
++
++    def worker():
++        barrier.wait()
++        reservation = budget.try_reserve(0.25)
++        if reservation is not None:
++            with lock:
++                granted.append(reservation)
++
++    threads = [threading.Thread(target=worker) for _ in range(8)]
++    for t in threads:
++        t.start()
++    for t in threads:
++        t.join()
++
++    # 8 x 0.25 = 2.0 against a 1.0 ceiling: exactly four should be admitted.
++    assert len(granted) == 4
++    assert budget.reserved == pytest.approx(1.0)
++
++
++def test_denial_detail_names_the_limit_and_attempted_call():
++    budget = CostBudget(1.0)
++    _settle(budget, 0.9)
++    detail = budget.denial_detail(0.5)
++    assert "$1.0000" in detail
++    assert "$0.9000" in detail
++    # 0.9 recorded + 0.5 attempted.
++    assert "$1.4000" in detail
++
++
++def test_non_positive_limit_is_rejected():
++    with pytest.raises(ValueError):
++        CostBudget(0.0)
++    with pytest.raises(ValueError):
++        CostBudget(1.0).child(0.0)
++
++
++@pytest.fixture
++def budget_call(monkeypatch):
++    info = {
++        "max_input_tokens": 20000,
++        "max_output_tokens": 100,
++        "input_cost_per_token": 0.0000005,
++        "output_cost_per_token": 0.001,
++    }
++    monkeypatch.setattr(llm_module, "get_litellm_model_info", lambda **_: info)
++    monkeypatch.setattr(llm_module, "token_counter", lambda **_: 10)
++    chat_response = create_mock_litellm_response("ok")
++    response = ResponsesAPIResponse(
++        id="budget-response",
++        created_at=0,
++        output=[
++            ResponseOutputMessage(
++                id="budget-message",
++                type="message",
++                role="assistant",
++                status="completed",
++                content=[
++                    ResponseOutputText(type="output_text", text="ok", annotations=[])
++                ],
++            )
++        ],
++        parallel_tool_calls=False,
++        tool_choice="auto",
++        top_p=None,
++        tools=[],
++        usage=ResponseAPIUsage(input_tokens=10, output_tokens=5, total_tokens=15),
++        status="completed",
++    )
++    providers = (
++        Mock(return_value=chat_response),
++        AsyncMock(return_value=chat_response),
++        Mock(return_value=response),
++        AsyncMock(return_value=response),
++    )
++    for name, provider in zip(
++        (
++            "litellm_completion",
++            "litellm_acompletion",
++            "litellm_responses",
++            "litellm_aresponses",
++        ),
++        providers,
++        strict=True,
++    ):
++        monkeypatch.setattr(llm_module, name, provider)
++
++    def make_llm(**kwargs):
++        options = {
++            "model": "openai/budget-fixture",
++            "api_key": "test",
++            "max_input_tokens": 20000,
++            "max_output_tokens": 100,
++            "num_retries": 0,
++        }
++        return LLM(**(options | kwargs))
++
++    messages = [
++        Message(role="system", content=[TextContent(text="Reply briefly.")]),
++        Message(role="user", content=[TextContent(text="hi")]),
++    ]
++    return info, make_llm, messages, providers
++
++
++@pytest.mark.parametrize("custom_prices", [{}, {"input_cost_per_token": 0.0}])
++def test_budget_refuses_unknown_directional_prices(budget_call, custom_prices):
++    info, make_llm, messages, providers = budget_call
++    info.pop("input_cost_per_token")
++    info.pop("output_cost_per_token")
++    llm = make_llm(**custom_prices)
++
++    with pytest.raises(LLMBudgetExceededError, match="price"):
++        llm.completion(
++            messages, call_context=LLMCallContext(cost_budget=CostBudget(100))
++        )
++
++    providers[0].assert_not_called()
++
++
++@pytest.mark.parametrize(
++    "field,value",
++    [("input_cost_per_token", float("inf")), ("output_cost_per_token", float("nan"))],
++)
++def test_budget_refuses_nonfinite_prices(budget_call, field, value):
++    info, make_llm, messages, providers = budget_call
++    info[field] = value
++    llm = make_llm()
++
++    with pytest.raises(LLMBudgetExceededError, match="price"):
++        llm.completion(
++            messages, call_context=LLMCallContext(cost_budget=CostBudget(100))
++        )
++
++    providers[0].assert_not_called()
++
++
++@pytest.mark.parametrize("axis", ["input", "output"])
++def test_budget_refuses_unknown_token_bound(budget_call, axis):
++    info, make_llm, messages, providers = budget_call
++    info.pop(f"max_{axis}_tokens")
++    llm = make_llm(**{f"max_{axis}_tokens": None})
++
++    with pytest.raises(LLMBudgetExceededError, match=axis):
++        llm.completion(
++            messages, call_context=LLMCallContext(cost_budget=CostBudget(100))
++        )
++
++    providers[0].assert_not_called()
++
++
++@pytest.mark.parametrize(
++    "invoke,cap",
++    [
++        (LLM.completion, "max_completion_tokens"),
++        (LLM.acompletion, "max_completion_tokens"),
++        (LLM.responses, "max_output_tokens"),
++        (LLM.aresponses, "max_output_tokens"),
++    ],
++)
++async def test_budget_prices_actual_request_output_cap(budget_call, invoke, cap):
++    _, make_llm, messages, providers = budget_call
++    llm = make_llm()
++
++    with pytest.raises(LLMBudgetExceededError):
++        result = invoke(
++            llm,
++            messages,
++            call_context=LLMCallContext(cost_budget=CostBudget(0.2)),
++            **{cap: 1000},
++        )
++        if inspect.isawaitable(result):
++            await result
++
++    for provider in providers:
++        provider.assert_not_called()
++
++
++@pytest.mark.parametrize("limit,admitted", [(0.2, False), (1.1, True)])
++def test_budget_accounts_for_published_context_tiers(
++    budget_call, monkeypatch, limit, admitted
++):
++    info, make_llm, messages, providers = budget_call
++    packaged = files("litellm").joinpath("model_prices_and_context_window_backup.json")
++    info.update(json.loads(packaged.read_text())["xai/grok-4-fast-reasoning"])
++    monkeypatch.setattr(llm_module, "token_counter", lambda **_: 128001)
++    llm = make_llm(
++        model="xai/grok-4-fast-reasoning",
++        max_input_tokens=2000000,
++        max_output_tokens=200000,
++    )
++    providers[0].return_value = create_mock_litellm_response(
++        "ok",
++        model="xai/grok-4-fast-reasoning",
++        prompt_tokens=128001,
++        completion_tokens=200000,
++    )
++    budget = CostBudget(limit)
++
++    if admitted:
++        result = llm.completion(
++            messages, call_context=LLMCallContext(cost_budget=budget)
++        )
++        assert result.message.content == [TextContent(text="ok")]
++        providers[0].assert_called_once()
++    else:
++        with pytest.raises(LLMBudgetExceededError):
++            llm.completion(messages, call_context=LLMCallContext(cost_budget=budget))
++        providers[0].assert_not_called()
++
++
++def test_budget_accounts_for_published_cache_write_tiers(budget_call):
++    info, make_llm, messages, providers = budget_call
++    info["cache_creation_input_token_cost_above_1hr"] = 0.05
++    llm = make_llm()
++
++    with pytest.raises(LLMBudgetExceededError):
++        llm.completion(
++            messages, call_context=LLMCallContext(cost_budget=CostBudget(0.2))
++        )
++
++    providers[0].assert_not_called()
++
++
++@pytest.mark.parametrize("invoke", [LLM.completion, LLM.responses])
++def test_budget_does_not_treat_input_hint_as_provider_limit(budget_call, invoke):
++    _, make_llm, messages, providers = budget_call
++    llm = make_llm(max_input_tokens=16384)
++
++    with pytest.raises(LLMBudgetExceededError):
++        invoke(
++            llm, messages, call_context=LLMCallContext(cost_budget=CostBudget(0.109))
++        )
++
++    for provider in providers:
++        provider.assert_not_called()
++
++
++@pytest.mark.parametrize(
++    "kwargs",
++    [
++        {"n": 2},
++        {"extra_body": {"max_tokens": 1000}},
++        {"model": "another-model"},
++        {"client": Mock()},
++        {"web_search_options": {}},
++        {"retry_policy": {"TimeoutErrorRetries": 2}},
++        {"additional_drop_params": ["max_completion_tokens"]},
++    ],
++)
++def test_budget_refuses_unbounded_request_overrides(budget_call, kwargs):
++    _, make_llm, messages, providers = budget_call
++    llm = make_llm()
++
++    with pytest.raises(LLMBudgetExceededError):
++        llm.completion(
++            messages,
++            call_context=LLMCallContext(cost_budget=CostBudget(0.2)),
++            **kwargs,
++        )
++
++    providers[0].assert_not_called()
++
++
++@pytest.mark.parametrize(
++    "model,kwargs,output_cap,limit",
++    [
++        (
++            "gpt-4o-mini-audio-preview",
++            {
++                "modalities": ["text", "audio"],
++                "audio": {"voice": "alloy", "format": "wav"},
++            },
++            16384,
++            0.05,
++        ),
++        ("gpt-4o-search-preview", {}, 100, 1),
++    ],
++)
++def test_budget_refuses_non_token_request_costs(
++    budget_call, model, kwargs, output_cap, limit
++):
++    info, make_llm, messages, providers = budget_call
++    packaged = files("litellm").joinpath("model_prices_and_context_window_backup.json")
++    info.update(json.loads(packaged.read_text())[model])
++    llm = make_llm(model=f"openai/{model}", max_output_tokens=output_cap)
++
++    with pytest.raises(LLMBudgetExceededError):
++        llm.completion(
++            messages,
++            call_context=LLMCallContext(cost_budget=CostBudget(limit)),
++            **kwargs,
++        )
++
++    providers[0].assert_not_called()
++
++
++@pytest.mark.parametrize(
++    "name,value",
++    [("num_retries", 2), ("model_fallbacks", ["another-model"]), ("drop_params", True)],
++)
++def test_budget_refuses_unsafe_global_transport_options(
++    budget_call, monkeypatch, name, value
++):
++    _, make_llm, messages, providers = budget_call
++    monkeypatch.setattr(llm_module.litellm, name, value)
++    llm = make_llm()
++
++    with pytest.raises(LLMBudgetExceededError, match="global LiteLLM"):
++        llm.completion(
++            messages, call_context=LLMCallContext(cost_budget=CostBudget(0.2))
++        )
++
++    providers[0].assert_not_called()
++
++
++def test_budget_allows_known_free_custom_prices(budget_call):
++    info, make_llm, messages, providers = budget_call
++    info.pop("input_cost_per_token")
++    info.pop("output_cost_per_token")
++    llm = make_llm(input_cost_per_token=0.0, output_cost_per_token=0.0)
++
++    result = llm.completion(
++        messages, call_context=LLMCallContext(cost_budget=CostBudget(0.01))
++    )
++
++    assert result.message.content == [TextContent(text="ok")]
++    providers[0].assert_called_once()
++
++
++def test_unbudgeted_call_does_not_require_price_or_caps(budget_call):
++    info, make_llm, messages, providers = budget_call
++    info.clear()
++    llm = make_llm(max_input_tokens=None, max_output_tokens=None)
++
++    result = llm.completion(messages)
++
++    assert result.message.content == [TextContent(text="ok")]
++    providers[0].assert_called_once()
++
++
++def test_budget_retains_recorded_cost_before_validation_retry(budget_call):
++    _, make_llm, messages, providers = budget_call
++    response = create_mock_litellm_response("", prompt_tokens=10, completion_tokens=100)
++    response.choices = []
++    providers[0].return_value = response
++    llm = make_llm(
++        input_cost_per_token=0.0000005,
++        output_cost_per_token=0.001,
++        num_retries=2,
++        retry_min_wait=0,
++        retry_max_wait=0,
++    )
++    budget = CostBudget(0.15)
++
++    with pytest.raises(LLMBudgetExceededError):
++        llm.completion(messages, call_context=LLMCallContext(cost_budget=budget))
++
++    providers[0].assert_called_once()
++    assert llm.metrics.accumulated_cost == pytest.approx(0.100005)
++    assert budget.spent == pytest.approx(0.11)
++    assert budget.reserved == 0
++
++
++async def test_budget_retains_unknown_work_on_cancellation(budget_call):
++    _, make_llm, messages, providers = budget_call
++    providers[1].side_effect = asyncio.CancelledError()
++    llm = make_llm()
++    budget = CostBudget(0.15)
++
++    with pytest.raises(asyncio.CancelledError):
++        await llm.acompletion(messages, call_context=LLMCallContext(cost_budget=budget))
++
++    providers[1].assert_called_once()
++    assert budget.spent == pytest.approx(0.11)
++    assert budget.reserved == 0
++
++
++def test_budget_retains_successful_work_when_cost_is_unknown(budget_call, monkeypatch):
++    _, make_llm, messages, providers = budget_call
++    monkeypatch.setattr(
++        telemetry_module,
++        "litellm_completion_cost",
++        Mock(side_effect=ValueError("Fixture: response price unavailable")),
++    )
++    llm = make_llm()
++    budget = CostBudget(0.15)
++    context = LLMCallContext(cost_budget=budget)
++
++    result = llm.completion(messages, call_context=context)
++    with pytest.raises(LLMBudgetExceededError):
++        llm.completion(messages, call_context=context)
++
++    assert result.message.content == [TextContent(text="ok")]
++    providers[0].assert_called_once()
++    assert llm.metrics.accumulated_cost == 0
++    assert budget.spent == pytest.approx(0.11)
+
+ +

06Evidence has an explicit boundary

Source manifest
+

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:113 · tests/sdk/llm/test_cost_budget.py:106 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

+
ValidationObserved result and limit
Final admission repair
94039bc
110 final focused tests passed with no failures or skips. The 110 cases comprise 42 cost cases, 62 real loopback HTTP cases, and six local conversation budget cases. The five-file checks and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:155 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Broader pre-final run1,698 of 1,709 passed; 11 Kimi image/model tests failed with the forced packaged metadata catalog. This run preceded the final client/audio/search refusal additions and is not a wholly green final-head run.
Baseline and recheckAll 11 metadata-related failures reproduced at 091882a under the same forced catalog. All 11 passed on current code with ordinary latest public LiteLLM metadata. No image/vision product change was made.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
+
Commands and result provenance

Sanitized verification summaries and XML result counts recorded on 18 October 2026; raw local logs and profile data are absent from this portable page. Final focused command scope:

pytest tests/sdk/llm/test_cost_budget.py \
+  tests/sdk/llm/test_budget_denial_http.py \
+  tests/sdk/conversation/local/test_cost_budget_enforcement.py -q

Prior contract validation at 091882a also included:

pytest tests/sdk/llm tests/sdk/conversation \
+  tests/agent_server/test_conversation_service.py \
+  tests/agent_server/test_conversation_router.py \
+  tests/cross/test_remote_conversation_live_server.py -q
+
+npm --script-shell=/bin/sh run lint
+npm --script-shell=/bin/sh run test:coverage -- --maxWorkers=1
+npm --script-shell=/bin/sh run format:check

That earlier broad run reported 2,437 passed, one existing skip, and two environment-path failures; both passed in an environment-only 11-test rerun. It is historical evidence, not a wholly green final-head run.

+
ⓘ Integration boundary: this SDK PR adds the run cap, not a Canvas budget meter. A meter needs matching current-run cost reporting and frontend integration; cumulative lifetime cost and a legacy task-budget property do not express this run's remaining allowance.
+

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

+
Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: 94039bc709d2b54e6c622d1444e7f2d0722348d5. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file From 2960119d5b1f0d8e3e2fc07523b3fbc2d022c71d Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 07:00:44 +0200 Subject: [PATCH 10/24] docs(pr): keep source links usable in hosted preview Co-authored-by: openhands --- .pr/budget-design.html | 108 ++++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/.pr/budget-design.html b/.pr/budget-design.html index 71b8f21490e..f472b195b2f 100644 --- a/.pr/budget-design.html +++ b/.pr/budget-design.html @@ -4,10 +4,10 @@ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.6 var(--sans)}.wrap{display:grid;grid-template-columns:192px minmax(0,1fr);gap:44px;max-width:1210px;margin:auto;padding:38px 32px 80px}nav{position:sticky;top:30px;align-self:start;font-size:13px}nav h2{font-size:10px;text-transform:uppercase;letter-spacing:.13em;color:var(--muted);margin:0 0 14px}nav ol{list-style:none;margin:0;padding:0;counter-reset:toc}nav li{counter-increment:toc;margin:10px 0}nav li::before{content:counter(toc,decimal-leading-zero) " ";color:var(--subtle);font:11px var(--mono)}nav a{color:var(--fg);text-decoration:none}nav small{display:block;margin-top:24px;color:var(--muted);line-height:1.6}a{color:var(--accent);text-underline-offset:3px}a:hover{color:var(--warn)}header{padding-bottom:24px;border-bottom:1px solid var(--rule)}.eyebrow{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);font-weight:750}h1{font-size:40px;line-height:1.12;letter-spacing:-.04em;margin:13px 0 16px;max-width:760px}.sub{font-size:18px;line-height:1.6;color:var(--muted);max-width:760px;margin:0}.meta{font:11px/1.8 var(--mono);color:var(--muted);margin-top:17px;overflow-wrap:anywhere}h2{font-size:25px;line-height:1.25;letter-spacing:-.025em;margin:0 0 14px}h2 .num{color:var(--subtle);font:13px var(--mono);margin-right:12px}section{padding-top:40px;scroll-margin-top:24px}h3{font-size:17px;line-height:1.35;margin:23px 0 8px}p{margin:10px 0 14px}code{font:.87em var(--mono);padding:2px 4px;background:var(--code-bg);border-radius:3px}pre{font:12px/1.65 var(--mono);padding:16px;background:var(--code-bg);border:1px solid var(--rule);border-radius:5px;overflow:auto;margin:8px 0}pre code{font:inherit;padding:0;background:none}.kw{color:var(--accent);font-weight:700}.com{color:var(--subtle)}.str{color:var(--ok)}.ba{display:grid;grid-template-columns:1fr 1fr;gap:16px}.label{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:750}.callout{margin:22px 0 20px;border-left:3px solid var(--accent);border-radius:0 5px 5px 0;background:var(--accent-soft);padding:13px 16px;font-size:14px}.callout.warn{border-color:var(--warn);background:var(--warn-soft)}.callout.note{border-color:var(--subtle);background:var(--code-bg)}.sources{font-size:12px;color:var(--muted);margin:8px 0 16px;line-height:1.8}.src{font:11px/1.7 var(--mono);text-decoration:none;border-bottom:1px dotted var(--accent);overflow-wrap:anywhere}.sources .src{font-size:10.5px}table{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}th,td{text-align:left;vertical-align:top;padding:10px 11px;border-bottom:1px solid var(--rule)}th{font-size:11px;color:var(--muted);background:var(--code-bg)}td:first-child{width:24%;font-weight:650}.fig{margin:18px 0 10px}.fig svg{display:block;width:100%;height:auto;background:var(--card);border:1px solid var(--rule);border-radius:6px}.fig figcaption{font-size:12px;line-height:1.5;color:var(--muted);margin:8px 2px}details{border:1px solid var(--rule);border-radius:5px;margin:10px 0}summary{cursor:pointer;padding:9px 12px;font-size:12px;color:var(--muted)}details>div{padding:3px 12px 12px}details pre{margin:0;border:0;border-radius:0;font-size:10.5px;max-height:550px}details.manifest{border:0;border-bottom:1px solid var(--rule);border-radius:0;margin:-4px 0 18px}details.manifest summary{padding:5px 0;font-size:11px}details.manifest>div{padding:3px 0 10px}.files summary{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}.files .path{font:11px var(--mono);color:var(--fg);overflow-wrap:anywhere}.files .counts{margin-left:auto;white-space:nowrap;font:10px var(--mono)}.add{color:var(--ok)}.del{color:var(--crit)}.tag{font-size:10px;color:var(--subtle)}.group{margin:25px 0 8px;font-size:13px;font-weight:750}.files p{font-size:12px;color:var(--muted)}footer{border-top:1px solid var(--rule);margin-top:38px;padding-top:16px;color:var(--muted);font-size:11px}.pending{color:var(--warn);font-weight:700}@media(max-width:900px){.wrap{grid-template-columns:1fr;padding:24px 20px 64px;gap:25px}nav{position:static}nav ol{display:flex;flex-wrap:wrap;gap:0 17px}nav li{margin:4px 0}nav small{display:none}h1{font-size:34px}}@media(max-width:600px){.ba{grid-template-columns:1fr}h1{font-size:30px}.sub{font-size:16px}th,td{padding:8px 6px;font-size:12px}.wrap{padding:22px 14px 54px}}@media print{.wrap{display:block;padding:0}nav{display:none}details{break-inside:avoid}h2{break-after:avoid}.fig{break-inside:avoid}a{color:var(--fg)}}
-
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ 94039bc709d2b54e6c622d1444e7f2d0722348d5
30 changed files · +1826 / −20 against merge base · Admission repair included
+
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ 94039bc709d2b54e6c622d1444e7f2d0722348d5
30 changed files · +1826 / −20 against merge base · Admission repair included

Source links open commit-pinned files; displayed path:line labels identify the exact lines.

-

01The option now crosses the public API

Source manifest
-

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

+

01The option now crosses the public API

Source manifest
+

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

Before · merge base
# Local implementation only
 LocalConversation(..., max_budget_per_run=None)
 
@@ -19,21 +19,21 @@
 
 // TypeScript remote constructor or start option
 { maxBudgetPerRun: 2.5 }
-

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

-
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
+

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

+
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
-

02Reserve before each SDK LLM attempt

Source manifest
+

02Reserve before each SDK LLM attempt

Source manifest
The admission check moves before each SDK LLM attemptBefore: a local run checks accumulated lifetime metrics after a step. After: SDK LLM attempts reserve against one shared ledger before transport, then settle each attempt. Authoritative bounds, transport controls, and conservative settlement are the focused repair described in section 3.LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1924CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2080new gateCostBudget.settle()positive success: cost; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
-

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

-

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

-

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost. After an attempt starts, failed, cancelled, or unknown-cost successful work retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1158 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

+LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1924CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2080new gateCostBudget.settle()positive success: cost; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
+

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

+

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

+

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost. After an attempt starts, failed, cancelled, or unknown-cost successful work retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1158 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

-

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

-
⚠ The original PR's admission projection at 091882a invented rates for missing price data and a 32,768-token cap for missing limits. A finite fallback is an estimate, not an upper bound; published context tiers can also exceed the selected base rates.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:180 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1765 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1789 @ 091882a

+

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

+
⚠ The original PR's admission projection at 091882a invented rates for missing price data and a 32,768-token cap for missing limits. A finite fallback is an estimate, not an upper bound; published context tiers can also exceed the selected base rates.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:180 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1765 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1789 @ 091882a

A deterministic probe at 091882a, using packaged LiteLLM metadata for xai/grok-4-fast-reasoning, admitted a $0.1640005 reservation under a $0.20 cap while the published tier arithmetic was $0.2512004. This was metadata arithmetic with no provider request or billed usage.

Reproduction inputs and arithmetic · original PR at 091882a
input tokens:          128,001
 maximum output tokens: 200,000
@@ -45,22 +45,22 @@
 No credentials, network, or model completion used.
 Metadata snapshot SHA-256:
 cc1955f7f1cdc2cdd9ebf216e7eebbc90bbb3528b62439327f7e8b893eda7c12

Observed against the pre-repair admission helper; the focused regression tests belong with the repair.

-

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1776 · openhands-sdk/openhands/sdk/llm/llm.py:1806 · openhands-sdk/openhands/sdk/llm/llm.py:1845

-

Failed attempts cannot receive a blind refund

A separate deterministic fixture at 091882a followed the real SDK completion, telemetry, validation, and retry path. Two invalid-response attempts each reserved $0.11; under a $0.15 cap, SDK telemetry recorded $0.22 while the budget ledger finished at zero. A timeout fixture separately reproduced reuse of allowance for uncertain attempted work. These probes establish SDK accounting behavior, without a provider request or billing claim.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:1833 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1887 @ 091882a

+

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1776 · openhands-sdk/openhands/sdk/llm/llm.py:1806 · openhands-sdk/openhands/sdk/llm/llm.py:1845

+

Failed attempts cannot receive a blind refund

A separate deterministic fixture at 091882a followed the real SDK completion, telemetry, validation, and retry path. Two invalid-response attempts each reserved $0.11; under a $0.15 cap, SDK telemetry recorded $0.22 while the budget ledger finished at zero. A timeout fixture separately reproduced reuse of allowance for uncertain attempted work. These probes establish SDK accounting behavior, without a provider request or billing claim.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:1833 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1887 @ 091882a

  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Limit the bound to standard token-priced text requests. Refuse non-text output modalities, audio options, web_search_options even when empty, and intrinsic search_context_cost_per_query fee metadata. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost. Failed, cancelled, or unknown-cost successful attempts retain at least the reserved bound; a larger recorded delta also counts. Setup or refusal before admission remains uncharged.
-
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/llm/llm.py:1806 · openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

-

The refusal boundary is exercised before the transport: opaque clients, an empty web_search_options object, audio/non-text output options, and a model’s intrinsic search fee all fail admission. These cases do not add fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:367 · tests/sdk/llm/test_cost_budget.py:396 · openhands-sdk/openhands/sdk/llm/llm.py:1866

-

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/llm/call_context.py:29

+
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/llm/llm.py:1806 · openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

+

The refusal boundary is exercised before the transport: opaque clients, an empty web_search_options object, audio/non-text output options, and a model’s intrinsic search fee all fail admission. These cases do not add fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:367 · tests/sdk/llm/test_cost_budget.py:396 · openhands-sdk/openhands/sdk/llm/llm.py:1866

+

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/llm/call_context.py:29

-

04Each top-level run gets a fresh allowance

Source manifest
+

04Each top-level run gets a fresh allowance

Source manifest
Each top-level run receives a fresh allowance; delegates share itA new top-level run resets its owned ledger while lifetime metrics remain cumulative. Delegated children share the ledger and ancestor ceilings. A fork copies the configured limit but owns a separate ledger.ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

-
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
+ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

+
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
-

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
+

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
 index c508fef15..510ac4b9a 100644
 --- a/openhands-sdk/openhands/sdk/conversation/conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/conversation.py
@@ -104,7 +104,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=stuck_detection, stuck_detection_thresholds=stuck_detection_thresholds, visualizer=visualizer, -

✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
 index 4885b35ef..64ad8aff9 100644
 --- a/openhands-sdk/openhands/sdk/conversation/request.py
 +++ b/openhands-sdk/openhands/sdk/conversation/request.py
@@ -128,7 +128,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="If true, the conversation will use stuck detection to " -

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 index 907b700d3..ca4d8b0fd 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
@@ -180,7 +180,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi self.workspace = workspace self._client = workspace.client self._cleanup_initiated = False -

✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
 index ea2d8d332..30b317ac8 100644
 --- a/openhands-agent-server/openhands/agent_server/models.py
 +++ b/openhands-agent-server/openhands/agent_server/models.py
@@ -202,7 +202,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="Whether to enable stuck detection for the agent.", -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
 index e7df77d9a..49a2d879f 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py
@@ -214,7 +214,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi title=stored.title, metrics=stored.metrics, created_at=stored.created_at, -

✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
 index 83721a6da..923713fbe 100644
 --- a/openhands-agent-server/openhands/agent_server/event_service.py
 +++ b/openhands-agent-server/openhands/agent_server/event_service.py
@@ -226,7 +226,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=self.stored.stuck_detection, visualizer=None, secrets=self.stored.secrets, -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
 index 23968b2d8..dc8e39b81 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py
@@ -247,7 +247,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) ) from e -

✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
+
✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
 index bfd1b6eec..f887053d8 100644
 --- a/clients/typescript/src/models/conversation.ts
 +++ b/clients/typescript/src/models/conversation.ts
@@ -285,7 +285,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: boolean; workspace: Record<string, unknown>; hook_config?: HookConfig | null; -

✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
+
✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
 index 04091d075..9a61d6257 100644
 --- a/clients/typescript/src/conversation/remote-conversation.ts
 +++ b/clients/typescript/src/conversation/remote-conversation.ts
@@ -340,7 +340,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? this.stuckDetection ?? true, workspace: { type: 'local', working_dir: this.workspace.workingDir }, hook_config: hookConfig ?? null, -

✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
+
✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
 index 8ac58e347..3f3c66fc7 100644
 --- a/clients/typescript/src/conversation/conversation-manager.ts
 +++ b/clients/typescript/src/conversation/conversation-manager.ts
@@ -365,7 +365,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? true, workspace: { type: 'local', working_dir: options.workingDir || '/tmp' }, user_id: options.userId ?? null, -

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
+

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 index 9c56cf0b1..447231fb6 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
@@ -675,7 +675,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi except Exception as e: with self._state: updated_agent_state = dict(self._state.agent_state) -

🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
+
🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
 new file mode 100644
 index 000000000..da9142417
 --- /dev/null
@@ -867,7 +867,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + + +__all__ = ["BudgetReservation", "CostBudget"] -

✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
 index 6c886595e..8dd7cb5d8 100644
 --- a/openhands-sdk/openhands/sdk/llm/call_context.py
 +++ b/openhands-sdk/openhands/sdk/llm/call_context.py
@@ -893,7 +893,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def for_conversation( self, -

✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+269 / −0

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+269 / −0

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
 index 3e304d2ee..cb3ff82b3 100644
 --- a/openhands-sdk/openhands/sdk/llm/llm.py
 +++ b/openhands-sdk/openhands/sdk/llm/llm.py
@@ -1287,7 +1287,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 index da9da3806..8a34d4045 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
@@ -1311,7 +1311,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class LLMContentPolicyViolationError(LLMBadRequestError): """Provider blocked the request/response via its content filter. -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 index 878fced0d..40452d159 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
@@ -1332,7 +1332,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi return True if not isinstance(exception, (RateLimitError, LLMRateLimitError)): return False -

⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
+
⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 index 218c93a05..4e9123725 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
@@ -1352,7 +1352,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi "UserCancelledError", "OperationCancelled", # Helpers -

✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
 index 8fcc9b9bb..83ebab66d 100644
 --- a/openhands-sdk/openhands/sdk/conversation/exceptions.py
 +++ b/openhands-sdk/openhands/sdk/conversation/exceptions.py
@@ -1375,7 +1375,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class WebSocketConnectionError(RuntimeError): """Raised when WebSocket connection fails to establish within the timeout.""" -

✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
 index ed7741759..aa0b9785f 100644
 --- a/openhands-sdk/openhands/sdk/conversation/state.py
 +++ b/openhands-sdk/openhands/sdk/conversation/state.py
@@ -1396,7 +1396,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def owned(self) -> bool: """ Return True if the lock is currently held by the calling thread. -

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
+

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
 index ee6aef487..f74030443 100644
 --- a/clients/typescript/src/__tests__/api-clients.test.ts
 +++ b/clients/typescript/src/__tests__/api-clients.test.ts
@@ -1447,7 +1447,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi it('HttpClient can parse blob responses when requested', async () => { global.fetch = vi.fn().mockResolvedValue( new Response(new Blob(['zip-data']), { -

✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
+
✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
 index eff6ce516..26687efd9 100644
 --- a/tests/agent_server/test_conversation_router.py
 +++ b/tests/agent_server/test_conversation_router.py
@@ -1490,7 +1490,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_start_conversation_merges_observability_headers( client, mock_conversation_service, sample_conversation_info ): -

✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
+
✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
 index 54824cde2..56394feba 100644
 --- a/tests/agent_server/test_conversation_service.py
 +++ b/tests/agent_server/test_conversation_service.py
@@ -1614,7 +1614,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @pytest.mark.asyncio async def test_agent_tool_from_unimportable_module_rejects_create(tmp_path): """The create fails with a client error instead of every message failing.""" -

✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
+
✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
 index dc1245e3e..44d2228b1 100644
 --- a/tests/cross/test_remote_conversation_live_server.py
 +++ b/tests/cross/test_remote_conversation_live_server.py
@@ -1654,7 +1654,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_openai_chat_completions_gateway_over_real_server( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patched_llm ): -

✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
+
✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
 index 807f19a89..4b6ba77f1 100644
 --- a/tests/sdk/conversation/local/test_agent_status_transition.py
 +++ b/tests/sdk/conversation/local/test_agent_status_transition.py
@@ -1722,7 +1722,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi conversation.run() assert conversation.state.execution_status == ConversationExecutionStatus.ERROR -

🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
+
🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
 new file mode 100644
 index 000000000..eed344f1d
 --- /dev/null
@@ -1905,7 +1905,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + delete_on_close=False, + ) + assert not (persist / cid.hex / "base_state.json").exists() -

✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
+
✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
 index b9b93141c..e6d326e8e 100644
 --- a/tests/sdk/conversation/local/test_fork.py
 +++ b/tests/sdk/conversation/local/test_fork.py
@@ -1936,7 +1936,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_fork_copies_agent_state(): """agent_state dict should be carried over to the fork.""" with tempfile.TemporaryDirectory() as tmpdir: -

✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
+
✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
 index 16457eb93..075574f3a 100644
 --- a/tests/sdk/conversation/local/test_state_serialization.py
 +++ b/tests/sdk/conversation/local/test_state_serialization.py
@@ -1991,7 +1991,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_resume_with_conversation_id_mismatch_raises_error(): """Test that resuming with mismatched conversation ID raises error.""" with tempfile.TemporaryDirectory() as temp_dir: -

✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
+
✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
 index 86914e25e..5b48fe9b7 100644
 --- a/tests/sdk/conversation/remote/test_remote_conversation.py
 +++ b/tests/sdk/conversation/remote/test_remote_conversation.py
@@ -2018,7 +2018,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @patch( "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient" ) -

✏️ changedtests/sdk/llm/test_budget_denial_http.py+117 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
+
✏️ changedtests/sdk/llm/test_budget_denial_http.py+117 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
 index 5f626dded..0d1ebce91 100644
 --- a/tests/sdk/llm/test_budget_denial_http.py
 +++ b/tests/sdk/llm/test_budget_denial_http.py
@@ -2151,7 +2151,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + assert len(requests) == 1 + assert budget.reserved == 0 + assert 0 < budget.spent <= budget.limit -

🟢 addedtests/sdk/llm/test_cost_budget.py+513 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
+
🟢 addedtests/sdk/llm/test_cost_budget.py+513 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
 new file mode 100644
 index 000000000..f147ae122
 --- /dev/null
@@ -2672,9 +2672,9 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + assert budget.spent == pytest.approx(0.11)

-

06Evidence has an explicit boundary

Source manifest
-

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:113 · tests/sdk/llm/test_cost_budget.py:106 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

-
ValidationObserved result and limit
Final admission repair
94039bc
110 final focused tests passed with no failures or skips. The 110 cases comprise 42 cost cases, 62 real loopback HTTP cases, and six local conversation budget cases. The five-file checks and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:155 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Broader pre-final run1,698 of 1,709 passed; 11 Kimi image/model tests failed with the forced packaged metadata catalog. This run preceded the final client/audio/search refusal additions and is not a wholly green final-head run.
Baseline and recheckAll 11 metadata-related failures reproduced at 091882a under the same forced catalog. All 11 passed on current code with ordinary latest public LiteLLM metadata. No image/vision product change was made.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
+

06Evidence has an explicit boundary

Source manifest
+

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:113 · tests/sdk/llm/test_cost_budget.py:106 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

+
ValidationObserved result and limit
Final admission repair
94039bc
110 final focused tests passed with no failures or skips. The 110 cases comprise 42 cost cases, 62 real loopback HTTP cases, and six local conversation budget cases. The five-file checks and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:155 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Broader pre-final run1,698 of 1,709 passed; 11 Kimi image/model tests failed with the forced packaged metadata catalog. This run preceded the final client/audio/search refusal additions and is not a wholly green final-head run.
Baseline and recheckAll 11 metadata-related failures reproduced at 091882a under the same forced catalog. All 11 passed on current code with ordinary latest public LiteLLM metadata. No image/vision product change was made.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
Commands and result provenance

Sanitized verification summaries and XML result counts recorded on 18 October 2026; raw local logs and profile data are absent from this portable page. Final focused command scope:

pytest tests/sdk/llm/test_cost_budget.py \
   tests/sdk/llm/test_budget_denial_http.py \
   tests/sdk/conversation/local/test_cost_budget_enforcement.py -q

Prior contract validation at 091882a also included:

pytest tests/sdk/llm tests/sdk/conversation \
@@ -2686,5 +2686,5 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi npm --script-shell=/bin/sh run test:coverage -- --maxWorkers=1 npm --script-shell=/bin/sh run format:check

That earlier broad run reported 2,437 passed, one existing skip, and two environment-path failures; both passed in an environment-only 11-test rerun. It is historical evidence, not a wholly green final-head run.

ⓘ Integration boundary: this SDK PR adds the run cap, not a Canvas budget meter. A meter needs matching current-run cost reporting and frontend integration; cumulative lifetime cost and a legacy task-budget property do not express this run's remaining allowance.
-

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

+

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: 94039bc709d2b54e6c622d1444e7f2d0722348d5. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file From dceb668511cb6ccbc9e16dd3320b1a5d2019144b Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 07:11:49 +0200 Subject: [PATCH 11/24] fix(budget): distinguish optional search pricing from search work Allow plain function calls for resolved ordinary OpenAI routes that publish optional search prices. Keep intrinsic and unresolved search fees closed, and refuse actual prepared server-native tools before budgeted requests. Exercise current public metadata through all four SDK wrappers without changing production catalog configuration or unbudgeted behavior. Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/llm.py | 25 ++++- .../openhands/sdk/llm/utils/model_features.py | 38 ++++++++ .../fixtures/optional-search-model-info.json | 35 +++++++ tests/sdk/llm/test_cost_budget.py | 97 +++++++++++++++++++ 4 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 tests/sdk/llm/fixtures/optional-search-model-info.json diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index cb3ff82b3f6..98eee449198 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -158,7 +158,11 @@ from openhands.sdk.llm.utils.image_resize import maybe_resize_messages_for_provider from openhands.sdk.llm.utils.litellm_provider import LLMProvider from openhands.sdk.llm.utils.metrics import Metrics -from openhands.sdk.llm.utils.model_features import ModelFeatures, get_features +from openhands.sdk.llm.utils.model_features import ( + ModelFeatures, + get_features, + has_unbounded_search_cost, +) from openhands.sdk.llm.utils.openhands_provider import ( LiteLLMCallKwargs, canonicalize_openhands_llm_payload, @@ -1860,11 +1864,20 @@ def _reserve_budget( request_kwargs.get("audio") is not None or request_kwargs.get("modalities") not in (None, ["text"]) or "web_search_options" in request_kwargs - or (self._model_info or {}).get("search_context_cost_per_query") is not None + or has_unbounded_search_cost( + self.model, + canonical_model=self.model_canonical_name, + model_info=self._model_info, + ) ): raise self._budget_bound_error( "non-token audio/search request costs are unsupported" ) + if any( + not isinstance(tool, Mapping) or tool.get("type") != "function" + for tool in request_kwargs.get("tools") or () + ): + raise self._budget_bound_error("native server tools are unsupported") if litellm.num_retries or litellm.model_fallbacks or litellm.drop_params: raise self._budget_bound_error( "global LiteLLM retries/fallbacks/parameter dropping are unsupported" @@ -2340,7 +2353,9 @@ def responses( ) @self._make_retry_decorator() - @self._budget_guarded_attempt(call_context, call_kwargs) + @self._budget_guarded_attempt( + call_context, {"tools": resp_tools, **call_kwargs} + ) def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) @@ -2509,7 +2524,9 @@ async def aresponses( @self._make_retry_decorator() @self._async_hard_timeout_decorator() - @self._budget_guarded_attempt(call_context, call_kwargs) + @self._budget_guarded_attempt( + call_context, {"tools": resp_tools, **call_kwargs} + ) async def _one_attempt( **retry_kwargs: Any, ) -> ResponsesAPIResponse: diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py index 3b40fae0459..ddd898d4315 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py @@ -5,6 +5,7 @@ from typing import Any, Literal from litellm import get_supported_openai_params +from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config from litellm.utils import supports_vision as litellm_supports_vision from openhands.sdk.llm.utils.openhands_provider import OPENHANDS_PROVIDER_PREFIX @@ -127,6 +128,43 @@ def _real_litellm_provider( return provider +def has_unbounded_search_cost( + model: str | None, + *, + canonical_model: str | None = None, + model_info: Mapping[str, Any] | None = None, +) -> bool: + """Identify search fees that a token-only budget cannot safely reserve. + + A search price schedule alone is not proof that an ordinary call searches. + Resolved ordinary OpenAI GPT/o routes can use search optionally; their plain + function calls remain token-priced. Known search-only routes are refused + even when a dated catalog entry omits its search fee. Other fee-bearing + routes stay closed when optional-vs-intrinsic behavior is not established. + """ + info = model_info or {} + catalog_key = info.get("key") + names = ( + model, + canonical_model, + catalog_key if isinstance(catalog_key, str) else None, + ) + for name in names: + normalized = _normalize_model_for_litellm(name) or "" + if model_matches( + normalized, ("gpt-4o-search-preview", "gpt-4o-mini-search-preview") + ) or OpenAIGPT5Config.is_model_gpt_5_search_model(normalized): + return True + if info.get("search_context_cost_per_query") is None: + return False + if _real_litellm_provider(info) != "openai" or not isinstance(catalog_key, str): + return True + resolved_name = (_normalize_model_for_litellm(catalog_key) or "").split("/")[-1] + return "search" in resolved_name or not resolved_name.startswith( + ("gpt-", "o1", "o3", "o4") + ) + + @cache def _normalized_supported_openai_params( model: str | None, provider_hint: str | None = None diff --git a/tests/sdk/llm/fixtures/optional-search-model-info.json b/tests/sdk/llm/fixtures/optional-search-model-info.json new file mode 100644 index 00000000000..feff127e3ae --- /dev/null +++ b/tests/sdk/llm/fixtures/optional-search-model-info.json @@ -0,0 +1,35 @@ +{ + "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json", + "captured_utc": "2026-10-18T05:06:31.228170+00:00", + "catalog_sha256": "fdf7f01ddc8e96a7c792f8fa49960660d59027d125c0d888beb5ad47c09f0e65", + "model_info": { + "key": "gpt-4o-mini", + "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_priority": 1.25e-07, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_batches": 7.5e-08, + "input_cost_per_token_priority": 2.5e-07, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-07, + "output_cost_per_token_batches": 3e-07, + "output_cost_per_token_priority": 1e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "search_context_cost_per_query": { + "search_context_size_high": 0.025, + "search_context_size_low": 0.025, + "search_context_size_medium": 0.025 + }, + "source": "https://developers.openai.com/api/docs/pricing" + } +} diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index f147ae122d4..ec78d5a4442 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -3,6 +3,7 @@ import json import threading from importlib.resources import files +from pathlib import Path from unittest.mock import AsyncMock, Mock import pytest @@ -15,6 +16,7 @@ from openhands.sdk.llm.call_context import LLMCallContext from openhands.sdk.llm.cost_budget import BudgetReservation, CostBudget from openhands.sdk.llm.exceptions import LLMBudgetExceededError +from openhands.sdk.tool.builtins.finish import FinishTool from tests.conftest import create_mock_litellm_response @@ -411,6 +413,101 @@ def test_budget_refuses_non_token_request_costs( providers[0].assert_not_called() +@pytest.mark.parametrize( + "invoke,provider_index", + [ + (LLM.completion, 0), + (LLM.acompletion, 1), + (LLM.responses, 2), + (LLM.aresponses, 3), + ], +) +async def test_budget_allows_plain_function_calls_with_optional_search_prices( + budget_call, invoke, provider_index +): + info, make_llm, messages, providers = budget_call + fixture = Path(__file__).with_name("fixtures") / "optional-search-model-info.json" + info.update(json.loads(fixture.read_text())["model_info"]) + llm = make_llm( + model="openai/gpt-4o-mini", + native_tool_calling=True, + input_cost_per_token=0.0000005, + output_cost_per_token=0.001, + ) + + result = invoke( + llm, + messages, + tools=list(FinishTool.create()), + add_security_risk_prediction=True, + call_context=LLMCallContext(cost_budget=CostBudget(0.2)), + ) + if inspect.isawaitable(result): + result = await result + + assert result.message.content == [TextContent(text="ok")] + providers[provider_index].assert_called_once() + assert providers[provider_index].call_args.kwargs["tools"][0]["type"] == "function" + + +@pytest.mark.parametrize( + "invoke,tool_type", + [(LLM.responses, "web_search_preview"), (LLM.aresponses, "web_search")], +) +async def test_budget_refuses_prepared_native_search_tools( + budget_call, monkeypatch, invoke, tool_type +): + _, make_llm, messages, providers = budget_call + monkeypatch.setattr( + FinishTool, "to_responses_tool", lambda *args, **kwargs: {"type": tool_type} + ) + llm = make_llm() + + with pytest.raises(LLMBudgetExceededError): + result = invoke( + llm, + messages, + tools=list(FinishTool.create()), + call_context=LLMCallContext(cost_budget=CostBudget(0.2)), + ) + if inspect.isawaitable(result): + await result + + for provider in providers: + provider.assert_not_called() + + +@pytest.mark.parametrize( + "model,canonical,key,provider", + [ + ("openai/gpt-4o-mini-search-preview-2025-03-11", None, None, "openai"), + ("litellm_proxy/gpt-5-search-api", None, None, "openai"), + ("openai/generated-alias", "gpt-4o-search-preview", "gpt-4o-mini", "openai"), + ("openai/gpt-4o-search-preview", "gpt-4o-mini", "gpt-4o-mini", "openai"), + ("openai/generated-alias", None, "gpt-4o-search-preview", "openai"), + ("perplexity/sonar", None, "perplexity/sonar", "perplexity"), + ], +) +def test_budget_keeps_intrinsic_or_unresolved_search_fees_closed( + budget_call, model, canonical, key, provider +): + info, make_llm, messages, providers = budget_call + if key is not None: + info.update( + key=key, + litellm_provider=provider, + search_context_cost_per_query={"search_context_size_medium": 0.025}, + ) + llm = make_llm(model=model, model_canonical_name=canonical) + + with pytest.raises(LLMBudgetExceededError): + llm.completion( + messages, call_context=LLMCallContext(cost_budget=CostBudget(0.2)) + ) + + providers[0].assert_not_called() + + @pytest.mark.parametrize( "name,value", [("num_retries", 2), ("model_fallbacks", ["another-model"]), ("drop_params", True)], From 10bcfc30179d2338697377228f78975764dd024c Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 07:14:12 +0200 Subject: [PATCH 12/24] docs(pr): refresh budget review for optional search fix Co-authored-by: openhands --- .pr/budget-design.html | 368 +++++++++++++++++++++++++++++++++-------- 1 file changed, 295 insertions(+), 73 deletions(-) diff --git a/.pr/budget-design.html b/.pr/budget-design.html index f472b195b2f..c492882f71d 100644 --- a/.pr/budget-design.html +++ b/.pr/budget-design.html @@ -4,10 +4,10 @@ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.6 var(--sans)}.wrap{display:grid;grid-template-columns:192px minmax(0,1fr);gap:44px;max-width:1210px;margin:auto;padding:38px 32px 80px}nav{position:sticky;top:30px;align-self:start;font-size:13px}nav h2{font-size:10px;text-transform:uppercase;letter-spacing:.13em;color:var(--muted);margin:0 0 14px}nav ol{list-style:none;margin:0;padding:0;counter-reset:toc}nav li{counter-increment:toc;margin:10px 0}nav li::before{content:counter(toc,decimal-leading-zero) " ";color:var(--subtle);font:11px var(--mono)}nav a{color:var(--fg);text-decoration:none}nav small{display:block;margin-top:24px;color:var(--muted);line-height:1.6}a{color:var(--accent);text-underline-offset:3px}a:hover{color:var(--warn)}header{padding-bottom:24px;border-bottom:1px solid var(--rule)}.eyebrow{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);font-weight:750}h1{font-size:40px;line-height:1.12;letter-spacing:-.04em;margin:13px 0 16px;max-width:760px}.sub{font-size:18px;line-height:1.6;color:var(--muted);max-width:760px;margin:0}.meta{font:11px/1.8 var(--mono);color:var(--muted);margin-top:17px;overflow-wrap:anywhere}h2{font-size:25px;line-height:1.25;letter-spacing:-.025em;margin:0 0 14px}h2 .num{color:var(--subtle);font:13px var(--mono);margin-right:12px}section{padding-top:40px;scroll-margin-top:24px}h3{font-size:17px;line-height:1.35;margin:23px 0 8px}p{margin:10px 0 14px}code{font:.87em var(--mono);padding:2px 4px;background:var(--code-bg);border-radius:3px}pre{font:12px/1.65 var(--mono);padding:16px;background:var(--code-bg);border:1px solid var(--rule);border-radius:5px;overflow:auto;margin:8px 0}pre code{font:inherit;padding:0;background:none}.kw{color:var(--accent);font-weight:700}.com{color:var(--subtle)}.str{color:var(--ok)}.ba{display:grid;grid-template-columns:1fr 1fr;gap:16px}.label{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:750}.callout{margin:22px 0 20px;border-left:3px solid var(--accent);border-radius:0 5px 5px 0;background:var(--accent-soft);padding:13px 16px;font-size:14px}.callout.warn{border-color:var(--warn);background:var(--warn-soft)}.callout.note{border-color:var(--subtle);background:var(--code-bg)}.sources{font-size:12px;color:var(--muted);margin:8px 0 16px;line-height:1.8}.src{font:11px/1.7 var(--mono);text-decoration:none;border-bottom:1px dotted var(--accent);overflow-wrap:anywhere}.sources .src{font-size:10.5px}table{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}th,td{text-align:left;vertical-align:top;padding:10px 11px;border-bottom:1px solid var(--rule)}th{font-size:11px;color:var(--muted);background:var(--code-bg)}td:first-child{width:24%;font-weight:650}.fig{margin:18px 0 10px}.fig svg{display:block;width:100%;height:auto;background:var(--card);border:1px solid var(--rule);border-radius:6px}.fig figcaption{font-size:12px;line-height:1.5;color:var(--muted);margin:8px 2px}details{border:1px solid var(--rule);border-radius:5px;margin:10px 0}summary{cursor:pointer;padding:9px 12px;font-size:12px;color:var(--muted)}details>div{padding:3px 12px 12px}details pre{margin:0;border:0;border-radius:0;font-size:10.5px;max-height:550px}details.manifest{border:0;border-bottom:1px solid var(--rule);border-radius:0;margin:-4px 0 18px}details.manifest summary{padding:5px 0;font-size:11px}details.manifest>div{padding:3px 0 10px}.files summary{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}.files .path{font:11px var(--mono);color:var(--fg);overflow-wrap:anywhere}.files .counts{margin-left:auto;white-space:nowrap;font:10px var(--mono)}.add{color:var(--ok)}.del{color:var(--crit)}.tag{font-size:10px;color:var(--subtle)}.group{margin:25px 0 8px;font-size:13px;font-weight:750}.files p{font-size:12px;color:var(--muted)}footer{border-top:1px solid var(--rule);margin-top:38px;padding-top:16px;color:var(--muted);font-size:11px}.pending{color:var(--warn);font-weight:700}@media(max-width:900px){.wrap{grid-template-columns:1fr;padding:24px 20px 64px;gap:25px}nav{position:static}nav ol{display:flex;flex-wrap:wrap;gap:0 17px}nav li{margin:4px 0}nav small{display:none}h1{font-size:34px}}@media(max-width:600px){.ba{grid-template-columns:1fr}h1{font-size:30px}.sub{font-size:16px}th,td{padding:8px 6px;font-size:12px}.wrap{padding:22px 14px 54px}}@media print{.wrap{display:block;padding:0}nav{display:none}details{break-inside:avoid}h2{break-after:avoid}.fig{break-inside:avoid}a{color:var(--fg)}}
-
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ 94039bc709d2b54e6c622d1444e7f2d0722348d5
30 changed files · +1826 / −20 against merge base · Admission repair included

Source links open commit-pinned files; displayed path:line labels identify the exact lines.

+
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1849 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ dceb668511cb6ccbc9e16dd3320b1a5d2019144b
32 changed files · +2014 / −21 against merge base · Admission repair included

Source links open commit-pinned files; displayed path:line labels identify the exact lines.

-

01The option now crosses the public API

Source manifest
-

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

+

01The option now crosses the public API

Source manifest
+

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

Before · merge base
# Local implementation only
 LocalConversation(..., max_budget_per_run=None)
 
@@ -19,20 +19,20 @@
 
 // TypeScript remote constructor or start option
 { maxBudgetPerRun: 2.5 }
-

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

-
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
+

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

+
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
-

02Reserve before each SDK LLM attempt

Source manifest
+

02Reserve before each SDK LLM attempt

Source manifest
The admission check moves before each SDK LLM attemptBefore: a local run checks accumulated lifetime metrics after a step. After: SDK LLM attempts reserve against one shared ledger before transport, then settle each attempt. Authoritative bounds, transport controls, and conservative settlement are the focused repair described in section 3.LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1924CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2080new gateCostBudget.settle()positive success: cost; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
-

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

-

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

-

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost. After an attempt starts, failed, cancelled, or unknown-cost successful work retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1158 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

+LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1937CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2093new gateCostBudget.settle()positive success: cost; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
+

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1937 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

+

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

+

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost. After an attempt starts, failed, cancelled, or unknown-cost successful work retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1937 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1162 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

-

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

+

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

⚠ The original PR's admission projection at 091882a invented rates for missing price data and a 32,768-token cap for missing limits. A finite fallback is an estimate, not an upper bound; published context tiers can also exceed the selected base rates.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:180 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1765 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1789 @ 091882a

A deterministic probe at 091882a, using packaged LiteLLM metadata for xai/grok-4-fast-reasoning, admitted a $0.1640005 reservation under a $0.20 cap while the published tier arithmetic was $0.2512004. This was metadata arithmetic with no provider request or billed usage.

Reproduction inputs and arithmetic · original PR at 091882a
input tokens:          128,001
@@ -45,22 +45,23 @@
 No credentials, network, or model completion used.
 Metadata snapshot SHA-256:
 cc1955f7f1cdc2cdd9ebf216e7eebbc90bbb3528b62439327f7e8b893eda7c12

Observed against the pre-repair admission helper; the focused regression tests belong with the repair.

-

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1776 · openhands-sdk/openhands/sdk/llm/llm.py:1806 · openhands-sdk/openhands/sdk/llm/llm.py:1845

+

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1780 · openhands-sdk/openhands/sdk/llm/llm.py:1810 · openhands-sdk/openhands/sdk/llm/llm.py:1849

Failed attempts cannot receive a blind refund

A separate deterministic fixture at 091882a followed the real SDK completion, telemetry, validation, and retry path. Two invalid-response attempts each reserved $0.11; under a $0.15 cap, SDK telemetry recorded $0.22 while the budget ledger finished at zero. A timeout fixture separately reproduced reuse of allowance for uncertain attempted work. These probes establish SDK accounting behavior, without a provider request or billing claim.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:1833 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1887 @ 091882a

-
  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Limit the bound to standard token-priced text requests. Refuse non-text output modalities, audio options, web_search_options even when empty, and intrinsic search_context_cost_per_query fee metadata. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost. Failed, cancelled, or unknown-cost successful attempts retain at least the reserved bound; a larger recorded delta also counts. Setup or refusal before admission remains uncharged.
-
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/llm/llm.py:1806 · openhands-sdk/openhands/sdk/llm/llm.py:1924 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

-

The refusal boundary is exercised before the transport: opaque clients, an empty web_search_options object, audio/non-text output options, and a model’s intrinsic search fee all fail admission. These cases do not add fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:367 · tests/sdk/llm/test_cost_budget.py:396 · openhands-sdk/openhands/sdk/llm/llm.py:1866

-

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1845 · openhands-sdk/openhands/sdk/llm/call_context.py:29

+
  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Bound standard token-priced text and function calls. Refuse audio/non-text output, web_search_options even when empty, and prepared non-function/native server tools. A search price schedule alone does not imply search work: resolved ordinary OpenAI GPT/o routes are allowed when no search is requested; intrinsic search and unresolved fee-bearing routes remain closed. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost. Failed, cancelled, or unknown-cost successful attempts retain at least the reserved bound; a larger recorded delta also counts. Setup or refusal before admission remains uncharged.
+
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1849 · openhands-sdk/openhands/sdk/llm/llm.py:1810 · openhands-sdk/openhands/sdk/llm/llm.py:1937 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

+

has_unbounded_search_cost() distinguishes an optional search price schedule from intrinsic search work. Resolved ordinary OpenAI GPT/o routes retain text/function-call support when no search is requested; intrinsic search aliases, raw and dated routes, and unresolved/provider fee routes remain refused. The shared guard also checks prepared tools, including the Responses wire payload, and rejects non-function/native server tools.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:131 · openhands-sdk/openhands/sdk/llm/llm.py:1880 · tests/sdk/llm/test_cost_budget.py:425 · tests/sdk/llm/test_cost_budget.py:457 · tests/sdk/llm/test_cost_budget.py:491 · tests/sdk/llm/fixtures/optional-search-model-info.json:1

+

Opaque clients, an empty web_search_options object, and audio/non-text output options still fail before transport. These cases do not add vendor-fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:369 · tests/sdk/llm/test_cost_budget.py:398

+

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1849 · openhands-sdk/openhands/sdk/llm/call_context.py:29

-

04Each top-level run gets a fresh allowance

Source manifest
+

04Each top-level run gets a fresh allowance

Source manifest
Each top-level run receives a fresh allowance; delegates share itA new top-level run resets its owned ledger while lifetime metrics remain cumulative. Delegated children share the ledger and ancestor ceilings. A fork copies the configured limit but owns a separate ledger.ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

-
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
+ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

+
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
-

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
+

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
 index c508fef15..510ac4b9a 100644
 --- a/openhands-sdk/openhands/sdk/conversation/conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/conversation.py
@@ -104,7 +105,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=stuck_detection, stuck_detection_thresholds=stuck_detection_thresholds, visualizer=visualizer, -

✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
 index 4885b35ef..64ad8aff9 100644
 --- a/openhands-sdk/openhands/sdk/conversation/request.py
 +++ b/openhands-sdk/openhands/sdk/conversation/request.py
@@ -128,7 +129,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="If true, the conversation will use stuck detection to " -

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 index 907b700d3..ca4d8b0fd 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
@@ -180,7 +181,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi self.workspace = workspace self._client = workspace.client self._cleanup_initiated = False -

✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
 index ea2d8d332..30b317ac8 100644
 --- a/openhands-agent-server/openhands/agent_server/models.py
 +++ b/openhands-agent-server/openhands/agent_server/models.py
@@ -202,7 +203,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="Whether to enable stuck detection for the agent.", -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
 index e7df77d9a..49a2d879f 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py
@@ -214,7 +215,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi title=stored.title, metrics=stored.metrics, created_at=stored.created_at, -

✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
 index 83721a6da..923713fbe 100644
 --- a/openhands-agent-server/openhands/agent_server/event_service.py
 +++ b/openhands-agent-server/openhands/agent_server/event_service.py
@@ -226,7 +227,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=self.stored.stuck_detection, visualizer=None, secrets=self.stored.secrets, -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
 index 23968b2d8..dc8e39b81 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py
@@ -247,7 +248,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) ) from e -

✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
+
✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
 index bfd1b6eec..f887053d8 100644
 --- a/clients/typescript/src/models/conversation.ts
 +++ b/clients/typescript/src/models/conversation.ts
@@ -285,7 +286,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: boolean; workspace: Record<string, unknown>; hook_config?: HookConfig | null; -

✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
+
✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
 index 04091d075..9a61d6257 100644
 --- a/clients/typescript/src/conversation/remote-conversation.ts
 +++ b/clients/typescript/src/conversation/remote-conversation.ts
@@ -340,7 +341,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? this.stuckDetection ?? true, workspace: { type: 'local', working_dir: this.workspace.workingDir }, hook_config: hookConfig ?? null, -

✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
+
✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
 index 8ac58e347..3f3c66fc7 100644
 --- a/clients/typescript/src/conversation/conversation-manager.ts
 +++ b/clients/typescript/src/conversation/conversation-manager.ts
@@ -365,7 +366,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? true, workspace: { type: 'local', working_dir: options.workingDir || '/tmp' }, user_id: options.userId ?? null, -

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
+

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 index 9c56cf0b1..447231fb6 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
@@ -675,7 +676,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi except Exception as e: with self._state: updated_agent_state = dict(self._state.agent_state) -

🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
+
🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
 new file mode 100644
 index 000000000..da9142417
 --- /dev/null
@@ -867,7 +868,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + + +__all__ = ["BudgetReservation", "CostBudget"] -

✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
 index 6c886595e..8dd7cb5d8 100644
 --- a/openhands-sdk/openhands/sdk/llm/call_context.py
 +++ b/openhands-sdk/openhands/sdk/llm/call_context.py
@@ -893,8 +894,8 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def for_conversation( self, -

✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+269 / −0

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
-index 3e304d2ee..cb3ff82b3 100644
+
✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+287 / −1

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
+index 3e304d2ee..98eee4491 100644
 --- a/openhands-sdk/openhands/sdk/llm/llm.py
 +++ b/openhands-sdk/openhands/sdk/llm/llm.py
 @@ -2,7 +2,9 @@ from __future__ import annotations
@@ -933,7 +934,20 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi LLMContextWindowTooSmallError, LLMNoResponseError, is_prompt_cache_too_small, -@@ -1749,6 +1755,257 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -152,7 +158,11 @@ from openhands.sdk.llm.utils.image_inline import ( + from openhands.sdk.llm.utils.image_resize import maybe_resize_messages_for_provider + from openhands.sdk.llm.utils.litellm_provider import LLMProvider + from openhands.sdk.llm.utils.metrics import Metrics +-from openhands.sdk.llm.utils.model_features import ModelFeatures, get_features ++from openhands.sdk.llm.utils.model_features import ( ++ ModelFeatures, ++ get_features, ++ has_unbounded_search_cost, ++) + from openhands.sdk.llm.utils.openhands_provider import ( + LiteLLMCallKwargs, + canonicalize_openhands_llm_payload, +@@ -1749,6 +1759,266 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # Chat Completion API # ========================================================================= @@ -1042,11 +1056,20 @@

Failed attempts cannot receive a blind refund

A separate deterministi + request_kwargs.get("audio") is not None + or request_kwargs.get("modalities") not in (None, ["text"]) + or "web_search_options" in request_kwargs -+ or (self._model_info or {}).get("search_context_cost_per_query") is not None ++ or has_unbounded_search_cost( ++ self.model, ++ canonical_model=self.model_canonical_name, ++ model_info=self._model_info, ++ ) + ): + raise self._budget_bound_error( + "non-token audio/search request costs are unsupported" + ) ++ if any( ++ not isinstance(tool, Mapping) or tool.get("type") != "function" ++ for tool in request_kwargs.get("tools") or () ++ ): ++ raise self._budget_bound_error("native server tools are unsupported") + if litellm.num_retries or litellm.model_fallbacks or litellm.drop_params: + raise self._budget_bound_error( + "global LiteLLM retries/fallbacks/parameter dropping are unsupported" @@ -1191,7 +1214,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def completion( self, messages: list[Message], -@@ -1785,6 +2042,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1785,6 +2055,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # no-op for providers without runtime metadata. self.resolve_runtime_metadata() @@ -1199,7 +1222,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() enable_streaming = bool(kwargs.get("stream", False)) or self.stream if enable_streaming and on_token is None: -@@ -1814,6 +2072,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1814,6 +2085,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): ) @self._make_retry_decorator() @@ -1207,7 +1230,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) -@@ -1850,6 +2109,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1850,6 +2122,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): tools, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1215,7 +1238,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -@@ -1904,6 +2164,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1904,6 +2177,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # calls are cheap. await self.aresolve_runtime_metadata() @@ -1223,7 +1246,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() enable_streaming = bool(kwargs.get("stream", False)) or self.stream if enable_streaming and on_token is None: -@@ -1934,6 +2195,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1934,6 +2208,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): @self._make_retry_decorator() @self._async_hard_timeout_decorator() @@ -1231,7 +1254,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi async def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) -@@ -1970,6 +2232,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1970,6 +2245,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): tools, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1239,7 +1262,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -@@ -2043,6 +2306,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2043,6 +2319,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # metadata. self.resolve_runtime_metadata() @@ -1247,15 +1270,17 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream if user_enable_streaming and on_token is None and not self.requires_streaming: -@@ -2076,6 +2340,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2076,6 +2353,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): ) @self._make_retry_decorator() -+ @self._budget_guarded_attempt(call_context, call_kwargs) ++ @self._budget_guarded_attempt( ++ call_context, {"tools": resp_tools, **call_kwargs} ++ ) def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) -@@ -2151,6 +2416,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2151,6 +2431,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): store, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1263,7 +1288,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -@@ -2208,6 +2474,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2208,6 +2489,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # limits. No blocking network I/O in-process. await self.aresolve_runtime_metadata() @@ -1271,15 +1296,17 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream if user_enable_streaming and on_token is None and not self.requires_streaming: -@@ -2242,6 +2509,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2242,6 +2524,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): @self._make_retry_decorator() @self._async_hard_timeout_decorator() -+ @self._budget_guarded_attempt(call_context, call_kwargs) ++ @self._budget_guarded_attempt( ++ call_context, {"tools": resp_tools, **call_kwargs} ++ ) async def _one_attempt( **retry_kwargs: Any, ) -> ResponsesAPIResponse: -@@ -2343,6 +2611,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2343,6 +2628,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): store, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1287,7 +1314,63 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/utils/model_features.py+38 / −0

Distinguishes optional search pricing on resolved ordinary OpenAI GPT/o routes from intrinsic or unresolved search fees.

openhands-sdk/openhands/sdk/llm/utils/model_features.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
+index 3b40fae04..ddd898d43 100644
+--- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py
++++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
+@@ -5,6 +5,7 @@ from functools import cache
+ from typing import Any, Literal
+ 
+ from litellm import get_supported_openai_params
++from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
+ from litellm.utils import supports_vision as litellm_supports_vision
+ 
+ from openhands.sdk.llm.utils.openhands_provider import OPENHANDS_PROVIDER_PREFIX
+@@ -127,6 +128,43 @@ def _real_litellm_provider(
+     return provider
+ 
+ 
++def has_unbounded_search_cost(
++    model: str | None,
++    *,
++    canonical_model: str | None = None,
++    model_info: Mapping[str, Any] | None = None,
++) -> bool:
++    """Identify search fees that a token-only budget cannot safely reserve.
++
++    A search price schedule alone is not proof that an ordinary call searches.
++    Resolved ordinary OpenAI GPT/o routes can use search optionally; their plain
++    function calls remain token-priced. Known search-only routes are refused
++    even when a dated catalog entry omits its search fee. Other fee-bearing
++    routes stay closed when optional-vs-intrinsic behavior is not established.
++    """
++    info = model_info or {}
++    catalog_key = info.get("key")
++    names = (
++        model,
++        canonical_model,
++        catalog_key if isinstance(catalog_key, str) else None,
++    )
++    for name in names:
++        normalized = _normalize_model_for_litellm(name) or ""
++        if model_matches(
++            normalized, ("gpt-4o-search-preview", "gpt-4o-mini-search-preview")
++        ) or OpenAIGPT5Config.is_model_gpt_5_search_model(normalized):
++            return True
++    if info.get("search_context_cost_per_query") is None:
++        return False
++    if _real_litellm_provider(info) != "openai" or not isinstance(catalog_key, str):
++        return True
++    resolved_name = (_normalize_model_for_litellm(catalog_key) or "").split("/")[-1]
++    return "search" in resolved_name or not resolved_name.startswith(
++        ("gpt-", "o1", "o3", "o4")
++    )
++
++
+ @cache
+ def _normalized_supported_openai_params(
+     model: str | None, provider_hint: str | None = None
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 index da9da3806..8a34d4045 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
@@ -1311,7 +1394,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class LLMContentPolicyViolationError(LLMBadRequestError): """Provider blocked the request/response via its content filter. -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 index 878fced0d..40452d159 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
@@ -1332,7 +1415,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi return True if not isinstance(exception, (RateLimitError, LLMRateLimitError)): return False -

⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
+
⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 index 218c93a05..4e9123725 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
@@ -1352,7 +1435,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi "UserCancelledError", "OperationCancelled", # Helpers -

✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
 index 8fcc9b9bb..83ebab66d 100644
 --- a/openhands-sdk/openhands/sdk/conversation/exceptions.py
 +++ b/openhands-sdk/openhands/sdk/conversation/exceptions.py
@@ -1375,7 +1458,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class WebSocketConnectionError(RuntimeError): """Raised when WebSocket connection fails to establish within the timeout.""" -

✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
 index ed7741759..aa0b9785f 100644
 --- a/openhands-sdk/openhands/sdk/conversation/state.py
 +++ b/openhands-sdk/openhands/sdk/conversation/state.py
@@ -1396,7 +1479,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def owned(self) -> bool: """ Return True if the lock is currently held by the calling thread. -

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
+

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
 index ee6aef487..f74030443 100644
 --- a/clients/typescript/src/__tests__/api-clients.test.ts
 +++ b/clients/typescript/src/__tests__/api-clients.test.ts
@@ -1447,7 +1530,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi it('HttpClient can parse blob responses when requested', async () => { global.fetch = vi.fn().mockResolvedValue( new Response(new Blob(['zip-data']), { -

✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
+
✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
 index eff6ce516..26687efd9 100644
 --- a/tests/agent_server/test_conversation_router.py
 +++ b/tests/agent_server/test_conversation_router.py
@@ -1490,7 +1573,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_start_conversation_merges_observability_headers( client, mock_conversation_service, sample_conversation_info ): -

✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
+
✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
 index 54824cde2..56394feba 100644
 --- a/tests/agent_server/test_conversation_service.py
 +++ b/tests/agent_server/test_conversation_service.py
@@ -1614,7 +1697,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @pytest.mark.asyncio async def test_agent_tool_from_unimportable_module_rejects_create(tmp_path): """The create fails with a client error instead of every message failing.""" -

✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
+
✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
 index dc1245e3e..44d2228b1 100644
 --- a/tests/cross/test_remote_conversation_live_server.py
 +++ b/tests/cross/test_remote_conversation_live_server.py
@@ -1654,7 +1737,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_openai_chat_completions_gateway_over_real_server( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patched_llm ): -

✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
+
✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
 index 807f19a89..4b6ba77f1 100644
 --- a/tests/sdk/conversation/local/test_agent_status_transition.py
 +++ b/tests/sdk/conversation/local/test_agent_status_transition.py
@@ -1722,7 +1805,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi conversation.run() assert conversation.state.execution_status == ConversationExecutionStatus.ERROR -

🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
+
🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
 new file mode 100644
 index 000000000..eed344f1d
 --- /dev/null
@@ -1905,7 +1988,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + delete_on_close=False, + ) + assert not (persist / cid.hex / "base_state.json").exists() -

✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
+
✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
 index b9b93141c..e6d326e8e 100644
 --- a/tests/sdk/conversation/local/test_fork.py
 +++ b/tests/sdk/conversation/local/test_fork.py
@@ -1936,7 +2019,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_fork_copies_agent_state(): """agent_state dict should be carried over to the fork.""" with tempfile.TemporaryDirectory() as tmpdir: -

✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
+
✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
 index 16457eb93..075574f3a 100644
 --- a/tests/sdk/conversation/local/test_state_serialization.py
 +++ b/tests/sdk/conversation/local/test_state_serialization.py
@@ -1991,7 +2074,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_resume_with_conversation_id_mismatch_raises_error(): """Test that resuming with mismatched conversation ID raises error.""" with tempfile.TemporaryDirectory() as temp_dir: -

✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
+
✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
 index 86914e25e..5b48fe9b7 100644
 --- a/tests/sdk/conversation/remote/test_remote_conversation.py
 +++ b/tests/sdk/conversation/remote/test_remote_conversation.py
@@ -2018,7 +2101,48 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @patch( "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient" ) -

✏️ changedtests/sdk/llm/test_budget_denial_http.py+117 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
+
🟢 addedtests/sdk/llm/fixtures/optional-search-model-info.json+35 / −0

Captures public gpt-4o-mini model metadata with optional search pricing for deterministic function-call regression coverage.

tests/sdk/llm/fixtures/optional-search-model-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-model-info.json b/tests/sdk/llm/fixtures/optional-search-model-info.json
+new file mode 100644
+index 000000000..feff127e3
+--- /dev/null
++++ b/tests/sdk/llm/fixtures/optional-search-model-info.json
+@@ -0,0 +1,35 @@
++{
++  "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
++  "captured_utc": "2026-10-18T05:06:31.228170+00:00",
++  "catalog_sha256": "fdf7f01ddc8e96a7c792f8fa49960660d59027d125c0d888beb5ad47c09f0e65",
++  "model_info": {
++    "key": "gpt-4o-mini",
++    "cache_read_input_token_cost": 7.5e-08,
++    "cache_read_input_token_cost_priority": 1.25e-07,
++    "input_cost_per_token": 1.5e-07,
++    "input_cost_per_token_batches": 7.5e-08,
++    "input_cost_per_token_priority": 2.5e-07,
++    "litellm_provider": "openai",
++    "max_input_tokens": 128000,
++    "max_output_tokens": 16384,
++    "max_tokens": 16384,
++    "mode": "chat",
++    "output_cost_per_token": 6e-07,
++    "output_cost_per_token_batches": 3e-07,
++    "output_cost_per_token_priority": 1e-06,
++    "supports_function_calling": true,
++    "supports_parallel_function_calling": true,
++    "supports_pdf_input": true,
++    "supports_prompt_caching": true,
++    "supports_response_schema": true,
++    "supports_system_messages": true,
++    "supports_tool_choice": true,
++    "supports_vision": true,
++    "search_context_cost_per_query": {
++      "search_context_size_high": 0.025,
++      "search_context_size_low": 0.025,
++      "search_context_size_medium": 0.025
++    },
++    "source": "https://developers.openai.com/api/docs/pricing"
++  }
++}
+
✏️ changedtests/sdk/llm/test_budget_denial_http.py+117 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
 index 5f626dded..0d1ebce91 100644
 --- a/tests/sdk/llm/test_budget_denial_http.py
 +++ b/tests/sdk/llm/test_budget_denial_http.py
@@ -2151,17 +2275,18 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + assert len(requests) == 1 + assert budget.reserved == 0 + assert 0 < budget.spent <= budget.limit -

🟢 addedtests/sdk/llm/test_cost_budget.py+513 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
+
🟢 addedtests/sdk/llm/test_cost_budget.py+610 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
 new file mode 100644
-index 000000000..f147ae122
+index 000000000..ec78d5a44
 --- /dev/null
 +++ b/tests/sdk/llm/test_cost_budget.py
-@@ -0,0 +1,513 @@
+@@ -0,0 +1,610 @@
 +import asyncio
 +import inspect
 +import json
 +import threading
 +from importlib.resources import files
++from pathlib import Path
 +from unittest.mock import AsyncMock, Mock
 +
 +import pytest
@@ -2174,6 +2299,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi +from openhands.sdk.llm.call_context import LLMCallContext +from openhands.sdk.llm.cost_budget import BudgetReservation, CostBudget +from openhands.sdk.llm.exceptions import LLMBudgetExceededError ++from openhands.sdk.tool.builtins.finish import FinishTool +from tests.conftest import create_mock_litellm_response + + @@ -2571,6 +2697,101 @@

Failed attempts cannot receive a blind refund

A separate deterministi + + +@pytest.mark.parametrize( ++ "invoke,provider_index", ++ [ ++ (LLM.completion, 0), ++ (LLM.acompletion, 1), ++ (LLM.responses, 2), ++ (LLM.aresponses, 3), ++ ], ++) ++async def test_budget_allows_plain_function_calls_with_optional_search_prices( ++ budget_call, invoke, provider_index ++): ++ info, make_llm, messages, providers = budget_call ++ fixture = Path(__file__).with_name("fixtures") / "optional-search-model-info.json" ++ info.update(json.loads(fixture.read_text())["model_info"]) ++ llm = make_llm( ++ model="openai/gpt-4o-mini", ++ native_tool_calling=True, ++ input_cost_per_token=0.0000005, ++ output_cost_per_token=0.001, ++ ) ++ ++ result = invoke( ++ llm, ++ messages, ++ tools=list(FinishTool.create()), ++ add_security_risk_prediction=True, ++ call_context=LLMCallContext(cost_budget=CostBudget(0.2)), ++ ) ++ if inspect.isawaitable(result): ++ result = await result ++ ++ assert result.message.content == [TextContent(text="ok")] ++ providers[provider_index].assert_called_once() ++ assert providers[provider_index].call_args.kwargs["tools"][0]["type"] == "function" ++ ++ ++@pytest.mark.parametrize( ++ "invoke,tool_type", ++ [(LLM.responses, "web_search_preview"), (LLM.aresponses, "web_search")], ++) ++async def test_budget_refuses_prepared_native_search_tools( ++ budget_call, monkeypatch, invoke, tool_type ++): ++ _, make_llm, messages, providers = budget_call ++ monkeypatch.setattr( ++ FinishTool, "to_responses_tool", lambda *args, **kwargs: {"type": tool_type} ++ ) ++ llm = make_llm() ++ ++ with pytest.raises(LLMBudgetExceededError): ++ result = invoke( ++ llm, ++ messages, ++ tools=list(FinishTool.create()), ++ call_context=LLMCallContext(cost_budget=CostBudget(0.2)), ++ ) ++ if inspect.isawaitable(result): ++ await result ++ ++ for provider in providers: ++ provider.assert_not_called() ++ ++ ++@pytest.mark.parametrize( ++ "model,canonical,key,provider", ++ [ ++ ("openai/gpt-4o-mini-search-preview-2025-03-11", None, None, "openai"), ++ ("litellm_proxy/gpt-5-search-api", None, None, "openai"), ++ ("openai/generated-alias", "gpt-4o-search-preview", "gpt-4o-mini", "openai"), ++ ("openai/gpt-4o-search-preview", "gpt-4o-mini", "gpt-4o-mini", "openai"), ++ ("openai/generated-alias", None, "gpt-4o-search-preview", "openai"), ++ ("perplexity/sonar", None, "perplexity/sonar", "perplexity"), ++ ], ++) ++def test_budget_keeps_intrinsic_or_unresolved_search_fees_closed( ++ budget_call, model, canonical, key, provider ++): ++ info, make_llm, messages, providers = budget_call ++ if key is not None: ++ info.update( ++ key=key, ++ litellm_provider=provider, ++ search_context_cost_per_query={"search_context_size_medium": 0.025}, ++ ) ++ llm = make_llm(model=model, model_canonical_name=canonical) ++ ++ with pytest.raises(LLMBudgetExceededError): ++ llm.completion( ++ messages, call_context=LLMCallContext(cost_budget=CostBudget(0.2)) ++ ) ++ ++ providers[0].assert_not_called() ++ ++ ++@pytest.mark.parametrize( + "name,value", + [("num_retries", 2), ("model_fallbacks", ["another-model"]), ("drop_params", True)], +) @@ -2672,9 +2893,10 @@

Failed attempts cannot receive a blind refund

A separate deterministi + assert budget.spent == pytest.approx(0.11)

-

06Evidence has an explicit boundary

Source manifest
-

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:113 · tests/sdk/llm/test_cost_budget.py:106 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

-
ValidationObserved result and limit
Final admission repair
94039bc
110 final focused tests passed with no failures or skips. The 110 cases comprise 42 cost cases, 62 real loopback HTTP cases, and six local conversation budget cases. The five-file checks and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:155 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Broader pre-final run1,698 of 1,709 passed; 11 Kimi image/model tests failed with the forced packaged metadata catalog. This run preceded the final client/audio/search refusal additions and is not a wholly green final-head run.
Baseline and recheckAll 11 metadata-related failures reproduced at 091882a under the same forced catalog. All 11 passed on current code with ordinary latest public LiteLLM metadata. No image/vision product change was made.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
+

06Evidence has an explicit boundary

Source manifest
+

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:115 · tests/sdk/llm/test_cost_budget.py:108 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

+
ValidationObserved result and limit
Final focused run
dceb668
122 final focused tests passed with no failures or skips. The 122 cases comprise 54 cost cases, 62 real loopback HTTP cases, and six local conversation budget cases. Edited-file and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:155 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Final broader run1,725 LLM and local/remote conversation tests passed with ordinary current public LiteLLM metadata, with no failures or skips.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
+
Historical forced-catalog diagnosis

An earlier pre-final run passed 1,698 of 1,709 cases; 11 Kimi image/model tests failed with forced packaged metadata. All 11 failures reproduced at 091882a under that catalog and passed on the repaired code with ordinary public metadata. These are historical environment/catalog results; no image/vision product patch was made. The final ordinary-metadata broader run above passed all 1,725 cases.

Commands and result provenance

Sanitized verification summaries and XML result counts recorded on 18 October 2026; raw local logs and profile data are absent from this portable page. Final focused command scope:

pytest tests/sdk/llm/test_cost_budget.py \
   tests/sdk/llm/test_budget_denial_http.py \
   tests/sdk/conversation/local/test_cost_budget_enforcement.py -q

Prior contract validation at 091882a also included:

pytest tests/sdk/llm tests/sdk/conversation \
@@ -2686,5 +2908,5 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi npm --script-shell=/bin/sh run test:coverage -- --maxWorkers=1 npm --script-shell=/bin/sh run format:check

That earlier broad run reported 2,437 passed, one existing skip, and two environment-path failures; both passed in an environment-only 11-test rerun. It is historical evidence, not a wholly green final-head run.

ⓘ Integration boundary: this SDK PR adds the run cap, not a Canvas budget meter. A meter needs matching current-run cost reporting and frontend integration; cumulative lifetime cost and a legacy task-budget property do not express this run's remaining allowance.
-

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

-
Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: 94039bc709d2b54e6c622d1444e7f2d0722348d5. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file +

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

+
Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: dceb668511cb6ccbc9e16dd3320b1a5d2019144b. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file From 322790065776568285eb852ea36cbb260a3df47e Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 19:59:42 +0200 Subject: [PATCH 13/24] fix(budget): support ordinary provider calls safely Treat optional search prices as a capability of resolved ordinary chat routes, while refusing intrinsic search, legacy hosted tools, and opaque provider caches. Include known regional endpoint premiums in reservations and retain their bound when recorded costs cannot prove complete pricing. Refuse unresolved Vertex Claude endpoint prices unless paired custom token prices override them. Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/llm.py | 52 ++- .../openhands/sdk/llm/utils/model_features.py | 48 ++- .../optional-search-provider-info.json | 330 ++++++++++++++++++ tests/sdk/llm/test_budget_denial_http.py | 75 ++++ tests/sdk/llm/test_cost_budget.py | 273 ++++++++++++++- 5 files changed, 755 insertions(+), 23 deletions(-) create mode 100644 tests/sdk/llm/fixtures/optional-search-provider-info.json diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index 98eee449198..49069f94c36 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -162,6 +162,7 @@ ModelFeatures, get_features, has_unbounded_search_cost, + has_unresolved_regional_endpoint_pricing, ) from openhands.sdk.llm.utils.openhands_provider import ( LiteLLMCallKwargs, @@ -1823,6 +1824,8 @@ def _cost_per_token_bounds(self) -> tuple[float, float]: self._budget_price(self.output_cost_per_token), ) info = self._model_info or {} + if has_unresolved_regional_endpoint_pricing(info): + raise self._budget_bound_error("unknown regional endpoint premium") input_costs = [self._budget_price(info.get("input_cost_per_token"))] output_costs = [self._budget_price(info.get("output_cost_per_token"))] uplifts = [1.0] @@ -1841,7 +1844,12 @@ def _cost_per_token_bounds(self) -> tuple[float, float]: ("output_cost_per_token", "output_cost_per_reasoning_token") ): output_costs.append(self._budget_price(value)) - elif key.startswith("regional_processing_uplift_multiplier"): + elif key.startswith( + ( + "regional_processing_uplift_multiplier", + "regional_endpoint_uplift_multiplier", + ) + ): uplifts.append(self._budget_price(value)) uplift = max(uplifts) return max(input_costs) * uplift, max(output_costs) * uplift @@ -1876,8 +1884,13 @@ def _reserve_budget( if any( not isinstance(tool, Mapping) or tool.get("type") != "function" for tool in request_kwargs.get("tools") or () - ): - raise self._budget_bound_error("native server tools are unsupported") + ) or request_kwargs.get("functions"): + # LiteLLM's legacy functions parameter also accepts native tools. + raise self._budget_bound_error("native/legacy server tools are unsupported") + if request_kwargs.get("cached_content") is not None: + # Provider-stored caches may contain hosted tools that are not + # visible in this prepared request, in addition to hidden tokens. + raise self._budget_bound_error("opaque provider caches are unsupported") if litellm.num_retries or litellm.model_fallbacks or litellm.drop_params: raise self._budget_bound_error( "global LiteLLM retries/fallbacks/parameter dropping are unsupported" @@ -1943,9 +1956,10 @@ def _budget_guarded_attempt( Failed/cancelled attempts retain their bound because the provider may have processed work without returning complete usage. Successful calls - settle to recorded positive cost; absent cost telemetry retains the - bound too. This ledger is budget usage, not a provider billing record. - Unbudgeted calls keep their existing transport options and behavior. + settle to recorded positive cost when its pricing is complete; absent + or endpoint-premium-unaware telemetry retains the bound too. This ledger + is budget usage, not a provider billing record. Unbudgeted calls keep + their existing transport options and behavior. """ def decorate(attempt: Callable[..., Any]) -> Callable[..., Any]: @@ -1977,7 +1991,7 @@ async def _reserved_async(*args: Any, **kwargs: Any) -> Any: raise cost = self._cost_delta(cost_before) reservation.budget.settle( - reservation, cost if cost > 0 else reservation.amount + reservation, self._budget_success_cost(reservation, cost) ) return result @@ -2007,7 +2021,7 @@ def _reserved_sync(*args: Any, **kwargs: Any) -> Any: raise cost = self._cost_delta(cost_before) reservation.budget.settle( - reservation, cost if cost > 0 else reservation.amount + reservation, self._budget_success_cost(reservation, cost) ) return result @@ -2019,6 +2033,28 @@ def _cost_delta(self, cost_before: float) -> float: """Cost added to this LLM's own metrics by the call just completed.""" return max(0.0, self.metrics.accumulated_cost - cost_before) + def _budget_success_cost( + self, reservation: BudgetReservation, cost: float + ) -> float: + """Keep the bound when recorded cost cannot establish complete pricing.""" + uses_custom_prices = ( + self.input_cost_per_token is not None + and self.output_cost_per_token is not None + ) + # LiteLLM's calculated token cost does not establish that a known + # regional endpoint premium was included. Keep the inclusive bound + # without changing the lifetime metrics displayed to callers. Paired + # custom prices override the published rates in both paths. + unverified_endpoint_premium = not uses_custom_prices and any( + key.startswith("regional_endpoint_uplift_multiplier") + and value is not None + and self._budget_price(value) > 1 + for key, value in (self._model_info or {}).items() + ) + if cost <= 0 or unverified_endpoint_premium: + return max(reservation.amount, cost) + return cost + def completion( self, messages: list[Message], diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py index ddd898d4315..3c5b5787d32 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py @@ -128,6 +128,21 @@ def _real_litellm_provider( return provider +def has_unresolved_regional_endpoint_pricing( + model_info: Mapping[str, Any] | None, +) -> bool: + """Vertex Claude needs a known premium to bound regional endpoint cost. + + Older LiteLLM catalogs carry token prices but omit this provider's known + endpoint premium. Their rates alone cannot establish a safe ceiling. + """ + info = model_info or {} + return _real_litellm_provider(info) == "vertex_ai-anthropic_models" and not any( + key.startswith("regional_endpoint_uplift_multiplier") and value is not None + for key, value in info.items() + ) + + def has_unbounded_search_cost( model: str | None, *, @@ -137,10 +152,11 @@ def has_unbounded_search_cost( """Identify search fees that a token-only budget cannot safely reserve. A search price schedule alone is not proof that an ordinary call searches. - Resolved ordinary OpenAI GPT/o routes can use search optionally; their plain - function calls remain token-priced. Known search-only routes are refused - even when a dated catalog entry omits its search fee. Other fee-bearing - routes stay closed when optional-vs-intrinsic behavior is not established. + Ordinary GPT/o, Claude and Gemini chat routes search only when a request + enables a hosted tool. The caller must separately reject those tools and + opaque request overrides before using this distinction. Search-only routes + stay closed even when a dated catalog entry omits their search fee; unknown + fee-bearing routes stay closed too. """ info = model_info or {} catalog_key = info.get("key") @@ -149,20 +165,36 @@ def has_unbounded_search_cost( canonical_model, catalog_key if isinstance(catalog_key, str) else None, ) + provider = _real_litellm_provider(info) for name in names: normalized = _normalize_model_for_litellm(name) or "" if model_matches( normalized, ("gpt-4o-search-preview", "gpt-4o-mini-search-preview") ) or OpenAIGPT5Config.is_model_gpt_5_search_model(normalized): return True + if normalized.split("/")[-1].startswith("sonar"): + return True if info.get("search_context_cost_per_query") is None: return False - if _real_litellm_provider(info) != "openai" or not isinstance(catalog_key, str): + if not isinstance(catalog_key, str) or info.get("mode") != "chat": return True resolved_name = (_normalize_model_for_litellm(catalog_key) or "").split("/")[-1] - return "search" in resolved_name or not resolved_name.startswith( - ("gpt-", "o1", "o3", "o4") - ) + if "search" in resolved_name: + return True + if provider in {"openai", "azure"}: + return not resolved_name.startswith(("gpt-", "o1", "o3", "o4")) + if provider in { + "anthropic", + "azure_ai", + "bedrock", + "bedrock_converse", + "vertex_ai-anthropic_models", + }: + # Bedrock catalog keys include a region and anthropic namespace. + return not resolved_name.split(".")[-1].startswith("claude-") + if provider in {"gemini", "vertex_ai", "vertex_ai-language-models"}: + return not resolved_name.startswith("gemini-") + return True @cache diff --git a/tests/sdk/llm/fixtures/optional-search-provider-info.json b/tests/sdk/llm/fixtures/optional-search-provider-info.json new file mode 100644 index 00000000000..21f4b8e503e --- /dev/null +++ b/tests/sdk/llm/fixtures/optional-search-provider-info.json @@ -0,0 +1,330 @@ +{ + "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json", + "captured_utc": "2026-10-18T17:45:00.739898+00:00", + "catalog_sha256": "da798353b3d14c09a13f2f1fe42eaf903212c158bb6d94d724836225c68d865d", + "model_info": { + "claude-sonnet-4-5": { + "key": "claude-sonnet-4-5", + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "cache_creation_input_token_cost_batches": 1.875e-06, + "cache_read_input_token_cost_batches": 1.5e-07, + "deprecation_date": "2026-11-30", + "input_cost_per_token_batches": 1.5e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_batches": 7.5e-06, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_native_structured_output": true, + "supports_tool_choice": true, + "supports_vision": true, + "prompt_cache_min_tokens": 1024, + "source": "https://platform.claude.com/docs/en/about-claude/pricing" + }, + "gemini/gemini-2.5-pro": { + "key": "gemini/gemini-2.5-pro", + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, + "cache_read_input_token_cost_batches": 1.25e-07, + "cache_read_input_token_cost_flex": 1.25e-07, + "cache_read_input_token_cost_priority": 2.25e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "input_cost_per_token_priority": 2.25e-06, + "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, + "litellm_provider": "gemini", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "output_cost_per_token_priority": 1.8e-05, + "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, + "rpm": 2000, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 800000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + }, + "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "supports_url_context": true + }, + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + "key": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "deprecation_date": "2027-04-08", + "litellm_provider": "bedrock_converse", + "supports_tool_search": true, + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_batches": 7.5e-06, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" + }, + "gemini-2.5-pro": { + "key": "gemini-2.5-pro", + "deprecation_date": "2026-10-20", + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, + "cache_read_input_token_cost_flex": 1.25e-07, + "cache_read_input_token_cost_priority": 2.25e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + }, + "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.25e-06, + "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 1.8e-05 + }, + "vertex_ai/claude-opus-4-6": { + "key": "vertex_ai/claude-opus-4-6", + "deprecation_date": "2027-02-05", + "regional_endpoint_uplift_multiplier": 1.1, + "supports_adaptive_thinking": true, + "supports_legacy_thinking": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_creation_input_token_cost_batches": 3.125e-06, + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_batches": 2.5e-07, + "input_cost_per_token": 5e-06, + "input_cost_per_token_batches": 2.5e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "output_cost_per_token_batches": 1.25e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_output_config": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 4096, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + }, + "vertex_ai/gemini-3-pro-preview": { + "key": "vertex_ai/gemini-3-pro-preview", + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "input_cost_per_token_batches": 1e-06, + "litellm_provider": "vertex_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_200k_tokens": 1.8e-05, + "output_cost_per_token_batches": 6e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query" + }, + "azure_ai/claude-opus-4-6": { + "key": "azure_ai/claude-opus-4-6", + "deprecation_date": "2027-02-02", + "supports_adaptive_thinking": true, + "supports_legacy_thinking": true, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_output_config": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 4096 + } + } +} diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py index 0d1ebce911d..d319ebf440f 100644 --- a/tests/sdk/llm/test_budget_denial_http.py +++ b/tests/sdk/llm/test_budget_denial_http.py @@ -1,11 +1,15 @@ import json from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from threading import Thread import pytest from pydantic import SecretStr +import openhands.sdk.llm.llm as llm_module +from openhands.sdk import Agent, Conversation +from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.llm import LLM, Message, TextContent from openhands.sdk.llm.call_context import LLMCallContext from openhands.sdk.llm.cost_budget import CostBudget @@ -45,6 +49,77 @@ def log_message(self, format, *args): thread.join() +@pytest.mark.parametrize( + "model", ["anthropic/claude-sonnet-4-5", "gemini/gemini-2.5-pro"] +) +@pytest.mark.parametrize("use_async", [False, True], ids=["run", "arun"]) +async def test_optional_search_prices_allow_ordinary_conversation_over_http( + provider, tmp_path, monkeypatch, model, use_async +): + base_url, replies, requests = provider + fixture = ( + Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" + ) + key = "claude-sonnet-4-5" if model.startswith("anthropic/") else model + info = json.loads(fixture.read_text())["model_info"][key] + monkeypatch.setattr(llm_module, "get_litellm_model_info", lambda **_: info) + if model.startswith("anthropic/"): + response = { + "id": "message-budget", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "done"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 100, "output_tokens": 5}, + } + else: + response = { + "candidates": [ + { + "content": {"role": "model", "parts": [{"text": "done"}]}, + "finishReason": "STOP", + "index": 0, + } + ], + "usageMetadata": { + "promptTokenCount": 100, + "candidatesTokenCount": 5, + "totalTokenCount": 105, + }, + "modelVersion": "gemini-2.5-pro", + } + replies.append((200, response)) + conv = Conversation( + agent=Agent( + llm=LLM( + model=model, + base_url=base_url, + api_key=SecretStr("fixture-key"), + num_retries=0, + max_output_tokens=100, + ), + tools=[], + ), + workspace=str(tmp_path), + persistence_dir=str(tmp_path / "persist"), + max_budget_per_run=50, + visualizer=None, + ) + try: + conv.send_message("Reply briefly.") + if use_async: + await conv.arun() + else: + conv.run() + + assert conv.state.execution_status == ConversationExecutionStatus.FINISHED + assert len(requests) == 1 + assert conv.conversation_stats.get_combined_metrics().accumulated_cost > 0 + finally: + conv.close() + + @pytest.mark.asyncio @pytest.mark.parametrize("api_mode", ["chat", "responses"]) @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index ec78d5a4442..e73f8b850fe 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -450,18 +450,216 @@ async def test_budget_allows_plain_function_calls_with_optional_search_prices( assert providers[provider_index].call_args.kwargs["tools"][0]["type"] == "function" +@pytest.mark.parametrize( + "model,catalog_key", + [ + ("anthropic/claude-sonnet-4-5", "claude-sonnet-4-5"), + ("gemini/gemini-2.5-pro", "gemini/gemini-2.5-pro"), + ( + "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0", + "anthropic.claude-sonnet-4-5-20250929-v1:0", + ), + ("vertex_ai/gemini-2.5-pro", "gemini-2.5-pro"), + ("vertex_ai/claude-opus-4-6", "vertex_ai/claude-opus-4-6"), + ("vertex_ai/gemini-3-pro-preview", "vertex_ai/gemini-3-pro-preview"), + ("azure_ai/claude-opus-4-6", "azure_ai/claude-opus-4-6"), + ], +) +@pytest.mark.parametrize( + "invoke,provider_index", + [ + (LLM.completion, 0), + (LLM.acompletion, 1), + (LLM.responses, 2), + (LLM.aresponses, 3), + ], +) +async def test_budget_allows_ordinary_provider_calls_with_optional_search_prices( + budget_call, monkeypatch, model, catalog_key, invoke, provider_index +): + info, make_llm, messages, providers = budget_call + fixture = ( + Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" + ) + info.clear() + info.update(json.loads(fixture.read_text())["model_info"][catalog_key]) + # The underlying provider functions are test doubles; optional Vertex + # package availability is checked separately by test_vertex_preflight. + monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) + llm = make_llm(model=model, native_tool_calling=True) + + result = invoke( + llm, + messages, + tools=list(FinishTool.create()), + call_context=LLMCallContext(cost_budget=CostBudget(50)), + ) + if inspect.isawaitable(result): + result = await result + + assert result.message.content == [TextContent(text="ok")] + providers[provider_index].assert_called_once() + assert providers[provider_index].call_args.kwargs["tools"][0]["type"] == "function" + + +@pytest.mark.parametrize( + "invoke", [LLM.completion, LLM.acompletion, LLM.responses, LLM.aresponses] +) +async def test_budget_reserves_vertex_regional_endpoint_premium( + budget_call, monkeypatch, invoke +): + info, make_llm, messages, providers = budget_call + fixture = ( + Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" + ) + info.clear() + info.update( + json.loads(fixture.read_text())["model_info"]["vertex_ai/claude-opus-4-6"] + ) + monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) + llm = make_llm(model="vertex_ai/claude-opus-4-6") + + # Published context/cache tiers require $10.0025 before the endpoint's + # 10% regional premium. $10.50 must not admit the $11.00275 bound. + with pytest.raises(LLMBudgetExceededError): + result = invoke( + llm, messages, call_context=LLMCallContext(cost_budget=CostBudget(10.5)) + ) + if inspect.isawaitable(result): + await result + + for provider in providers: + provider.assert_not_called() + + +@pytest.mark.parametrize( + "invoke,provider_index", + [ + (LLM.completion, 0), + (LLM.acompletion, 1), + (LLM.responses, 2), + (LLM.aresponses, 3), + ], +) +@pytest.mark.parametrize("custom_prices", [False, True]) +async def test_budget_does_not_refund_unverified_endpoint_premium( + budget_call, monkeypatch, invoke, provider_index, custom_prices +): + info, make_llm, messages, providers = budget_call + fixture = ( + Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" + ) + info.clear() + info.update( + json.loads(fixture.read_text())["model_info"]["vertex_ai/claude-opus-4-6"] + ) + monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) + pricing = ( + {"input_cost_per_token": 0.000001, "output_cost_per_token": 0.000001} + if custom_prices + else {} + ) + llm = make_llm(model="vertex_ai/claude-opus-4-6", **pricing) + budget = CostBudget(12) + + async def call(): + result = invoke(llm, messages, call_context=LLMCallContext(cost_budget=budget)) + return await result if inspect.isawaitable(result) else result + + result = await call() + assert result.message.content == [TextContent(text="ok")] + recorded_cost = llm.metrics.accumulated_cost + assert recorded_cost > 0 + if custom_prices: + await call() + assert budget.spent == pytest.approx(llm.metrics.accumulated_cost) + assert providers[provider_index].call_count == 2 + else: + # Positive token telemetry does not establish that the endpoint's + # premium was included. Keep the inclusive bound for this run. + with pytest.raises(LLMBudgetExceededError): + await call() + assert llm.metrics.accumulated_cost == recorded_cost + assert budget.spent > recorded_cost + providers[provider_index].assert_called_once() + + +def test_budget_counts_reported_cost_above_endpoint_reservation( + budget_call, monkeypatch +): + info, make_llm, messages, providers = budget_call + fixture = ( + Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" + ) + info.clear() + info.update( + json.loads(fixture.read_text())["model_info"]["vertex_ai/claude-opus-4-6"] + ) + monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) + monkeypatch.setattr(telemetry_module, "litellm_completion_cost", lambda **_: 20) + llm = make_llm(model="vertex_ai/claude-opus-4-6") + context = LLMCallContext(cost_budget=CostBudget(31)) + + llm.completion(messages, call_context=context) + with pytest.raises(LLMBudgetExceededError): + llm.completion(messages, call_context=context) + + assert llm.metrics.accumulated_cost == 20 + providers[0].assert_called_once() + + +@pytest.mark.parametrize("custom_prices", [False, True]) +def test_vertex_budget_requires_known_endpoint_premium_or_custom_rates( + budget_call, monkeypatch, custom_prices +): + info, make_llm, messages, providers = budget_call + # The pinned LiteLLM fallback contains token prices and the context cap + # but lacks the regional endpoint premium in the current public catalog. + packaged = files("litellm").joinpath("model_prices_and_context_window_backup.json") + info.clear() + info.update( + key="vertex_ai/claude-opus-4-6", + **json.loads(packaged.read_text())["vertex_ai/claude-opus-4-6"], + ) + monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) + pricing = ( + {"input_cost_per_token": 0.000001, "output_cost_per_token": 0.000001} + if custom_prices + else {} + ) + llm = make_llm(model="vertex_ai/claude-opus-4-6", **pricing) + context = LLMCallContext(cost_budget=CostBudget(10.5)) + + if custom_prices: + result = llm.completion(messages, call_context=context) + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() + else: + with pytest.raises(LLMBudgetExceededError, match="endpoint premium"): + llm.completion(messages, call_context=context) + providers[0].assert_not_called() + + @pytest.mark.parametrize( "invoke,tool_type", - [(LLM.responses, "web_search_preview"), (LLM.aresponses, "web_search")], + [ + (LLM.completion, "web_search"), + (LLM.acompletion, "web_search"), + (LLM.responses, "web_search_preview"), + (LLM.aresponses, "web_search"), + ], ) async def test_budget_refuses_prepared_native_search_tools( budget_call, monkeypatch, invoke, tool_type ): _, make_llm, messages, providers = budget_call - monkeypatch.setattr( - FinishTool, "to_responses_tool", lambda *args, **kwargs: {"type": tool_type} + method = ( + "to_openai_tool" + if invoke in (LLM.completion, LLM.acompletion) + else "to_responses_tool" ) - llm = make_llm() + monkeypatch.setattr(FinishTool, method, lambda *args, **kwargs: {"type": tool_type}) + llm = make_llm(native_tool_calling=True) with pytest.raises(LLMBudgetExceededError): result = invoke( @@ -477,6 +675,42 @@ async def test_budget_refuses_prepared_native_search_tools( provider.assert_not_called() +@pytest.mark.parametrize( + "invoke", [LLM.completion, LLM.acompletion, LLM.responses, LLM.aresponses] +) +@pytest.mark.parametrize( + "kwargs", + [ + {"web_search_options": {}}, + {"functions": [{"google_search": {}}]}, + {"cached_content": "cachedContents/opaque-hosted-tools"}, + ], +) +async def test_budget_refuses_explicit_or_legacy_provider_search( + budget_call, invoke, kwargs +): + info, make_llm, messages, providers = budget_call + fixture = ( + Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" + ) + info.clear() + info.update(json.loads(fixture.read_text())["model_info"]["gemini/gemini-2.5-pro"]) + llm = make_llm(model="gemini/gemini-2.5-pro") + + with pytest.raises(LLMBudgetExceededError): + result = invoke( + llm, + messages, + call_context=LLMCallContext(cost_budget=CostBudget(50)), + **kwargs, + ) + if inspect.isawaitable(result): + await result + + for provider in providers: + provider.assert_not_called() + + @pytest.mark.parametrize( "model,canonical,key,provider", [ @@ -486,6 +720,17 @@ async def test_budget_refuses_prepared_native_search_tools( ("openai/gpt-4o-search-preview", "gpt-4o-mini", "gpt-4o-mini", "openai"), ("openai/generated-alias", None, "gpt-4o-search-preview", "openai"), ("perplexity/sonar", None, "perplexity/sonar", "perplexity"), + ("perplexity/sonar", None, None, "perplexity"), + ("litellm_proxy/sonar", None, None, None), + ("openrouter/perplexity/sonar-pro", None, None, None), + ( + "gemini/deep-research-pro-preview", + None, + "gemini/deep-research-pro-preview", + "gemini", + ), + ("unknown/gemini-2.5-pro", None, "gemini-2.5-pro", "unknown"), + ("gemini/gemini-live-preview", None, "gemini-live-preview", "gemini"), ], ) def test_budget_keeps_intrinsic_or_unresolved_search_fees_closed( @@ -496,9 +741,15 @@ def test_budget_keeps_intrinsic_or_unresolved_search_fees_closed( info.update( key=key, litellm_provider=provider, + mode="realtime" if "live" in key else "chat", search_context_cost_per_query={"search_context_size_medium": 0.025}, ) - llm = make_llm(model=model, model_canonical_name=canonical) + llm = make_llm( + model=model, + model_canonical_name=canonical, + input_cost_per_token=0.0000005, + output_cost_per_token=0.001, + ) with pytest.raises(LLMBudgetExceededError): llm.completion( @@ -541,12 +792,20 @@ def test_budget_allows_known_free_custom_prices(budget_call): providers[0].assert_called_once() -def test_unbudgeted_call_does_not_require_price_or_caps(budget_call): +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"functions": [{"google_search": {}}]}, + {"cached_content": "cachedContents/opaque-hosted-tools"}, + ], +) +def test_unbudgeted_call_does_not_require_price_or_caps(budget_call, kwargs): info, make_llm, messages, providers = budget_call info.clear() llm = make_llm(max_input_tokens=None, max_output_tokens=None) - result = llm.completion(messages) + result = llm.completion(messages, **kwargs) assert result.message.content == [TextContent(text="ok")] providers[0].assert_called_once() From c9980ec792decae373c32edbd3767322c2cab665 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 20:00:49 +0200 Subject: [PATCH 14/24] docs: refresh budget admission review evidence Document ordinary provider support, hosted-tool and cache boundaries, regional endpoint pricing, and final regression evidence. Co-authored-by: openhands --- .pr/budget-design.html | 959 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 856 insertions(+), 103 deletions(-) diff --git a/.pr/budget-design.html b/.pr/budget-design.html index c492882f71d..f53957c973d 100644 --- a/.pr/budget-design.html +++ b/.pr/budget-design.html @@ -4,10 +4,10 @@ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.6 var(--sans)}.wrap{display:grid;grid-template-columns:192px minmax(0,1fr);gap:44px;max-width:1210px;margin:auto;padding:38px 32px 80px}nav{position:sticky;top:30px;align-self:start;font-size:13px}nav h2{font-size:10px;text-transform:uppercase;letter-spacing:.13em;color:var(--muted);margin:0 0 14px}nav ol{list-style:none;margin:0;padding:0;counter-reset:toc}nav li{counter-increment:toc;margin:10px 0}nav li::before{content:counter(toc,decimal-leading-zero) " ";color:var(--subtle);font:11px var(--mono)}nav a{color:var(--fg);text-decoration:none}nav small{display:block;margin-top:24px;color:var(--muted);line-height:1.6}a{color:var(--accent);text-underline-offset:3px}a:hover{color:var(--warn)}header{padding-bottom:24px;border-bottom:1px solid var(--rule)}.eyebrow{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);font-weight:750}h1{font-size:40px;line-height:1.12;letter-spacing:-.04em;margin:13px 0 16px;max-width:760px}.sub{font-size:18px;line-height:1.6;color:var(--muted);max-width:760px;margin:0}.meta{font:11px/1.8 var(--mono);color:var(--muted);margin-top:17px;overflow-wrap:anywhere}h2{font-size:25px;line-height:1.25;letter-spacing:-.025em;margin:0 0 14px}h2 .num{color:var(--subtle);font:13px var(--mono);margin-right:12px}section{padding-top:40px;scroll-margin-top:24px}h3{font-size:17px;line-height:1.35;margin:23px 0 8px}p{margin:10px 0 14px}code{font:.87em var(--mono);padding:2px 4px;background:var(--code-bg);border-radius:3px}pre{font:12px/1.65 var(--mono);padding:16px;background:var(--code-bg);border:1px solid var(--rule);border-radius:5px;overflow:auto;margin:8px 0}pre code{font:inherit;padding:0;background:none}.kw{color:var(--accent);font-weight:700}.com{color:var(--subtle)}.str{color:var(--ok)}.ba{display:grid;grid-template-columns:1fr 1fr;gap:16px}.label{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:750}.callout{margin:22px 0 20px;border-left:3px solid var(--accent);border-radius:0 5px 5px 0;background:var(--accent-soft);padding:13px 16px;font-size:14px}.callout.warn{border-color:var(--warn);background:var(--warn-soft)}.callout.note{border-color:var(--subtle);background:var(--code-bg)}.sources{font-size:12px;color:var(--muted);margin:8px 0 16px;line-height:1.8}.src{font:11px/1.7 var(--mono);text-decoration:none;border-bottom:1px dotted var(--accent);overflow-wrap:anywhere}.sources .src{font-size:10.5px}table{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}th,td{text-align:left;vertical-align:top;padding:10px 11px;border-bottom:1px solid var(--rule)}th{font-size:11px;color:var(--muted);background:var(--code-bg)}td:first-child{width:24%;font-weight:650}.fig{margin:18px 0 10px}.fig svg{display:block;width:100%;height:auto;background:var(--card);border:1px solid var(--rule);border-radius:6px}.fig figcaption{font-size:12px;line-height:1.5;color:var(--muted);margin:8px 2px}details{border:1px solid var(--rule);border-radius:5px;margin:10px 0}summary{cursor:pointer;padding:9px 12px;font-size:12px;color:var(--muted)}details>div{padding:3px 12px 12px}details pre{margin:0;border:0;border-radius:0;font-size:10.5px;max-height:550px}details.manifest{border:0;border-bottom:1px solid var(--rule);border-radius:0;margin:-4px 0 18px}details.manifest summary{padding:5px 0;font-size:11px}details.manifest>div{padding:3px 0 10px}.files summary{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}.files .path{font:11px var(--mono);color:var(--fg);overflow-wrap:anywhere}.files .counts{margin-left:auto;white-space:nowrap;font:10px var(--mono)}.add{color:var(--ok)}.del{color:var(--crit)}.tag{font-size:10px;color:var(--subtle)}.group{margin:25px 0 8px;font-size:13px;font-weight:750}.files p{font-size:12px;color:var(--muted)}footer{border-top:1px solid var(--rule);margin-top:38px;padding-top:16px;color:var(--muted);font-size:11px}.pending{color:var(--warn);font-weight:700}@media(max-width:900px){.wrap{grid-template-columns:1fr;padding:24px 20px 64px;gap:25px}nav{position:static}nav ol{display:flex;flex-wrap:wrap;gap:0 17px}nav li{margin:4px 0}nav small{display:none}h1{font-size:34px}}@media(max-width:600px){.ba{grid-template-columns:1fr}h1{font-size:30px}.sub{font-size:16px}th,td{padding:8px 6px;font-size:12px}.wrap{padding:22px 14px 54px}}@media print{.wrap{display:block;padding:0}nav{display:none}details{break-inside:avoid}h2{break-after:avoid}.fig{break-inside:avoid}a{color:var(--fg)}}
-
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1849 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ dceb668511cb6ccbc9e16dd3320b1a5d2019144b
32 changed files · +2014 / −21 against merge base · Admission repair included

Source links open commit-pinned files; displayed path:line labels identify the exact lines.

+
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ 322790065776568285eb852ea36cbb260a3df47e
33 changed files · +2746 / −21 against merge base · Admission repair included

Source links open commit-pinned files; displayed path:line labels identify the exact lines.

-

01The option now crosses the public API

Source manifest
-

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

+

01The option now crosses the public API

Source manifest
+

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

Before · merge base
# Local implementation only
 LocalConversation(..., max_budget_per_run=None)
 
@@ -19,20 +19,20 @@
 
 // TypeScript remote constructor or start option
 { maxBudgetPerRun: 2.5 }
-

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

-
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
+

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

+
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
-

02Reserve before each SDK LLM attempt

Source manifest
+

02Reserve before each SDK LLM attempt

Source manifest
The admission check moves before each SDK LLM attemptBefore: a local run checks accumulated lifetime metrics after a step. After: SDK LLM attempts reserve against one shared ledger before transport, then settle each attempt. Authoritative bounds, transport controls, and conservative settlement are the focused repair described in section 3.LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1937CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2093new gateCostBudget.settle()positive success: cost; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
-

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1937 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

-

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

-

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost. After an attempt starts, failed, cancelled, or unknown-cost successful work retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1937 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1162 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

+LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1950CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2129new gateCostBudget.settle()complete cost: actual; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
+

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

+

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

+

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost when it accounts for the applicable rates. Published endpoint-uplift routes retain the reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. After an attempt starts, failed, cancelled, or unknown-cost successful work also retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1163 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

-

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

+

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

⚠ The original PR's admission projection at 091882a invented rates for missing price data and a 32,768-token cap for missing limits. A finite fallback is an estimate, not an upper bound; published context tiers can also exceed the selected base rates.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:180 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1765 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1789 @ 091882a

A deterministic probe at 091882a, using packaged LiteLLM metadata for xai/grok-4-fast-reasoning, admitted a $0.1640005 reservation under a $0.20 cap while the published tier arithmetic was $0.2512004. This was metadata arithmetic with no provider request or billed usage.

Reproduction inputs and arithmetic · original PR at 091882a
input tokens:          128,001
@@ -45,23 +45,25 @@
 No credentials, network, or model completion used.
 Metadata snapshot SHA-256:
 cc1955f7f1cdc2cdd9ebf216e7eebbc90bbb3528b62439327f7e8b893eda7c12

Observed against the pre-repair admission helper; the focused regression tests belong with the repair.

-

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1780 · openhands-sdk/openhands/sdk/llm/llm.py:1810 · openhands-sdk/openhands/sdk/llm/llm.py:1849

+

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1781 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1857

Failed attempts cannot receive a blind refund

A separate deterministic fixture at 091882a followed the real SDK completion, telemetry, validation, and retry path. Two invalid-response attempts each reserved $0.11; under a $0.15 cap, SDK telemetry recorded $0.22 while the budget ledger finished at zero. A timeout fixture separately reproduced reuse of allowance for uncertain attempted work. These probes establish SDK accounting behavior, without a provider request or billing claim.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:1833 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1887 @ 091882a

-
  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Bound standard token-priced text and function calls. Refuse audio/non-text output, web_search_options even when empty, and prepared non-function/native server tools. A search price schedule alone does not imply search work: resolved ordinary OpenAI GPT/o routes are allowed when no search is requested; intrinsic search and unresolved fee-bearing routes remain closed. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost. Failed, cancelled, or unknown-cost successful attempts retain at least the reserved bound; a larger recorded delta also counts. Setup or refusal before admission remains uncharged.
-
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1849 · openhands-sdk/openhands/sdk/llm/llm.py:1810 · openhands-sdk/openhands/sdk/llm/llm.py:1937 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

-

has_unbounded_search_cost() distinguishes an optional search price schedule from intrinsic search work. Resolved ordinary OpenAI GPT/o routes retain text/function-call support when no search is requested; intrinsic search aliases, raw and dated routes, and unresolved/provider fee routes remain refused. The shared guard also checks prepared tools, including the Responses wire payload, and rejects non-function/native server tools.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:131 · openhands-sdk/openhands/sdk/llm/llm.py:1880 · tests/sdk/llm/test_cost_budget.py:425 · tests/sdk/llm/test_cost_budget.py:457 · tests/sdk/llm/test_cost_budget.py:491 · tests/sdk/llm/fixtures/optional-search-model-info.json:1

-

Opaque clients, an empty web_search_options object, and audio/non-text output options still fail before transport. These cases do not add vendor-fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:369 · tests/sdk/llm/test_cost_budget.py:398

-

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1849 · openhands-sdk/openhands/sdk/llm/call_context.py:29

+
  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Bound standard token-priced text and SDK function calls. Refuse audio/non-text output, web_search_options even when empty, prepared non-function/native server tools, nonempty legacy functions overrides, and opaque cached_content references. A search price schedule alone does not imply search work: resolved ordinary GPT/o, Claude, and Gemini chat routes are allowed when no search is requested; intrinsic search and unresolved fee-bearing routes remain closed. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost when it can account for the applicable rates. Published endpoint-uplift routes retain their full reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. Failed, cancelled, or unknown-cost successful attempts also retain at least the reserved bound; a larger recorded delta counts too. Setup or refusal before admission remains uncharged.
+
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

+

has_unbounded_search_cost() distinguishes an optional search price schedule from intrinsic search work. Resolved ordinary GPT/o, Claude, and Gemini chat routes retain text/function-call support when no search is requested, including supported Azure, Bedrock, and Vertex routes. Intrinsic search aliases, raw and dated routes, and unresolved fee-bearing routes remain refused. The shared guard checks prepared tools, including the Responses wire payload, and rejects non-function/native server tools. Nonempty legacy functions overrides and opaque cached_content references also fail before transport because they can hide hosted tools.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:146 · openhands-sdk/openhands/sdk/llm/llm.py:1889 · openhands-sdk/openhands/sdk/llm/llm.py:1893 · tests/sdk/llm/test_cost_budget.py:425 · tests/sdk/llm/test_cost_budget.py:477 · tests/sdk/llm/test_cost_budget.py:652 · tests/sdk/llm/test_cost_budget.py:689 · tests/sdk/llm/test_cost_budget.py:736 · tests/sdk/llm/fixtures/optional-search-model-info.json:1 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

+

The bot’s second review reproduced ordinary Claude and Gemini conversations failing only when a budget was enabled. Regression coverage now runs all four public LLM wrappers across resolved provider routes and exercises real Conversation.run()/arun() paths against local Anthropic/Gemini HTTP adapters; those conversations finish with positive recorded cost and exactly one provider request.

Sources: tests/sdk/llm/test_budget_denial_http.py:56

+

The resolved Vertex Claude fixture also publishes a regional endpoint uplift. Admission includes that multiplier as well as regional processing uplifts; a ceiling that covers the base token rates but misses the endpoint surcharge is refused. Older catalog snapshots can omit that premium: published pricing on those Vertex Claude routes is refused until its endpoint multiplier is known. Since installed LiteLLM telemetry may omit the premium even when the catalog includes it, successful calls retain at least the full reservation. Paired custom prices remain an explicit override. Recorded lifetime metrics are unchanged.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1850 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

+

Opaque clients, an empty web_search_options object, and audio/non-text output options still fail before transport. These cases do not add vendor-fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:369 · tests/sdk/llm/test_cost_budget.py:398

+

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/call_context.py:29

-

04Each top-level run gets a fresh allowance

Source manifest
+

04Each top-level run gets a fresh allowance

Source manifest
Each top-level run receives a fresh allowance; delegates share itA new top-level run resets its owned ledger while lifetime metrics remain cumulative. Delegated children share the ledger and ancestor ceilings. A fork copies the configured limit but owns a separate ledger.ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

-
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
+ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

+
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
-

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
+

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
 index c508fef15..510ac4b9a 100644
 --- a/openhands-sdk/openhands/sdk/conversation/conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/conversation.py
@@ -105,7 +107,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=stuck_detection, stuck_detection_thresholds=stuck_detection_thresholds, visualizer=visualizer, -

✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
 index 4885b35ef..64ad8aff9 100644
 --- a/openhands-sdk/openhands/sdk/conversation/request.py
 +++ b/openhands-sdk/openhands/sdk/conversation/request.py
@@ -129,7 +131,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="If true, the conversation will use stuck detection to " -

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 index 907b700d3..ca4d8b0fd 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
@@ -181,7 +183,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi self.workspace = workspace self._client = workspace.client self._cleanup_initiated = False -

✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
 index ea2d8d332..30b317ac8 100644
 --- a/openhands-agent-server/openhands/agent_server/models.py
 +++ b/openhands-agent-server/openhands/agent_server/models.py
@@ -203,7 +205,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="Whether to enable stuck detection for the agent.", -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
 index e7df77d9a..49a2d879f 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py
@@ -215,7 +217,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi title=stored.title, metrics=stored.metrics, created_at=stored.created_at, -

✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
 index 83721a6da..923713fbe 100644
 --- a/openhands-agent-server/openhands/agent_server/event_service.py
 +++ b/openhands-agent-server/openhands/agent_server/event_service.py
@@ -227,7 +229,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=self.stored.stuck_detection, visualizer=None, secrets=self.stored.secrets, -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
 index 23968b2d8..dc8e39b81 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py
@@ -248,7 +250,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) ) from e -

✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
+
✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
 index bfd1b6eec..f887053d8 100644
 --- a/clients/typescript/src/models/conversation.ts
 +++ b/clients/typescript/src/models/conversation.ts
@@ -286,7 +288,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: boolean; workspace: Record<string, unknown>; hook_config?: HookConfig | null; -

✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
+
✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
 index 04091d075..9a61d6257 100644
 --- a/clients/typescript/src/conversation/remote-conversation.ts
 +++ b/clients/typescript/src/conversation/remote-conversation.ts
@@ -341,7 +343,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? this.stuckDetection ?? true, workspace: { type: 'local', working_dir: this.workspace.workingDir }, hook_config: hookConfig ?? null, -

✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
+
✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
 index 8ac58e347..3f3c66fc7 100644
 --- a/clients/typescript/src/conversation/conversation-manager.ts
 +++ b/clients/typescript/src/conversation/conversation-manager.ts
@@ -366,7 +368,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? true, workspace: { type: 'local', working_dir: options.workingDir || '/tmp' }, user_id: options.userId ?? null, -

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
+

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 index 9c56cf0b1..447231fb6 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
@@ -676,7 +678,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi except Exception as e: with self._state: updated_agent_state = dict(self._state.agent_state) -

🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
+
🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
 new file mode 100644
 index 000000000..da9142417
 --- /dev/null
@@ -868,7 +870,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + + +__all__ = ["BudgetReservation", "CostBudget"] -

✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
 index 6c886595e..8dd7cb5d8 100644
 --- a/openhands-sdk/openhands/sdk/llm/call_context.py
 +++ b/openhands-sdk/openhands/sdk/llm/call_context.py
@@ -894,8 +896,8 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def for_conversation( self, -

✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+287 / −1

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
-index 3e304d2ee..98eee4491 100644
+
✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+323 / −1

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
+index 3e304d2ee..49069f94c 100644
 --- a/openhands-sdk/openhands/sdk/llm/llm.py
 +++ b/openhands-sdk/openhands/sdk/llm/llm.py
 @@ -2,7 +2,9 @@ from __future__ import annotations
@@ -934,7 +936,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi LLMContextWindowTooSmallError, LLMNoResponseError, is_prompt_cache_too_small, -@@ -152,7 +158,11 @@ from openhands.sdk.llm.utils.image_inline import ( +@@ -152,7 +158,12 @@ from openhands.sdk.llm.utils.image_inline import ( from openhands.sdk.llm.utils.image_resize import maybe_resize_messages_for_provider from openhands.sdk.llm.utils.litellm_provider import LLMProvider from openhands.sdk.llm.utils.metrics import Metrics @@ -943,11 +945,12 @@

Failed attempts cannot receive a blind refund

A separate deterministi + ModelFeatures, + get_features, + has_unbounded_search_cost, ++ has_unresolved_regional_endpoint_pricing, +) from openhands.sdk.llm.utils.openhands_provider import ( LiteLLMCallKwargs, canonicalize_openhands_llm_payload, -@@ -1749,6 +1759,266 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1749,6 +1760,301 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # Chat Completion API # ========================================================================= @@ -1015,6 +1018,8 @@

Failed attempts cannot receive a blind refund

A separate deterministi + self._budget_price(self.output_cost_per_token), + ) + info = self._model_info or {} ++ if has_unresolved_regional_endpoint_pricing(info): ++ raise self._budget_bound_error("unknown regional endpoint premium") + input_costs = [self._budget_price(info.get("input_cost_per_token"))] + output_costs = [self._budget_price(info.get("output_cost_per_token"))] + uplifts = [1.0] @@ -1033,7 +1038,12 @@

Failed attempts cannot receive a blind refund

A separate deterministi + ("output_cost_per_token", "output_cost_per_reasoning_token") + ): + output_costs.append(self._budget_price(value)) -+ elif key.startswith("regional_processing_uplift_multiplier"): ++ elif key.startswith( ++ ( ++ "regional_processing_uplift_multiplier", ++ "regional_endpoint_uplift_multiplier", ++ ) ++ ): + uplifts.append(self._budget_price(value)) + uplift = max(uplifts) + return max(input_costs) * uplift, max(output_costs) * uplift @@ -1068,8 +1078,13 @@

Failed attempts cannot receive a blind refund

A separate deterministi + if any( + not isinstance(tool, Mapping) or tool.get("type") != "function" + for tool in request_kwargs.get("tools") or () -+ ): -+ raise self._budget_bound_error("native server tools are unsupported") ++ ) or request_kwargs.get("functions"): ++ # LiteLLM's legacy functions parameter also accepts native tools. ++ raise self._budget_bound_error("native/legacy server tools are unsupported") ++ if request_kwargs.get("cached_content") is not None: ++ # Provider-stored caches may contain hosted tools that are not ++ # visible in this prepared request, in addition to hidden tokens. ++ raise self._budget_bound_error("opaque provider caches are unsupported") + if litellm.num_retries or litellm.model_fallbacks or litellm.drop_params: + raise self._budget_bound_error( + "global LiteLLM retries/fallbacks/parameter dropping are unsupported" @@ -1135,9 +1150,10 @@

Failed attempts cannot receive a blind refund

A separate deterministi + + Failed/cancelled attempts retain their bound because the provider may + have processed work without returning complete usage. Successful calls -+ settle to recorded positive cost; absent cost telemetry retains the -+ bound too. This ledger is budget usage, not a provider billing record. -+ Unbudgeted calls keep their existing transport options and behavior. ++ settle to recorded positive cost when its pricing is complete; absent ++ or endpoint-premium-unaware telemetry retains the bound too. This ledger ++ is budget usage, not a provider billing record. Unbudgeted calls keep ++ their existing transport options and behavior. + """ + + def decorate(attempt: Callable[..., Any]) -> Callable[..., Any]: @@ -1169,7 +1185,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi + raise + cost = self._cost_delta(cost_before) + reservation.budget.settle( -+ reservation, cost if cost > 0 else reservation.amount ++ reservation, self._budget_success_cost(reservation, cost) + ) + return result + @@ -1199,7 +1215,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi + raise + cost = self._cost_delta(cost_before) + reservation.budget.settle( -+ reservation, cost if cost > 0 else reservation.amount ++ reservation, self._budget_success_cost(reservation, cost) + ) + return result + @@ -1210,11 +1226,33 @@

Failed attempts cannot receive a blind refund

A separate deterministi + def _cost_delta(self, cost_before: float) -> float: + """Cost added to this LLM's own metrics by the call just completed.""" + return max(0.0, self.metrics.accumulated_cost - cost_before) ++ ++ def _budget_success_cost( ++ self, reservation: BudgetReservation, cost: float ++ ) -> float: ++ """Keep the bound when recorded cost cannot establish complete pricing.""" ++ uses_custom_prices = ( ++ self.input_cost_per_token is not None ++ and self.output_cost_per_token is not None ++ ) ++ # LiteLLM's calculated token cost does not establish that a known ++ # regional endpoint premium was included. Keep the inclusive bound ++ # without changing the lifetime metrics displayed to callers. Paired ++ # custom prices override the published rates in both paths. ++ unverified_endpoint_premium = not uses_custom_prices and any( ++ key.startswith("regional_endpoint_uplift_multiplier") ++ and value is not None ++ and self._budget_price(value) > 1 ++ for key, value in (self._model_info or {}).items() ++ ) ++ if cost <= 0 or unverified_endpoint_premium: ++ return max(reservation.amount, cost) ++ return cost + def completion( self, messages: list[Message], -@@ -1785,6 +2055,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1785,6 +2091,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # no-op for providers without runtime metadata. self.resolve_runtime_metadata() @@ -1222,7 +1260,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() enable_streaming = bool(kwargs.get("stream", False)) or self.stream if enable_streaming and on_token is None: -@@ -1814,6 +2085,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1814,6 +2121,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): ) @self._make_retry_decorator() @@ -1230,7 +1268,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) -@@ -1850,6 +2122,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1850,6 +2158,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): tools, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1238,7 +1276,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -@@ -1904,6 +2177,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1904,6 +2213,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # calls are cheap. await self.aresolve_runtime_metadata() @@ -1246,7 +1284,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() enable_streaming = bool(kwargs.get("stream", False)) or self.stream if enable_streaming and on_token is None: -@@ -1934,6 +2208,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1934,6 +2244,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): @self._make_retry_decorator() @self._async_hard_timeout_decorator() @@ -1254,7 +1292,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi async def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) -@@ -1970,6 +2245,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1970,6 +2281,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): tools, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1262,7 +1300,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -@@ -2043,6 +2319,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2043,6 +2355,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # metadata. self.resolve_runtime_metadata() @@ -1270,7 +1308,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream if user_enable_streaming and on_token is None and not self.requires_streaming: -@@ -2076,6 +2353,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2076,6 +2389,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): ) @self._make_retry_decorator() @@ -1280,7 +1318,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) -@@ -2151,6 +2431,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2151,6 +2467,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): store, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1288,7 +1326,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -@@ -2208,6 +2489,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2208,6 +2525,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # limits. No blocking network I/O in-process. await self.aresolve_runtime_metadata() @@ -1296,7 +1334,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream if user_enable_streaming and on_token is None and not self.requires_streaming: -@@ -2242,6 +2524,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2242,6 +2560,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): @self._make_retry_decorator() @self._async_hard_timeout_decorator() @@ -1306,7 +1344,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi async def _one_attempt( **retry_kwargs: Any, ) -> ResponsesAPIResponse: -@@ -2343,6 +2628,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2343,6 +2664,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): store, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1314,8 +1352,8 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -

✏️ changedopenhands-sdk/openhands/sdk/llm/utils/model_features.py+38 / −0

Distinguishes optional search pricing on resolved ordinary OpenAI GPT/o routes from intrinsic or unresolved search fees.

openhands-sdk/openhands/sdk/llm/utils/model_features.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
-index 3b40fae04..ddd898d43 100644
+
✏️ changedopenhands-sdk/openhands/sdk/llm/utils/model_features.py+70 / −0

Distinguishes optional search pricing on resolved ordinary GPT/o, Claude, and Gemini chat routes from intrinsic or unresolved search fees.

openhands-sdk/openhands/sdk/llm/utils/model_features.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
+index 3b40fae04..3c5b5787d 100644
 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py
 +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
 @@ -5,6 +5,7 @@ from functools import cache
@@ -1326,10 +1364,25 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi from litellm.utils import supports_vision as litellm_supports_vision from openhands.sdk.llm.utils.openhands_provider import OPENHANDS_PROVIDER_PREFIX -@@ -127,6 +128,43 @@ def _real_litellm_provider( +@@ -127,6 +128,75 @@ def _real_litellm_provider( return provider ++def has_unresolved_regional_endpoint_pricing( ++ model_info: Mapping[str, Any] | None, ++) -> bool: ++ """Vertex Claude needs a known premium to bound regional endpoint cost. ++ ++ Older LiteLLM catalogs carry token prices but omit this provider's known ++ endpoint premium. Their rates alone cannot establish a safe ceiling. ++ """ ++ info = model_info or {} ++ return _real_litellm_provider(info) == "vertex_ai-anthropic_models" and not any( ++ key.startswith("regional_endpoint_uplift_multiplier") and value is not None ++ for key, value in info.items() ++ ) ++ ++ +def has_unbounded_search_cost( + model: str | None, + *, @@ -1339,10 +1392,11 @@

Failed attempts cannot receive a blind refund

A separate deterministi + """Identify search fees that a token-only budget cannot safely reserve. + + A search price schedule alone is not proof that an ordinary call searches. -+ Resolved ordinary OpenAI GPT/o routes can use search optionally; their plain -+ function calls remain token-priced. Known search-only routes are refused -+ even when a dated catalog entry omits its search fee. Other fee-bearing -+ routes stay closed when optional-vs-intrinsic behavior is not established. ++ Ordinary GPT/o, Claude and Gemini chat routes search only when a request ++ enables a hosted tool. The caller must separately reject those tools and ++ opaque request overrides before using this distinction. Search-only routes ++ stay closed even when a dated catalog entry omits their search fee; unknown ++ fee-bearing routes stay closed too. + """ + info = model_info or {} + catalog_key = info.get("key") @@ -1351,26 +1405,42 @@

Failed attempts cannot receive a blind refund

A separate deterministi + canonical_model, + catalog_key if isinstance(catalog_key, str) else None, + ) ++ provider = _real_litellm_provider(info) + for name in names: + normalized = _normalize_model_for_litellm(name) or "" + if model_matches( + normalized, ("gpt-4o-search-preview", "gpt-4o-mini-search-preview") + ) or OpenAIGPT5Config.is_model_gpt_5_search_model(normalized): + return True ++ if normalized.split("/")[-1].startswith("sonar"): ++ return True + if info.get("search_context_cost_per_query") is None: + return False -+ if _real_litellm_provider(info) != "openai" or not isinstance(catalog_key, str): ++ if not isinstance(catalog_key, str) or info.get("mode") != "chat": + return True + resolved_name = (_normalize_model_for_litellm(catalog_key) or "").split("/")[-1] -+ return "search" in resolved_name or not resolved_name.startswith( -+ ("gpt-", "o1", "o3", "o4") -+ ) ++ if "search" in resolved_name: ++ return True ++ if provider in {"openai", "azure"}: ++ return not resolved_name.startswith(("gpt-", "o1", "o3", "o4")) ++ if provider in { ++ "anthropic", ++ "azure_ai", ++ "bedrock", ++ "bedrock_converse", ++ "vertex_ai-anthropic_models", ++ }: ++ # Bedrock catalog keys include a region and anthropic namespace. ++ return not resolved_name.split(".")[-1].startswith("claude-") ++ if provider in {"gemini", "vertex_ai", "vertex_ai-language-models"}: ++ return not resolved_name.startswith("gemini-") ++ return True + + @cache def _normalized_supported_openai_params( model: str | None, provider_hint: str | None = None -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 index da9da3806..8a34d4045 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
@@ -1394,7 +1464,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class LLMContentPolicyViolationError(LLMBadRequestError): """Provider blocked the request/response via its content filter. -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 index 878fced0d..40452d159 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
@@ -1415,7 +1485,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi return True if not isinstance(exception, (RateLimitError, LLMRateLimitError)): return False -

⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
+
⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 index 218c93a05..4e9123725 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
@@ -1435,7 +1505,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi "UserCancelledError", "OperationCancelled", # Helpers -

✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
 index 8fcc9b9bb..83ebab66d 100644
 --- a/openhands-sdk/openhands/sdk/conversation/exceptions.py
 +++ b/openhands-sdk/openhands/sdk/conversation/exceptions.py
@@ -1458,7 +1528,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class WebSocketConnectionError(RuntimeError): """Raised when WebSocket connection fails to establish within the timeout.""" -

✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
 index ed7741759..aa0b9785f 100644
 --- a/openhands-sdk/openhands/sdk/conversation/state.py
 +++ b/openhands-sdk/openhands/sdk/conversation/state.py
@@ -1479,7 +1549,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def owned(self) -> bool: """ Return True if the lock is currently held by the calling thread. -

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
+

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
 index ee6aef487..f74030443 100644
 --- a/clients/typescript/src/__tests__/api-clients.test.ts
 +++ b/clients/typescript/src/__tests__/api-clients.test.ts
@@ -1530,7 +1600,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi it('HttpClient can parse blob responses when requested', async () => { global.fetch = vi.fn().mockResolvedValue( new Response(new Blob(['zip-data']), { -

✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
+
✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
 index eff6ce516..26687efd9 100644
 --- a/tests/agent_server/test_conversation_router.py
 +++ b/tests/agent_server/test_conversation_router.py
@@ -1573,7 +1643,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_start_conversation_merges_observability_headers( client, mock_conversation_service, sample_conversation_info ): -

✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
+
✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
 index 54824cde2..56394feba 100644
 --- a/tests/agent_server/test_conversation_service.py
 +++ b/tests/agent_server/test_conversation_service.py
@@ -1697,7 +1767,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @pytest.mark.asyncio async def test_agent_tool_from_unimportable_module_rejects_create(tmp_path): """The create fails with a client error instead of every message failing.""" -

✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
+
✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
 index dc1245e3e..44d2228b1 100644
 --- a/tests/cross/test_remote_conversation_live_server.py
 +++ b/tests/cross/test_remote_conversation_live_server.py
@@ -1737,7 +1807,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_openai_chat_completions_gateway_over_real_server( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patched_llm ): -

✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
+
✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
 index 807f19a89..4b6ba77f1 100644
 --- a/tests/sdk/conversation/local/test_agent_status_transition.py
 +++ b/tests/sdk/conversation/local/test_agent_status_transition.py
@@ -1805,7 +1875,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi conversation.run() assert conversation.state.execution_status == ConversationExecutionStatus.ERROR -

🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
+
🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
 new file mode 100644
 index 000000000..eed344f1d
 --- /dev/null
@@ -1988,7 +2058,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + delete_on_close=False, + ) + assert not (persist / cid.hex / "base_state.json").exists() -

✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
+
✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
 index b9b93141c..e6d326e8e 100644
 --- a/tests/sdk/conversation/local/test_fork.py
 +++ b/tests/sdk/conversation/local/test_fork.py
@@ -2019,7 +2089,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_fork_copies_agent_state(): """agent_state dict should be carried over to the fork.""" with tempfile.TemporaryDirectory() as tmpdir: -

✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
+
✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
 index 16457eb93..075574f3a 100644
 --- a/tests/sdk/conversation/local/test_state_serialization.py
 +++ b/tests/sdk/conversation/local/test_state_serialization.py
@@ -2074,7 +2144,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_resume_with_conversation_id_mismatch_raises_error(): """Test that resuming with mismatched conversation ID raises error.""" with tempfile.TemporaryDirectory() as temp_dir: -

✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
+
✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
 index 86914e25e..5b48fe9b7 100644
 --- a/tests/sdk/conversation/remote/test_remote_conversation.py
 +++ b/tests/sdk/conversation/remote/test_remote_conversation.py
@@ -2101,7 +2171,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @patch( "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient" ) -

🟢 addedtests/sdk/llm/fixtures/optional-search-model-info.json+35 / −0

Captures public gpt-4o-mini model metadata with optional search pricing for deterministic function-call regression coverage.

tests/sdk/llm/fixtures/optional-search-model-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-model-info.json b/tests/sdk/llm/fixtures/optional-search-model-info.json
+
🟢 addedtests/sdk/llm/fixtures/optional-search-model-info.json+35 / −0

Captures public gpt-4o-mini model metadata with optional search pricing for deterministic function-call regression coverage.

tests/sdk/llm/fixtures/optional-search-model-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-model-info.json b/tests/sdk/llm/fixtures/optional-search-model-info.json
 new file mode 100644
 index 000000000..feff127e3
 --- /dev/null
@@ -2142,13 +2212,359 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + "source": "https://developers.openai.com/api/docs/pricing" + } +} -

✏️ changedtests/sdk/llm/test_budget_denial_http.py+117 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
-index 5f626dded..0d1ebce91 100644
+
🟢 addedtests/sdk/llm/fixtures/optional-search-provider-info.json+330 / −0

Captures public Claude and Gemini metadata, including hosted provider routes, for deterministic optional-search regression coverage.

tests/sdk/llm/fixtures/optional-search-provider-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-provider-info.json b/tests/sdk/llm/fixtures/optional-search-provider-info.json
+new file mode 100644
+index 000000000..21f4b8e50
+--- /dev/null
++++ b/tests/sdk/llm/fixtures/optional-search-provider-info.json
+@@ -0,0 +1,330 @@
++{
++  "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
++  "captured_utc": "2026-10-18T17:45:00.739898+00:00",
++  "catalog_sha256": "da798353b3d14c09a13f2f1fe42eaf903212c158bb6d94d724836225c68d865d",
++  "model_info": {
++    "claude-sonnet-4-5": {
++      "key": "claude-sonnet-4-5",
++      "cache_creation_input_token_cost": 3.75e-06,
++      "cache_creation_input_token_cost_above_1hr": 6e-06,
++      "cache_read_input_token_cost": 3e-07,
++      "input_cost_per_token": 3e-06,
++      "cache_creation_input_token_cost_batches": 1.875e-06,
++      "cache_read_input_token_cost_batches": 1.5e-07,
++      "deprecation_date": "2026-11-30",
++      "input_cost_per_token_batches": 1.5e-06,
++      "litellm_provider": "anthropic",
++      "max_input_tokens": 200000,
++      "max_output_tokens": 64000,
++      "max_tokens": 64000,
++      "mode": "chat",
++      "output_cost_per_token": 1.5e-05,
++      "output_cost_per_token_batches": 7.5e-06,
++      "search_context_cost_per_query": {
++        "search_context_size_high": 0.01,
++        "search_context_size_low": 0.01,
++        "search_context_size_medium": 0.01
++      },
++      "supports_assistant_prefill": true,
++      "supports_computer_use": true,
++      "supports_function_calling": true,
++      "supports_pdf_input": true,
++      "supports_prompt_caching": true,
++      "supports_reasoning": true,
++      "supports_response_schema": true,
++      "supports_native_structured_output": true,
++      "supports_tool_choice": true,
++      "supports_vision": true,
++      "prompt_cache_min_tokens": 1024,
++      "source": "https://platform.claude.com/docs/en/about-claude/pricing"
++    },
++    "gemini/gemini-2.5-pro": {
++      "key": "gemini/gemini-2.5-pro",
++      "cache_read_input_token_cost": 1.25e-07,
++      "cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
++      "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07,
++      "cache_read_input_token_cost_batches": 1.25e-07,
++      "cache_read_input_token_cost_flex": 1.25e-07,
++      "cache_read_input_token_cost_priority": 2.25e-07,
++      "input_cost_per_token": 1.25e-06,
++      "input_cost_per_token_above_200k_tokens": 2.5e-06,
++      "input_cost_per_token_priority": 2.25e-06,
++      "input_cost_per_token_above_200k_tokens_priority": 4.5e-06,
++      "litellm_provider": "gemini",
++      "max_input_tokens": 1048576,
++      "max_output_tokens": 65536,
++      "max_tokens": 65536,
++      "mode": "chat",
++      "output_cost_per_token": 1e-05,
++      "output_cost_per_token_above_200k_tokens": 1.5e-05,
++      "output_cost_per_token_priority": 1.8e-05,
++      "output_cost_per_token_above_200k_tokens_priority": 2.7e-05,
++      "rpm": 2000,
++      "source": "https://ai.google.dev/gemini-api/docs/pricing",
++      "supported_endpoints": [
++        "/v1/chat/completions",
++        "/v1/completions"
++      ],
++      "supported_modalities": [
++        "text",
++        "image",
++        "audio",
++        "video"
++      ],
++      "supported_output_modalities": [
++        "text"
++      ],
++      "supports_audio_input": true,
++      "supports_function_calling": true,
++      "supports_pdf_input": true,
++      "supports_prompt_caching": true,
++      "prompt_cache_min_tokens": 2048,
++      "supports_reasoning": true,
++      "supports_response_schema": true,
++      "supports_system_messages": true,
++      "supports_tool_choice": true,
++      "supports_video_input": true,
++      "supports_vision": true,
++      "supports_web_search": true,
++      "tpm": 800000,
++      "search_context_cost_per_query": {
++        "search_context_size_low": 0.035,
++        "search_context_size_medium": 0.035,
++        "search_context_size_high": 0.035
++      },
++      "google_maps_grounding_cost_per_query": 0.025,
++      "input_cost_per_token_batches": 6.25e-07,
++      "input_cost_per_token_flex": 6.25e-07,
++      "output_cost_per_token_batches": 5e-06,
++      "output_cost_per_token_flex": 5e-06,
++      "supports_url_context": true
++    },
++    "anthropic.claude-sonnet-4-5-20250929-v1:0": {
++      "key": "anthropic.claude-sonnet-4-5-20250929-v1:0",
++      "cache_creation_input_token_cost": 3.75e-06,
++      "cache_creation_input_token_cost_above_1hr": 6e-06,
++      "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05,
++      "cache_read_input_token_cost": 3e-07,
++      "input_cost_per_token": 3e-06,
++      "input_cost_per_token_above_200k_tokens": 6e-06,
++      "output_cost_per_token_above_200k_tokens": 2.25e-05,
++      "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06,
++      "cache_read_input_token_cost_above_200k_tokens": 6e-07,
++      "deprecation_date": "2027-04-08",
++      "litellm_provider": "bedrock_converse",
++      "supports_tool_search": true,
++      "max_input_tokens": 200000,
++      "max_output_tokens": 64000,
++      "max_tokens": 64000,
++      "mode": "chat",
++      "output_cost_per_token": 1.5e-05,
++      "search_context_cost_per_query": {
++        "search_context_size_high": 0.01,
++        "search_context_size_low": 0.01,
++        "search_context_size_medium": 0.01
++      },
++      "supports_assistant_prefill": true,
++      "supports_computer_use": true,
++      "supports_function_calling": true,
++      "supports_pdf_input": true,
++      "supports_prompt_caching": true,
++      "supports_reasoning": true,
++      "supports_response_schema": true,
++      "supports_tool_choice": true,
++      "supports_vision": true,
++      "supports_native_structured_output": true,
++      "supports_parallel_tool_use_config": true,
++      "prompt_cache_min_tokens": 1024,
++      "input_cost_per_token_batches": 1.5e-06,
++      "output_cost_per_token_batches": 7.5e-06,
++      "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json"
++    },
++    "gemini-2.5-pro": {
++      "key": "gemini-2.5-pro",
++      "deprecation_date": "2026-10-20",
++      "cache_read_input_token_cost": 1.25e-07,
++      "cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
++      "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07,
++      "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07,
++      "cache_read_input_token_cost_flex": 1.25e-07,
++      "cache_read_input_token_cost_priority": 2.25e-07,
++      "input_cost_per_token": 1.25e-06,
++      "input_cost_per_token_above_200k_tokens": 2.5e-06,
++      "litellm_provider": "vertex_ai-language-models",
++      "max_input_tokens": 1048576,
++      "max_output_tokens": 65535,
++      "max_tokens": 65535,
++      "mode": "chat",
++      "output_cost_per_token": 1e-05,
++      "output_cost_per_token_above_200k_tokens": 1.5e-05,
++      "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
++      "supported_endpoints": [
++        "/v1/chat/completions",
++        "/v1/completions"
++      ],
++      "supported_modalities": [
++        "text",
++        "image",
++        "audio",
++        "video"
++      ],
++      "supported_output_modalities": [
++        "text"
++      ],
++      "supports_audio_input": true,
++      "supports_function_calling": true,
++      "supports_pdf_input": true,
++      "supports_prompt_caching": true,
++      "prompt_cache_min_tokens": 2048,
++      "supports_reasoning": true,
++      "supports_response_schema": true,
++      "supports_system_messages": true,
++      "supports_tool_choice": true,
++      "supports_video_input": true,
++      "supports_vision": true,
++      "supports_web_search": true,
++      "search_context_cost_per_query": {
++        "search_context_size_low": 0.035,
++        "search_context_size_medium": 0.035,
++        "search_context_size_high": 0.035
++      },
++      "google_maps_grounding_cost_per_query": 0.025,
++      "input_cost_per_token_above_200k_tokens_priority": 4.5e-06,
++      "input_cost_per_token_batches": 6.25e-07,
++      "input_cost_per_token_flex": 6.25e-07,
++      "input_cost_per_token_priority": 2.25e-06,
++      "output_cost_per_token_above_200k_tokens_priority": 2.7e-05,
++      "output_cost_per_token_batches": 5e-06,
++      "output_cost_per_token_flex": 5e-06,
++      "output_cost_per_token_priority": 1.8e-05
++    },
++    "vertex_ai/claude-opus-4-6": {
++      "key": "vertex_ai/claude-opus-4-6",
++      "deprecation_date": "2027-02-05",
++      "regional_endpoint_uplift_multiplier": 1.1,
++      "supports_adaptive_thinking": true,
++      "supports_legacy_thinking": true,
++      "cache_creation_input_token_cost": 6.25e-06,
++      "cache_creation_input_token_cost_above_1hr": 1e-05,
++      "cache_creation_input_token_cost_batches": 3.125e-06,
++      "cache_read_input_token_cost": 5e-07,
++      "cache_read_input_token_cost_batches": 2.5e-07,
++      "input_cost_per_token": 5e-06,
++      "input_cost_per_token_batches": 2.5e-06,
++      "litellm_provider": "vertex_ai-anthropic_models",
++      "max_input_tokens": 1000000,
++      "max_output_tokens": 128000,
++      "max_tokens": 128000,
++      "mode": "chat",
++      "output_cost_per_token": 2.5e-05,
++      "output_cost_per_token_batches": 1.25e-05,
++      "search_context_cost_per_query": {
++        "search_context_size_high": 0.01,
++        "search_context_size_low": 0.01,
++        "search_context_size_medium": 0.01
++      },
++      "supports_assistant_prefill": false,
++      "supports_computer_use": true,
++      "supports_function_calling": true,
++      "supports_pdf_input": true,
++      "supports_prompt_caching": true,
++      "supports_reasoning": true,
++      "supports_response_schema": true,
++      "supports_tool_choice": true,
++      "supports_vision": true,
++      "supports_output_config": true,
++      "supports_max_reasoning_effort": true,
++      "prompt_cache_min_tokens": 4096,
++      "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
++    },
++    "vertex_ai/gemini-3-pro-preview": {
++      "key": "vertex_ai/gemini-3-pro-preview",
++      "cache_read_input_token_cost": 2e-07,
++      "cache_read_input_token_cost_above_200k_tokens": 4e-07,
++      "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07,
++      "input_cost_per_token": 2e-06,
++      "input_cost_per_token_above_200k_tokens": 4e-06,
++      "input_cost_per_token_batches": 1e-06,
++      "litellm_provider": "vertex_ai",
++      "max_input_tokens": 1048576,
++      "max_output_tokens": 65535,
++      "max_tokens": 65535,
++      "mode": "chat",
++      "output_cost_per_token": 1.2e-05,
++      "output_cost_per_token_above_200k_tokens": 1.8e-05,
++      "output_cost_per_token_batches": 6e-06,
++      "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
++      "supported_endpoints": [
++        "/v1/chat/completions",
++        "/v1/completions",
++        "/v1/batch"
++      ],
++      "supported_modalities": [
++        "text",
++        "image",
++        "audio",
++        "video"
++      ],
++      "supported_output_modalities": [
++        "text"
++      ],
++      "supports_audio_input": true,
++      "supports_function_calling": true,
++      "supports_pdf_input": true,
++      "supports_prompt_caching": true,
++      "supports_reasoning": true,
++      "supports_response_schema": true,
++      "supports_system_messages": true,
++      "supports_tool_choice": true,
++      "supports_video_input": true,
++      "supports_vision": true,
++      "supports_web_search": true,
++      "supports_native_streaming": true,
++      "input_cost_per_token_priority": 3.6e-06,
++      "input_cost_per_token_above_200k_tokens_priority": 7.2e-06,
++      "output_cost_per_token_priority": 2.16e-05,
++      "output_cost_per_token_above_200k_tokens_priority": 3.24e-05,
++      "cache_read_input_token_cost_priority": 3.6e-07,
++      "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07,
++      "search_context_cost_per_query": {
++        "search_context_size_low": 0.014,
++        "search_context_size_medium": 0.014,
++        "search_context_size_high": 0.014
++      },
++      "web_search_billing_unit": "per_query"
++    },
++    "azure_ai/claude-opus-4-6": {
++      "key": "azure_ai/claude-opus-4-6",
++      "deprecation_date": "2027-02-02",
++      "supports_adaptive_thinking": true,
++      "supports_legacy_thinking": true,
++      "input_cost_per_token": 5e-06,
++      "output_cost_per_token": 2.5e-05,
++      "litellm_provider": "azure_ai",
++      "max_input_tokens": 1000000,
++      "max_output_tokens": 128000,
++      "max_tokens": 128000,
++      "mode": "chat",
++      "search_context_cost_per_query": {
++        "search_context_size_high": 0.01,
++        "search_context_size_low": 0.01,
++        "search_context_size_medium": 0.01
++      },
++      "cache_creation_input_token_cost": 6.25e-06,
++      "cache_creation_input_token_cost_above_1hr": 1e-05,
++      "cache_read_input_token_cost": 5e-07,
++      "supports_assistant_prefill": false,
++      "supports_computer_use": true,
++      "supports_function_calling": true,
++      "supports_pdf_input": true,
++      "supports_prompt_caching": true,
++      "supports_reasoning": true,
++      "supports_response_schema": true,
++      "supports_tool_choice": true,
++      "supports_vision": true,
++      "supports_output_config": true,
++      "supports_max_reasoning_effort": true,
++      "prompt_cache_min_tokens": 4096
++    }
++  }
++}
+
✏️ changedtests/sdk/llm/test_budget_denial_http.py+192 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
+index 5f626dded..d319ebf44 100644
 --- a/tests/sdk/llm/test_budget_denial_http.py
 +++ b/tests/sdk/llm/test_budget_denial_http.py
-@@ -7,7 +7,10 @@ import pytest
+@@ -1,13 +1,20 @@
+ import json
+ from collections import deque
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
++from pathlib import Path
+ from threading import Thread
+ 
+ import pytest
  from pydantic import SecretStr
  
++import openhands.sdk.llm.llm as llm_module
++from openhands.sdk import Agent, Conversation
++from openhands.sdk.conversation.state import ConversationExecutionStatus
  from openhands.sdk.llm import LLM, Message, TextContent
 -from openhands.sdk.llm.exceptions import LLMRateLimitError
 +from openhands.sdk.llm.call_context import LLMCallContext
@@ -2158,7 +2574,85 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @pytest.fixture -@@ -145,3 +148,116 @@ async def test_http_rate_limit_retry_and_budget_recovery( +@@ -42,6 +49,77 @@ def provider(): + thread.join() + + ++@pytest.mark.parametrize( ++ "model", ["anthropic/claude-sonnet-4-5", "gemini/gemini-2.5-pro"] ++) ++@pytest.mark.parametrize("use_async", [False, True], ids=["run", "arun"]) ++async def test_optional_search_prices_allow_ordinary_conversation_over_http( ++ provider, tmp_path, monkeypatch, model, use_async ++): ++ base_url, replies, requests = provider ++ fixture = ( ++ Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" ++ ) ++ key = "claude-sonnet-4-5" if model.startswith("anthropic/") else model ++ info = json.loads(fixture.read_text())["model_info"][key] ++ monkeypatch.setattr(llm_module, "get_litellm_model_info", lambda **_: info) ++ if model.startswith("anthropic/"): ++ response = { ++ "id": "message-budget", ++ "type": "message", ++ "role": "assistant", ++ "model": "claude-sonnet-4-5", ++ "content": [{"type": "text", "text": "done"}], ++ "stop_reason": "end_turn", ++ "usage": {"input_tokens": 100, "output_tokens": 5}, ++ } ++ else: ++ response = { ++ "candidates": [ ++ { ++ "content": {"role": "model", "parts": [{"text": "done"}]}, ++ "finishReason": "STOP", ++ "index": 0, ++ } ++ ], ++ "usageMetadata": { ++ "promptTokenCount": 100, ++ "candidatesTokenCount": 5, ++ "totalTokenCount": 105, ++ }, ++ "modelVersion": "gemini-2.5-pro", ++ } ++ replies.append((200, response)) ++ conv = Conversation( ++ agent=Agent( ++ llm=LLM( ++ model=model, ++ base_url=base_url, ++ api_key=SecretStr("fixture-key"), ++ num_retries=0, ++ max_output_tokens=100, ++ ), ++ tools=[], ++ ), ++ workspace=str(tmp_path), ++ persistence_dir=str(tmp_path / "persist"), ++ max_budget_per_run=50, ++ visualizer=None, ++ ) ++ try: ++ conv.send_message("Reply briefly.") ++ if use_async: ++ await conv.arun() ++ else: ++ conv.run() ++ ++ assert conv.state.execution_status == ConversationExecutionStatus.FINISHED ++ assert len(requests) == 1 ++ assert conv.conversation_stats.get_combined_metrics().accumulated_cost > 0 ++ finally: ++ conv.close() ++ ++ + @pytest.mark.asyncio + @pytest.mark.parametrize("api_mode", ["chat", "responses"]) + @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) +@@ -145,3 +223,116 @@ async def test_http_rate_limit_retry_and_budget_recovery( response = await call() assert response.message.content == [TextContent(text="resumed")] assert len(requests) == 2 @@ -2275,12 +2769,12 @@

Failed attempts cannot receive a blind refund

A separate deterministi + assert len(requests) == 1 + assert budget.reserved == 0 + assert 0 < budget.spent <= budget.limit -

🟢 addedtests/sdk/llm/test_cost_budget.py+610 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
+
🟢 addedtests/sdk/llm/test_cost_budget.py+869 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
 new file mode 100644
-index 000000000..ec78d5a44
+index 000000000..e73f8b850
 --- /dev/null
 +++ b/tests/sdk/llm/test_cost_budget.py
-@@ -0,0 +1,610 @@
+@@ -0,0 +1,869 @@
 +import asyncio
 +import inspect
 +import json
@@ -2734,17 +3228,215 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + + +@pytest.mark.parametrize( ++ "model,catalog_key", ++ [ ++ ("anthropic/claude-sonnet-4-5", "claude-sonnet-4-5"), ++ ("gemini/gemini-2.5-pro", "gemini/gemini-2.5-pro"), ++ ( ++ "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0", ++ "anthropic.claude-sonnet-4-5-20250929-v1:0", ++ ), ++ ("vertex_ai/gemini-2.5-pro", "gemini-2.5-pro"), ++ ("vertex_ai/claude-opus-4-6", "vertex_ai/claude-opus-4-6"), ++ ("vertex_ai/gemini-3-pro-preview", "vertex_ai/gemini-3-pro-preview"), ++ ("azure_ai/claude-opus-4-6", "azure_ai/claude-opus-4-6"), ++ ], ++) ++@pytest.mark.parametrize( ++ "invoke,provider_index", ++ [ ++ (LLM.completion, 0), ++ (LLM.acompletion, 1), ++ (LLM.responses, 2), ++ (LLM.aresponses, 3), ++ ], ++) ++async def test_budget_allows_ordinary_provider_calls_with_optional_search_prices( ++ budget_call, monkeypatch, model, catalog_key, invoke, provider_index ++): ++ info, make_llm, messages, providers = budget_call ++ fixture = ( ++ Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" ++ ) ++ info.clear() ++ info.update(json.loads(fixture.read_text())["model_info"][catalog_key]) ++ # The underlying provider functions are test doubles; optional Vertex ++ # package availability is checked separately by test_vertex_preflight. ++ monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) ++ llm = make_llm(model=model, native_tool_calling=True) ++ ++ result = invoke( ++ llm, ++ messages, ++ tools=list(FinishTool.create()), ++ call_context=LLMCallContext(cost_budget=CostBudget(50)), ++ ) ++ if inspect.isawaitable(result): ++ result = await result ++ ++ assert result.message.content == [TextContent(text="ok")] ++ providers[provider_index].assert_called_once() ++ assert providers[provider_index].call_args.kwargs["tools"][0]["type"] == "function" ++ ++ ++@pytest.mark.parametrize( ++ "invoke", [LLM.completion, LLM.acompletion, LLM.responses, LLM.aresponses] ++) ++async def test_budget_reserves_vertex_regional_endpoint_premium( ++ budget_call, monkeypatch, invoke ++): ++ info, make_llm, messages, providers = budget_call ++ fixture = ( ++ Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" ++ ) ++ info.clear() ++ info.update( ++ json.loads(fixture.read_text())["model_info"]["vertex_ai/claude-opus-4-6"] ++ ) ++ monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) ++ llm = make_llm(model="vertex_ai/claude-opus-4-6") ++ ++ # Published context/cache tiers require $10.0025 before the endpoint's ++ # 10% regional premium. $10.50 must not admit the $11.00275 bound. ++ with pytest.raises(LLMBudgetExceededError): ++ result = invoke( ++ llm, messages, call_context=LLMCallContext(cost_budget=CostBudget(10.5)) ++ ) ++ if inspect.isawaitable(result): ++ await result ++ ++ for provider in providers: ++ provider.assert_not_called() ++ ++ ++@pytest.mark.parametrize( ++ "invoke,provider_index", ++ [ ++ (LLM.completion, 0), ++ (LLM.acompletion, 1), ++ (LLM.responses, 2), ++ (LLM.aresponses, 3), ++ ], ++) ++@pytest.mark.parametrize("custom_prices", [False, True]) ++async def test_budget_does_not_refund_unverified_endpoint_premium( ++ budget_call, monkeypatch, invoke, provider_index, custom_prices ++): ++ info, make_llm, messages, providers = budget_call ++ fixture = ( ++ Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" ++ ) ++ info.clear() ++ info.update( ++ json.loads(fixture.read_text())["model_info"]["vertex_ai/claude-opus-4-6"] ++ ) ++ monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) ++ pricing = ( ++ {"input_cost_per_token": 0.000001, "output_cost_per_token": 0.000001} ++ if custom_prices ++ else {} ++ ) ++ llm = make_llm(model="vertex_ai/claude-opus-4-6", **pricing) ++ budget = CostBudget(12) ++ ++ async def call(): ++ result = invoke(llm, messages, call_context=LLMCallContext(cost_budget=budget)) ++ return await result if inspect.isawaitable(result) else result ++ ++ result = await call() ++ assert result.message.content == [TextContent(text="ok")] ++ recorded_cost = llm.metrics.accumulated_cost ++ assert recorded_cost > 0 ++ if custom_prices: ++ await call() ++ assert budget.spent == pytest.approx(llm.metrics.accumulated_cost) ++ assert providers[provider_index].call_count == 2 ++ else: ++ # Positive token telemetry does not establish that the endpoint's ++ # premium was included. Keep the inclusive bound for this run. ++ with pytest.raises(LLMBudgetExceededError): ++ await call() ++ assert llm.metrics.accumulated_cost == recorded_cost ++ assert budget.spent > recorded_cost ++ providers[provider_index].assert_called_once() ++ ++ ++def test_budget_counts_reported_cost_above_endpoint_reservation( ++ budget_call, monkeypatch ++): ++ info, make_llm, messages, providers = budget_call ++ fixture = ( ++ Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" ++ ) ++ info.clear() ++ info.update( ++ json.loads(fixture.read_text())["model_info"]["vertex_ai/claude-opus-4-6"] ++ ) ++ monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) ++ monkeypatch.setattr(telemetry_module, "litellm_completion_cost", lambda **_: 20) ++ llm = make_llm(model="vertex_ai/claude-opus-4-6") ++ context = LLMCallContext(cost_budget=CostBudget(31)) ++ ++ llm.completion(messages, call_context=context) ++ with pytest.raises(LLMBudgetExceededError): ++ llm.completion(messages, call_context=context) ++ ++ assert llm.metrics.accumulated_cost == 20 ++ providers[0].assert_called_once() ++ ++ ++@pytest.mark.parametrize("custom_prices", [False, True]) ++def test_vertex_budget_requires_known_endpoint_premium_or_custom_rates( ++ budget_call, monkeypatch, custom_prices ++): ++ info, make_llm, messages, providers = budget_call ++ # The pinned LiteLLM fallback contains token prices and the context cap ++ # but lacks the regional endpoint premium in the current public catalog. ++ packaged = files("litellm").joinpath("model_prices_and_context_window_backup.json") ++ info.clear() ++ info.update( ++ key="vertex_ai/claude-opus-4-6", ++ **json.loads(packaged.read_text())["vertex_ai/claude-opus-4-6"], ++ ) ++ monkeypatch.setattr(llm_module, "assert_vertex_sdk_available", lambda _: None) ++ pricing = ( ++ {"input_cost_per_token": 0.000001, "output_cost_per_token": 0.000001} ++ if custom_prices ++ else {} ++ ) ++ llm = make_llm(model="vertex_ai/claude-opus-4-6", **pricing) ++ context = LLMCallContext(cost_budget=CostBudget(10.5)) ++ ++ if custom_prices: ++ result = llm.completion(messages, call_context=context) ++ assert result.message.content == [TextContent(text="ok")] ++ providers[0].assert_called_once() ++ else: ++ with pytest.raises(LLMBudgetExceededError, match="endpoint premium"): ++ llm.completion(messages, call_context=context) ++ providers[0].assert_not_called() ++ ++ ++@pytest.mark.parametrize( + "invoke,tool_type", -+ [(LLM.responses, "web_search_preview"), (LLM.aresponses, "web_search")], ++ [ ++ (LLM.completion, "web_search"), ++ (LLM.acompletion, "web_search"), ++ (LLM.responses, "web_search_preview"), ++ (LLM.aresponses, "web_search"), ++ ], +) +async def test_budget_refuses_prepared_native_search_tools( + budget_call, monkeypatch, invoke, tool_type +): + _, make_llm, messages, providers = budget_call -+ monkeypatch.setattr( -+ FinishTool, "to_responses_tool", lambda *args, **kwargs: {"type": tool_type} ++ method = ( ++ "to_openai_tool" ++ if invoke in (LLM.completion, LLM.acompletion) ++ else "to_responses_tool" + ) -+ llm = make_llm() ++ monkeypatch.setattr(FinishTool, method, lambda *args, **kwargs: {"type": tool_type}) ++ llm = make_llm(native_tool_calling=True) + + with pytest.raises(LLMBudgetExceededError): + result = invoke( @@ -2761,6 +3453,42 @@

Failed attempts cannot receive a blind refund

A separate deterministi + + +@pytest.mark.parametrize( ++ "invoke", [LLM.completion, LLM.acompletion, LLM.responses, LLM.aresponses] ++) ++@pytest.mark.parametrize( ++ "kwargs", ++ [ ++ {"web_search_options": {}}, ++ {"functions": [{"google_search": {}}]}, ++ {"cached_content": "cachedContents/opaque-hosted-tools"}, ++ ], ++) ++async def test_budget_refuses_explicit_or_legacy_provider_search( ++ budget_call, invoke, kwargs ++): ++ info, make_llm, messages, providers = budget_call ++ fixture = ( ++ Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" ++ ) ++ info.clear() ++ info.update(json.loads(fixture.read_text())["model_info"]["gemini/gemini-2.5-pro"]) ++ llm = make_llm(model="gemini/gemini-2.5-pro") ++ ++ with pytest.raises(LLMBudgetExceededError): ++ result = invoke( ++ llm, ++ messages, ++ call_context=LLMCallContext(cost_budget=CostBudget(50)), ++ **kwargs, ++ ) ++ if inspect.isawaitable(result): ++ await result ++ ++ for provider in providers: ++ provider.assert_not_called() ++ ++ ++@pytest.mark.parametrize( + "model,canonical,key,provider", + [ + ("openai/gpt-4o-mini-search-preview-2025-03-11", None, None, "openai"), @@ -2769,6 +3497,17 @@

Failed attempts cannot receive a blind refund

A separate deterministi + ("openai/gpt-4o-search-preview", "gpt-4o-mini", "gpt-4o-mini", "openai"), + ("openai/generated-alias", None, "gpt-4o-search-preview", "openai"), + ("perplexity/sonar", None, "perplexity/sonar", "perplexity"), ++ ("perplexity/sonar", None, None, "perplexity"), ++ ("litellm_proxy/sonar", None, None, None), ++ ("openrouter/perplexity/sonar-pro", None, None, None), ++ ( ++ "gemini/deep-research-pro-preview", ++ None, ++ "gemini/deep-research-pro-preview", ++ "gemini", ++ ), ++ ("unknown/gemini-2.5-pro", None, "gemini-2.5-pro", "unknown"), ++ ("gemini/gemini-live-preview", None, "gemini-live-preview", "gemini"), + ], +) +def test_budget_keeps_intrinsic_or_unresolved_search_fees_closed( @@ -2779,9 +3518,15 @@

Failed attempts cannot receive a blind refund

A separate deterministi + info.update( + key=key, + litellm_provider=provider, ++ mode="realtime" if "live" in key else "chat", + search_context_cost_per_query={"search_context_size_medium": 0.025}, + ) -+ llm = make_llm(model=model, model_canonical_name=canonical) ++ llm = make_llm( ++ model=model, ++ model_canonical_name=canonical, ++ input_cost_per_token=0.0000005, ++ output_cost_per_token=0.001, ++ ) + + with pytest.raises(LLMBudgetExceededError): + llm.completion( @@ -2824,12 +3569,20 @@

Failed attempts cannot receive a blind refund

A separate deterministi + providers[0].assert_called_once() + + -+def test_unbudgeted_call_does_not_require_price_or_caps(budget_call): ++@pytest.mark.parametrize( ++ "kwargs", ++ [ ++ {}, ++ {"functions": [{"google_search": {}}]}, ++ {"cached_content": "cachedContents/opaque-hosted-tools"}, ++ ], ++) ++def test_unbudgeted_call_does_not_require_price_or_caps(budget_call, kwargs): + info, make_llm, messages, providers = budget_call + info.clear() + llm = make_llm(max_input_tokens=None, max_output_tokens=None) + -+ result = llm.completion(messages) ++ result = llm.completion(messages, **kwargs) + + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() @@ -2893,10 +3646,10 @@

Failed attempts cannot receive a blind refund

A separate deterministi + assert budget.spent == pytest.approx(0.11)

-

06Evidence has an explicit boundary

Source manifest
-

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:115 · tests/sdk/llm/test_cost_budget.py:108 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

-
ValidationObserved result and limit
Final focused run
dceb668
122 final focused tests passed with no failures or skips. The 122 cases comprise 54 cost cases, 62 real loopback HTTP cases, and six local conversation budget cases. Edited-file and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:155 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Final broader run1,725 LLM and local/remote conversation tests passed with ordinary current public LiteLLM metadata, with no failures or skips.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
-
Historical forced-catalog diagnosis

An earlier pre-final run passed 1,698 of 1,709 cases; 11 Kimi image/model tests failed with forced packaged metadata. All 11 failures reproduced at 091882a under that catalog and passed on the repaired code with ordinary public metadata. These are historical environment/catalog results; no image/vision product patch was made. The final ordinary-metadata broader run above passed all 1,725 cases.

+

06Evidence has an explicit boundary

Source manifest
+

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:115 · tests/sdk/llm/test_cost_budget.py:108 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

+
ValidationObserved result and limit
Final focused run
3227900
191 focused Python cases passed, with no failures or skips; no paid provider calls. The 191 cases comprise 119 cost cases, 66 real loopback HTTP cases, and 6 local conversation budget cases. Edited-file and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:230 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Final broader run1,794 LLM and local/remote conversation tests passed with ordinary current public LiteLLM metadata, with no failures or skips.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
+
Historical forced-catalog diagnosis

An earlier pre-final run passed 1,698 of 1,709 cases; 11 Kimi image/model tests failed with forced packaged metadata. All 11 failures reproduced at 091882a under that catalog and passed on the repaired code with ordinary public metadata. These are historical environment/catalog results; no image/vision product patch was made. The final ordinary-metadata broader run above passed all 1,794 cases.

Commands and result provenance

Sanitized verification summaries and XML result counts recorded on 18 October 2026; raw local logs and profile data are absent from this portable page. Final focused command scope:

pytest tests/sdk/llm/test_cost_budget.py \
   tests/sdk/llm/test_budget_denial_http.py \
   tests/sdk/conversation/local/test_cost_budget_enforcement.py -q

Prior contract validation at 091882a also included:

pytest tests/sdk/llm tests/sdk/conversation \
@@ -2908,5 +3661,5 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi npm --script-shell=/bin/sh run test:coverage -- --maxWorkers=1 npm --script-shell=/bin/sh run format:check

That earlier broad run reported 2,437 passed, one existing skip, and two environment-path failures; both passed in an environment-only 11-test rerun. It is historical evidence, not a wholly green final-head run.

ⓘ Integration boundary: this SDK PR adds the run cap, not a Canvas budget meter. A meter needs matching current-run cost reporting and frontend integration; cumulative lifetime cost and a legacy task-budget property do not express this run's remaining allowance.
-

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

-
Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: dceb668511cb6ccbc9e16dd3320b1a5d2019144b. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file +

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

+
Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: 322790065776568285eb852ea36cbb260a3df47e. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file From feeb7197cc0498b72edd03af2ebc55bfb5310d49 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 21:53:44 +0200 Subject: [PATCH 15/24] fix(llm): allow ordinary Responses routes with optional search prices Treat resolved ordinary text/function models in Responses mode like chat models when search pricing metadata describes an optional hosted tool. Keep intrinsic search routes and unsupported native tools fail-closed. Capture current GPT-5.3-Codex metadata and cover the four public wrappers plus real Conversation.run/arun Responses requests over loopback HTTP. Co-authored-by: openhands --- .../openhands/sdk/llm/utils/model_features.py | 15 ++-- .../optional-search-provider-info.json | 48 ++++++++++++- tests/sdk/llm/test_budget_denial_http.py | 70 +++++++++++++++++++ tests/sdk/llm/test_cost_budget.py | 15 ++-- 4 files changed, 137 insertions(+), 11 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py index 3c5b5787d32..40d5c2493e5 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py @@ -152,11 +152,11 @@ def has_unbounded_search_cost( """Identify search fees that a token-only budget cannot safely reserve. A search price schedule alone is not proof that an ordinary call searches. - Ordinary GPT/o, Claude and Gemini chat routes search only when a request - enables a hosted tool. The caller must separately reject those tools and - opaque request overrides before using this distinction. Search-only routes - stay closed even when a dated catalog entry omits their search fee; unknown - fee-bearing routes stay closed too. + Ordinary GPT/o, Claude and Gemini chat/Responses routes search only when a + request enables a hosted tool. The caller must separately reject those tools + and opaque request overrides before using this distinction. Search-only + routes stay closed even when a dated catalog entry omits their search fee; + unknown fee-bearing routes stay closed too. """ info = model_info or {} catalog_key = info.get("key") @@ -176,7 +176,10 @@ def has_unbounded_search_cost( return True if info.get("search_context_cost_per_query") is None: return False - if not isinstance(catalog_key, str) or info.get("mode") != "chat": + if not isinstance(catalog_key, str) or info.get("mode") not in { + "chat", + "responses", + }: return True resolved_name = (_normalize_model_for_litellm(catalog_key) or "").split("/")[-1] if "search" in resolved_name: diff --git a/tests/sdk/llm/fixtures/optional-search-provider-info.json b/tests/sdk/llm/fixtures/optional-search-provider-info.json index 21f4b8e503e..50296b1d7ad 100644 --- a/tests/sdk/llm/fixtures/optional-search-provider-info.json +++ b/tests/sdk/llm/fixtures/optional-search-provider-info.json @@ -1,6 +1,6 @@ { "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json", - "captured_utc": "2026-10-18T17:45:00.739898+00:00", + "captured_utc": "2026-10-18T19:48:29.487639+00:00", "catalog_sha256": "da798353b3d14c09a13f2f1fe42eaf903212c158bb6d94d724836225c68d865d", "model_info": { "claude-sonnet-4-5": { @@ -325,6 +325,52 @@ "supports_output_config": true, "supports_max_reasoning_effort": true, "prompt_cache_min_tokens": 4096 + }, + "gpt-5.3-codex": { + "key": "gpt-5.3-codex", + "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_priority": 3.5e-07, + "input_cost_per_token": 1.75e-06, + "input_cost_per_token_priority": 3.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1.4e-05, + "output_cost_per_token_priority": 2.8e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "source": "https://developers.openai.com/api/docs/pricing", + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_tool_search": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": false, + "supports_xhigh_reasoning_effort": false, + "supports_minimal_reasoning_effort": true, + "deprecation_date": "2027-04-01" } } } diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py index d319ebf440f..3ce395e0c6d 100644 --- a/tests/sdk/llm/test_budget_denial_http.py +++ b/tests/sdk/llm/test_budget_denial_http.py @@ -120,6 +120,76 @@ async def test_optional_search_prices_allow_ordinary_conversation_over_http( conv.close() +@pytest.mark.parametrize("model", ["openai/gpt-5.3-codex", "openhands/gpt-5.3-codex"]) +@pytest.mark.parametrize("use_async", [False, True], ids=["run", "arun"]) +async def test_optional_search_prices_allow_responses_conversation_over_http( + provider, tmp_path, monkeypatch, model, use_async +): + base_url, replies, requests = provider + fixture = ( + Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" + ) + info = json.loads(fixture.read_text())["model_info"]["gpt-5.3-codex"] + monkeypatch.setattr(llm_module, "get_litellm_model_info", lambda **_: info) + replies.append( + ( + 200, + { + "id": "resp-budget-codex", + "object": "response", + "created_at": 1, + "model": "gpt-5.3-codex", + "status": "completed", + "parallel_tool_calls": False, + "tool_choice": "auto", + "tools": [], + "output": [ + { + "type": "message", + "id": "msg-budget-codex", + "role": "assistant", + "status": "completed", + "content": [ + {"type": "output_text", "text": "done", "annotations": []} + ], + } + ], + "usage": {"input_tokens": 100, "output_tokens": 5, "total_tokens": 105}, + }, + ) + ) + conv = Conversation( + agent=Agent( + llm=LLM( + model=model, + base_url=base_url, + api_key=SecretStr("fixture-key"), + num_retries=0, + max_output_tokens=100, + ), + tools=[], + ), + workspace=str(tmp_path), + persistence_dir=str(tmp_path / "persist"), + max_budget_per_run=50, + visualizer=None, + ) + try: + conv.send_message("Reply briefly.") + if use_async: + await conv.arun() + else: + conv.run() + + assert conv.state.execution_status == ConversationExecutionStatus.FINISHED + assert len(requests) == 1 + assert requests[0]["input"] + assert requests[0]["max_output_tokens"] == 100 + assert conv.conversation_stats.get_combined_metrics().accumulated_cost > 0 + finally: + conv.close() + + @pytest.mark.asyncio @pytest.mark.parametrize("api_mode", ["chat", "responses"]) @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index e73f8b850fe..51ee16a3034 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -463,6 +463,8 @@ async def test_budget_allows_plain_function_calls_with_optional_search_prices( ("vertex_ai/claude-opus-4-6", "vertex_ai/claude-opus-4-6"), ("vertex_ai/gemini-3-pro-preview", "vertex_ai/gemini-3-pro-preview"), ("azure_ai/claude-opus-4-6", "azure_ai/claude-opus-4-6"), + ("openai/gpt-5.3-codex", "gpt-5.3-codex"), + ("openhands/gpt-5.3-codex", "gpt-5.3-codex"), ], ) @pytest.mark.parametrize( @@ -652,21 +654,26 @@ def test_vertex_budget_requires_known_endpoint_premium_or_custom_rates( async def test_budget_refuses_prepared_native_search_tools( budget_call, monkeypatch, invoke, tool_type ): - _, make_llm, messages, providers = budget_call + info, make_llm, messages, providers = budget_call + fixture = ( + Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" + ) + info.clear() + info.update(json.loads(fixture.read_text())["model_info"]["gpt-5.3-codex"]) method = ( "to_openai_tool" if invoke in (LLM.completion, LLM.acompletion) else "to_responses_tool" ) monkeypatch.setattr(FinishTool, method, lambda *args, **kwargs: {"type": tool_type}) - llm = make_llm(native_tool_calling=True) + llm = make_llm(model="openai/gpt-5.3-codex", native_tool_calling=True) - with pytest.raises(LLMBudgetExceededError): + with pytest.raises(LLMBudgetExceededError, match="native/legacy server tools"): result = invoke( llm, messages, tools=list(FinishTool.create()), - call_context=LLMCallContext(cost_budget=CostBudget(0.2)), + call_context=LLMCallContext(cost_budget=CostBudget(50)), ) if inspect.isawaitable(result): await result From 7e375a91be1bbe842601244a27a44bb322f922e5 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sun, 18 Oct 2026 21:54:54 +0200 Subject: [PATCH 16/24] docs: refresh Responses budget review evidence Document ordinary Responses-mode support, actual HTTP conversation regressions, and final validation without changing historical evidence or the remaining Canvas reporting boundary. Co-authored-by: openhands --- .pr/budget-design.html | 289 +++++++++++++++++++++++++++++------------ 1 file changed, 208 insertions(+), 81 deletions(-) diff --git a/.pr/budget-design.html b/.pr/budget-design.html index f53957c973d..7b9582398f1 100644 --- a/.pr/budget-design.html +++ b/.pr/budget-design.html @@ -4,10 +4,10 @@ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.6 var(--sans)}.wrap{display:grid;grid-template-columns:192px minmax(0,1fr);gap:44px;max-width:1210px;margin:auto;padding:38px 32px 80px}nav{position:sticky;top:30px;align-self:start;font-size:13px}nav h2{font-size:10px;text-transform:uppercase;letter-spacing:.13em;color:var(--muted);margin:0 0 14px}nav ol{list-style:none;margin:0;padding:0;counter-reset:toc}nav li{counter-increment:toc;margin:10px 0}nav li::before{content:counter(toc,decimal-leading-zero) " ";color:var(--subtle);font:11px var(--mono)}nav a{color:var(--fg);text-decoration:none}nav small{display:block;margin-top:24px;color:var(--muted);line-height:1.6}a{color:var(--accent);text-underline-offset:3px}a:hover{color:var(--warn)}header{padding-bottom:24px;border-bottom:1px solid var(--rule)}.eyebrow{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);font-weight:750}h1{font-size:40px;line-height:1.12;letter-spacing:-.04em;margin:13px 0 16px;max-width:760px}.sub{font-size:18px;line-height:1.6;color:var(--muted);max-width:760px;margin:0}.meta{font:11px/1.8 var(--mono);color:var(--muted);margin-top:17px;overflow-wrap:anywhere}h2{font-size:25px;line-height:1.25;letter-spacing:-.025em;margin:0 0 14px}h2 .num{color:var(--subtle);font:13px var(--mono);margin-right:12px}section{padding-top:40px;scroll-margin-top:24px}h3{font-size:17px;line-height:1.35;margin:23px 0 8px}p{margin:10px 0 14px}code{font:.87em var(--mono);padding:2px 4px;background:var(--code-bg);border-radius:3px}pre{font:12px/1.65 var(--mono);padding:16px;background:var(--code-bg);border:1px solid var(--rule);border-radius:5px;overflow:auto;margin:8px 0}pre code{font:inherit;padding:0;background:none}.kw{color:var(--accent);font-weight:700}.com{color:var(--subtle)}.str{color:var(--ok)}.ba{display:grid;grid-template-columns:1fr 1fr;gap:16px}.label{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:750}.callout{margin:22px 0 20px;border-left:3px solid var(--accent);border-radius:0 5px 5px 0;background:var(--accent-soft);padding:13px 16px;font-size:14px}.callout.warn{border-color:var(--warn);background:var(--warn-soft)}.callout.note{border-color:var(--subtle);background:var(--code-bg)}.sources{font-size:12px;color:var(--muted);margin:8px 0 16px;line-height:1.8}.src{font:11px/1.7 var(--mono);text-decoration:none;border-bottom:1px dotted var(--accent);overflow-wrap:anywhere}.sources .src{font-size:10.5px}table{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}th,td{text-align:left;vertical-align:top;padding:10px 11px;border-bottom:1px solid var(--rule)}th{font-size:11px;color:var(--muted);background:var(--code-bg)}td:first-child{width:24%;font-weight:650}.fig{margin:18px 0 10px}.fig svg{display:block;width:100%;height:auto;background:var(--card);border:1px solid var(--rule);border-radius:6px}.fig figcaption{font-size:12px;line-height:1.5;color:var(--muted);margin:8px 2px}details{border:1px solid var(--rule);border-radius:5px;margin:10px 0}summary{cursor:pointer;padding:9px 12px;font-size:12px;color:var(--muted)}details>div{padding:3px 12px 12px}details pre{margin:0;border:0;border-radius:0;font-size:10.5px;max-height:550px}details.manifest{border:0;border-bottom:1px solid var(--rule);border-radius:0;margin:-4px 0 18px}details.manifest summary{padding:5px 0;font-size:11px}details.manifest>div{padding:3px 0 10px}.files summary{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}.files .path{font:11px var(--mono);color:var(--fg);overflow-wrap:anywhere}.files .counts{margin-left:auto;white-space:nowrap;font:10px var(--mono)}.add{color:var(--ok)}.del{color:var(--crit)}.tag{font-size:10px;color:var(--subtle)}.group{margin:25px 0 8px;font-size:13px;font-weight:750}.files p{font-size:12px;color:var(--muted)}footer{border-top:1px solid var(--rule);margin-top:38px;padding-top:16px;color:var(--muted);font-size:11px}.pending{color:var(--warn);font-weight:700}@media(max-width:900px){.wrap{grid-template-columns:1fr;padding:24px 20px 64px;gap:25px}nav{position:static}nav ol{display:flex;flex-wrap:wrap;gap:0 17px}nav li{margin:4px 0}nav small{display:none}h1{font-size:34px}}@media(max-width:600px){.ba{grid-template-columns:1fr}h1{font-size:30px}.sub{font-size:16px}th,td{padding:8px 6px;font-size:12px}.wrap{padding:22px 14px 54px}}@media print{.wrap{display:block;padding:0}nav{display:none}details{break-inside:avoid}h2{break-after:avoid}.fig{break-inside:avoid}a{color:var(--fg)}}
-
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ 322790065776568285eb852ea36cbb260a3df47e
33 changed files · +2746 / −21 against merge base · Admission repair included

Source links open commit-pinned files; displayed path:line labels identify the exact lines.

+
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ feeb7197cc0498b72edd03af2ebc55bfb5310d49
33 changed files · +2872 / −21 against merge base · Admission repair included

Source links open commit-pinned files; displayed path:line labels identify the exact lines.

-

01The option now crosses the public API

Source manifest
-

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

+

01The option now crosses the public API

Source manifest
+

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

Before · merge base
# Local implementation only
 LocalConversation(..., max_budget_per_run=None)
 
@@ -19,20 +19,20 @@
 
 // TypeScript remote constructor or start option
 { maxBudgetPerRun: 2.5 }
-

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

-
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
+

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

+
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
-

02Reserve before each SDK LLM attempt

Source manifest
+

02Reserve before each SDK LLM attempt

Source manifest
The admission check moves before each SDK LLM attemptBefore: a local run checks accumulated lifetime metrics after a step. After: SDK LLM attempts reserve against one shared ledger before transport, then settle each attempt. Authoritative bounds, transport controls, and conservative settlement are the focused repair described in section 3.LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1950CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2129new gateCostBudget.settle()complete cost: actual; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
-

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

-

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

-

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost when it accounts for the applicable rates. Published endpoint-uplift routes retain the reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. After an attempt starts, failed, cancelled, or unknown-cost successful work also retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1163 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

+LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1950CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2129new gateCostBudget.settle()complete cost: actual; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
+

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

+

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

+

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost when it accounts for the applicable rates. Published endpoint-uplift routes retain the reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. After an attempt starts, failed, cancelled, or unknown-cost successful work also retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1163 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

-

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

+

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

⚠ The original PR's admission projection at 091882a invented rates for missing price data and a 32,768-token cap for missing limits. A finite fallback is an estimate, not an upper bound; published context tiers can also exceed the selected base rates.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:180 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1765 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1789 @ 091882a

A deterministic probe at 091882a, using packaged LiteLLM metadata for xai/grok-4-fast-reasoning, admitted a $0.1640005 reservation under a $0.20 cap while the published tier arithmetic was $0.2512004. This was metadata arithmetic with no provider request or billed usage.

Reproduction inputs and arithmetic · original PR at 091882a
input tokens:          128,001
@@ -45,25 +45,26 @@
 No credentials, network, or model completion used.
 Metadata snapshot SHA-256:
 cc1955f7f1cdc2cdd9ebf216e7eebbc90bbb3528b62439327f7e8b893eda7c12

Observed against the pre-repair admission helper; the focused regression tests belong with the repair.

-

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1781 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1857

+

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1781 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1857

Failed attempts cannot receive a blind refund

A separate deterministic fixture at 091882a followed the real SDK completion, telemetry, validation, and retry path. Two invalid-response attempts each reserved $0.11; under a $0.15 cap, SDK telemetry recorded $0.22 while the budget ledger finished at zero. A timeout fixture separately reproduced reuse of allowance for uncertain attempted work. These probes establish SDK accounting behavior, without a provider request or billing claim.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:1833 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1887 @ 091882a

-
  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Bound standard token-priced text and SDK function calls. Refuse audio/non-text output, web_search_options even when empty, prepared non-function/native server tools, nonempty legacy functions overrides, and opaque cached_content references. A search price schedule alone does not imply search work: resolved ordinary GPT/o, Claude, and Gemini chat routes are allowed when no search is requested; intrinsic search and unresolved fee-bearing routes remain closed. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost when it can account for the applicable rates. Published endpoint-uplift routes retain their full reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. Failed, cancelled, or unknown-cost successful attempts also retain at least the reserved bound; a larger recorded delta counts too. Setup or refusal before admission remains uncharged.
-
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

-

has_unbounded_search_cost() distinguishes an optional search price schedule from intrinsic search work. Resolved ordinary GPT/o, Claude, and Gemini chat routes retain text/function-call support when no search is requested, including supported Azure, Bedrock, and Vertex routes. Intrinsic search aliases, raw and dated routes, and unresolved fee-bearing routes remain refused. The shared guard checks prepared tools, including the Responses wire payload, and rejects non-function/native server tools. Nonempty legacy functions overrides and opaque cached_content references also fail before transport because they can hide hosted tools.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:146 · openhands-sdk/openhands/sdk/llm/llm.py:1889 · openhands-sdk/openhands/sdk/llm/llm.py:1893 · tests/sdk/llm/test_cost_budget.py:425 · tests/sdk/llm/test_cost_budget.py:477 · tests/sdk/llm/test_cost_budget.py:652 · tests/sdk/llm/test_cost_budget.py:689 · tests/sdk/llm/test_cost_budget.py:736 · tests/sdk/llm/fixtures/optional-search-model-info.json:1 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

-

The bot’s second review reproduced ordinary Claude and Gemini conversations failing only when a budget was enabled. Regression coverage now runs all four public LLM wrappers across resolved provider routes and exercises real Conversation.run()/arun() paths against local Anthropic/Gemini HTTP adapters; those conversations finish with positive recorded cost and exactly one provider request.

Sources: tests/sdk/llm/test_budget_denial_http.py:56

-

The resolved Vertex Claude fixture also publishes a regional endpoint uplift. Admission includes that multiplier as well as regional processing uplifts; a ceiling that covers the base token rates but misses the endpoint surcharge is refused. Older catalog snapshots can omit that premium: published pricing on those Vertex Claude routes is refused until its endpoint multiplier is known. Since installed LiteLLM telemetry may omit the premium even when the catalog includes it, successful calls retain at least the full reservation. Paired custom prices remain an explicit override. Recorded lifetime metrics are unchanged.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1850 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

-

Opaque clients, an empty web_search_options object, and audio/non-text output options still fail before transport. These cases do not add vendor-fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:369 · tests/sdk/llm/test_cost_budget.py:398

-

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/call_context.py:29

+
  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Bound standard token-priced text and SDK function calls. Refuse audio/non-text output, web_search_options even when empty, prepared non-function/native server tools, nonempty legacy functions overrides, and opaque cached_content references. A search price schedule alone does not imply search work: resolved ordinary GPT/o, Claude, and Gemini routes in chat or Responses mode are allowed when no search is requested; intrinsic search and unresolved fee-bearing routes remain closed. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost when it can account for the applicable rates. Published endpoint-uplift routes retain their full reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. Failed, cancelled, or unknown-cost successful attempts also retain at least the reserved bound; a larger recorded delta counts too. Setup or refusal before admission remains uncharged.
+
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

+

has_unbounded_search_cost() distinguishes an optional search price schedule from intrinsic search work. Resolved ordinary GPT/o, Claude, and Gemini routes retain text/function-call support in chat or Responses mode when no search is requested, including supported Azure, Bedrock, and Vertex routes. Intrinsic search aliases, raw and dated routes, and unresolved fee-bearing routes remain refused. The shared guard checks prepared tools, including the Responses wire payload, and rejects non-function/native server tools. Nonempty legacy functions overrides and opaque cached_content references also fail before transport because they can hide hosted tools.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:146 · openhands-sdk/openhands/sdk/llm/llm.py:1889 · openhands-sdk/openhands/sdk/llm/llm.py:1893 · tests/sdk/llm/test_cost_budget.py:425 · tests/sdk/llm/test_cost_budget.py:479 · tests/sdk/llm/test_cost_budget.py:654 · tests/sdk/llm/test_cost_budget.py:696 · tests/sdk/llm/test_cost_budget.py:743 · tests/sdk/llm/fixtures/optional-search-model-info.json:1 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

+

The bot’s second review reproduced ordinary Claude and Gemini conversations failing only when a budget was enabled. Regression coverage now runs all four public LLM wrappers across resolved provider routes and exercises real Conversation.run()/arun() paths against local Anthropic/Gemini HTTP adapters; those conversations finish with positive recorded cost and exactly one provider request.

Sources: tests/sdk/llm/test_budget_denial_http.py:56

+

The third review caught the same false refusal for ordinary models whose catalog mode is responses. The classification now applies the existing provider/family rules to both chat and responses. Captured gpt-5.3-codex metadata exercises all four public wrappers; direct OpenAI and OpenHands aliases also run Conversation.run()/arun() through the automatically selected Responses HTTP adapter. Native-search requests still refuse before transport.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:146 · tests/sdk/llm/test_cost_budget.py:479 · tests/sdk/llm/test_budget_denial_http.py:125 · tests/sdk/llm/test_cost_budget.py:654 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

+

The resolved Vertex Claude fixture also publishes a regional endpoint uplift. Admission includes that multiplier as well as regional processing uplifts; a ceiling that covers the base token rates but misses the endpoint surcharge is refused. Older catalog snapshots can omit that premium: published pricing on those Vertex Claude routes is refused until its endpoint multiplier is known. Since installed LiteLLM telemetry may omit the premium even when the catalog includes it, successful calls retain at least the full reservation. Paired custom prices remain an explicit override. Recorded lifetime metrics are unchanged.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1850 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

+

Opaque clients, an empty web_search_options object, and audio/non-text output options still fail before transport. These cases do not add vendor-fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:369 · tests/sdk/llm/test_cost_budget.py:398

+

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/call_context.py:29

-

04Each top-level run gets a fresh allowance

Source manifest
+

04Each top-level run gets a fresh allowance

Source manifest
Each top-level run receives a fresh allowance; delegates share itA new top-level run resets its owned ledger while lifetime metrics remain cumulative. Delegated children share the ledger and ancestor ceilings. A fork copies the configured limit but owns a separate ledger.ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

-
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
+ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

+
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
-

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
+

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
 index c508fef15..510ac4b9a 100644
 --- a/openhands-sdk/openhands/sdk/conversation/conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/conversation.py
@@ -107,7 +108,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=stuck_detection, stuck_detection_thresholds=stuck_detection_thresholds, visualizer=visualizer, -

✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
 index 4885b35ef..64ad8aff9 100644
 --- a/openhands-sdk/openhands/sdk/conversation/request.py
 +++ b/openhands-sdk/openhands/sdk/conversation/request.py
@@ -131,7 +132,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="If true, the conversation will use stuck detection to " -

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 index 907b700d3..ca4d8b0fd 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
@@ -183,7 +184,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi self.workspace = workspace self._client = workspace.client self._cleanup_initiated = False -

✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
 index ea2d8d332..30b317ac8 100644
 --- a/openhands-agent-server/openhands/agent_server/models.py
 +++ b/openhands-agent-server/openhands/agent_server/models.py
@@ -205,7 +206,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="Whether to enable stuck detection for the agent.", -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
 index e7df77d9a..49a2d879f 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py
@@ -217,7 +218,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi title=stored.title, metrics=stored.metrics, created_at=stored.created_at, -

✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
 index 83721a6da..923713fbe 100644
 --- a/openhands-agent-server/openhands/agent_server/event_service.py
 +++ b/openhands-agent-server/openhands/agent_server/event_service.py
@@ -229,7 +230,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=self.stored.stuck_detection, visualizer=None, secrets=self.stored.secrets, -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
 index 23968b2d8..dc8e39b81 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py
@@ -250,7 +251,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) ) from e -

✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
+
✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
 index bfd1b6eec..f887053d8 100644
 --- a/clients/typescript/src/models/conversation.ts
 +++ b/clients/typescript/src/models/conversation.ts
@@ -288,7 +289,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: boolean; workspace: Record<string, unknown>; hook_config?: HookConfig | null; -

✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
+
✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
 index 04091d075..9a61d6257 100644
 --- a/clients/typescript/src/conversation/remote-conversation.ts
 +++ b/clients/typescript/src/conversation/remote-conversation.ts
@@ -343,7 +344,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? this.stuckDetection ?? true, workspace: { type: 'local', working_dir: this.workspace.workingDir }, hook_config: hookConfig ?? null, -

✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
+
✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
 index 8ac58e347..3f3c66fc7 100644
 --- a/clients/typescript/src/conversation/conversation-manager.ts
 +++ b/clients/typescript/src/conversation/conversation-manager.ts
@@ -368,7 +369,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? true, workspace: { type: 'local', working_dir: options.workingDir || '/tmp' }, user_id: options.userId ?? null, -

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
+

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 index 9c56cf0b1..447231fb6 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
@@ -678,7 +679,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi except Exception as e: with self._state: updated_agent_state = dict(self._state.agent_state) -

🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
+
🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
 new file mode 100644
 index 000000000..da9142417
 --- /dev/null
@@ -870,7 +871,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + + +__all__ = ["BudgetReservation", "CostBudget"] -

✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
 index 6c886595e..8dd7cb5d8 100644
 --- a/openhands-sdk/openhands/sdk/llm/call_context.py
 +++ b/openhands-sdk/openhands/sdk/llm/call_context.py
@@ -896,7 +897,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def for_conversation( self, -

✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+323 / −1

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+323 / −1

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
 index 3e304d2ee..49069f94c 100644
 --- a/openhands-sdk/openhands/sdk/llm/llm.py
 +++ b/openhands-sdk/openhands/sdk/llm/llm.py
@@ -1352,8 +1353,8 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -

✏️ changedopenhands-sdk/openhands/sdk/llm/utils/model_features.py+70 / −0

Distinguishes optional search pricing on resolved ordinary GPT/o, Claude, and Gemini chat routes from intrinsic or unresolved search fees.

openhands-sdk/openhands/sdk/llm/utils/model_features.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
-index 3b40fae04..3c5b5787d 100644
+
✏️ changedopenhands-sdk/openhands/sdk/llm/utils/model_features.py+73 / −0

Distinguishes optional search pricing on resolved ordinary GPT/o, Claude, and Gemini text/function routes in chat or Responses mode from intrinsic or unresolved search fees.

openhands-sdk/openhands/sdk/llm/utils/model_features.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
+index 3b40fae04..40d5c2493 100644
 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py
 +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
 @@ -5,6 +5,7 @@ from functools import cache
@@ -1364,7 +1365,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi from litellm.utils import supports_vision as litellm_supports_vision from openhands.sdk.llm.utils.openhands_provider import OPENHANDS_PROVIDER_PREFIX -@@ -127,6 +128,75 @@ def _real_litellm_provider( +@@ -127,6 +128,78 @@ def _real_litellm_provider( return provider @@ -1392,11 +1393,11 @@

Failed attempts cannot receive a blind refund

A separate deterministi + """Identify search fees that a token-only budget cannot safely reserve. + + A search price schedule alone is not proof that an ordinary call searches. -+ Ordinary GPT/o, Claude and Gemini chat routes search only when a request -+ enables a hosted tool. The caller must separately reject those tools and -+ opaque request overrides before using this distinction. Search-only routes -+ stay closed even when a dated catalog entry omits their search fee; unknown -+ fee-bearing routes stay closed too. ++ Ordinary GPT/o, Claude and Gemini chat/Responses routes search only when a ++ request enables a hosted tool. The caller must separately reject those tools ++ and opaque request overrides before using this distinction. Search-only ++ routes stay closed even when a dated catalog entry omits their search fee; ++ unknown fee-bearing routes stay closed too. + """ + info = model_info or {} + catalog_key = info.get("key") @@ -1416,7 +1417,10 @@

Failed attempts cannot receive a blind refund

A separate deterministi + return True + if info.get("search_context_cost_per_query") is None: + return False -+ if not isinstance(catalog_key, str) or info.get("mode") != "chat": ++ if not isinstance(catalog_key, str) or info.get("mode") not in { ++ "chat", ++ "responses", ++ }: + return True + resolved_name = (_normalize_model_for_litellm(catalog_key) or "").split("/")[-1] + if "search" in resolved_name: @@ -1440,7 +1444,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi @cache def _normalized_supported_openai_params( model: str | None, provider_hint: str | None = None -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 index da9da3806..8a34d4045 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
@@ -1464,7 +1468,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class LLMContentPolicyViolationError(LLMBadRequestError): """Provider blocked the request/response via its content filter. -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 index 878fced0d..40452d159 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
@@ -1485,7 +1489,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi return True if not isinstance(exception, (RateLimitError, LLMRateLimitError)): return False -

⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
+
⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 index 218c93a05..4e9123725 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
@@ -1505,7 +1509,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi "UserCancelledError", "OperationCancelled", # Helpers -

✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
 index 8fcc9b9bb..83ebab66d 100644
 --- a/openhands-sdk/openhands/sdk/conversation/exceptions.py
 +++ b/openhands-sdk/openhands/sdk/conversation/exceptions.py
@@ -1528,7 +1532,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class WebSocketConnectionError(RuntimeError): """Raised when WebSocket connection fails to establish within the timeout.""" -

✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
 index ed7741759..aa0b9785f 100644
 --- a/openhands-sdk/openhands/sdk/conversation/state.py
 +++ b/openhands-sdk/openhands/sdk/conversation/state.py
@@ -1549,7 +1553,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def owned(self) -> bool: """ Return True if the lock is currently held by the calling thread. -

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
+

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
 index ee6aef487..f74030443 100644
 --- a/clients/typescript/src/__tests__/api-clients.test.ts
 +++ b/clients/typescript/src/__tests__/api-clients.test.ts
@@ -1600,7 +1604,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi it('HttpClient can parse blob responses when requested', async () => { global.fetch = vi.fn().mockResolvedValue( new Response(new Blob(['zip-data']), { -

✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
+
✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
 index eff6ce516..26687efd9 100644
 --- a/tests/agent_server/test_conversation_router.py
 +++ b/tests/agent_server/test_conversation_router.py
@@ -1643,7 +1647,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_start_conversation_merges_observability_headers( client, mock_conversation_service, sample_conversation_info ): -

✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
+
✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
 index 54824cde2..56394feba 100644
 --- a/tests/agent_server/test_conversation_service.py
 +++ b/tests/agent_server/test_conversation_service.py
@@ -1767,7 +1771,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @pytest.mark.asyncio async def test_agent_tool_from_unimportable_module_rejects_create(tmp_path): """The create fails with a client error instead of every message failing.""" -

✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
+
✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
 index dc1245e3e..44d2228b1 100644
 --- a/tests/cross/test_remote_conversation_live_server.py
 +++ b/tests/cross/test_remote_conversation_live_server.py
@@ -1807,7 +1811,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_openai_chat_completions_gateway_over_real_server( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patched_llm ): -

✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
+
✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
 index 807f19a89..4b6ba77f1 100644
 --- a/tests/sdk/conversation/local/test_agent_status_transition.py
 +++ b/tests/sdk/conversation/local/test_agent_status_transition.py
@@ -1875,7 +1879,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi conversation.run() assert conversation.state.execution_status == ConversationExecutionStatus.ERROR -

🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
+
🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
 new file mode 100644
 index 000000000..eed344f1d
 --- /dev/null
@@ -2058,7 +2062,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + delete_on_close=False, + ) + assert not (persist / cid.hex / "base_state.json").exists() -

✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
+
✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
 index b9b93141c..e6d326e8e 100644
 --- a/tests/sdk/conversation/local/test_fork.py
 +++ b/tests/sdk/conversation/local/test_fork.py
@@ -2089,7 +2093,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_fork_copies_agent_state(): """agent_state dict should be carried over to the fork.""" with tempfile.TemporaryDirectory() as tmpdir: -

✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
+
✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
 index 16457eb93..075574f3a 100644
 --- a/tests/sdk/conversation/local/test_state_serialization.py
 +++ b/tests/sdk/conversation/local/test_state_serialization.py
@@ -2144,7 +2148,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_resume_with_conversation_id_mismatch_raises_error(): """Test that resuming with mismatched conversation ID raises error.""" with tempfile.TemporaryDirectory() as temp_dir: -

✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
+
✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
 index 86914e25e..5b48fe9b7 100644
 --- a/tests/sdk/conversation/remote/test_remote_conversation.py
 +++ b/tests/sdk/conversation/remote/test_remote_conversation.py
@@ -2171,7 +2175,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @patch( "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient" ) -

🟢 addedtests/sdk/llm/fixtures/optional-search-model-info.json+35 / −0

Captures public gpt-4o-mini model metadata with optional search pricing for deterministic function-call regression coverage.

tests/sdk/llm/fixtures/optional-search-model-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-model-info.json b/tests/sdk/llm/fixtures/optional-search-model-info.json
+
🟢 addedtests/sdk/llm/fixtures/optional-search-model-info.json+35 / −0

Captures public gpt-4o-mini model metadata with optional search pricing for deterministic function-call regression coverage.

tests/sdk/llm/fixtures/optional-search-model-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-model-info.json b/tests/sdk/llm/fixtures/optional-search-model-info.json
 new file mode 100644
 index 000000000..feff127e3
 --- /dev/null
@@ -2212,15 +2216,15 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + "source": "https://developers.openai.com/api/docs/pricing" + } +} -

🟢 addedtests/sdk/llm/fixtures/optional-search-provider-info.json+330 / −0

Captures public Claude and Gemini metadata, including hosted provider routes, for deterministic optional-search regression coverage.

tests/sdk/llm/fixtures/optional-search-provider-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-provider-info.json b/tests/sdk/llm/fixtures/optional-search-provider-info.json
+
🟢 addedtests/sdk/llm/fixtures/optional-search-provider-info.json+376 / −0

Captures public Claude, Gemini, and Responses-mode GPT-5.3-Codex metadata, including hosted provider routes, for deterministic optional-search regression coverage.

tests/sdk/llm/fixtures/optional-search-provider-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-provider-info.json b/tests/sdk/llm/fixtures/optional-search-provider-info.json
 new file mode 100644
-index 000000000..21f4b8e50
+index 000000000..50296b1d7
 --- /dev/null
 +++ b/tests/sdk/llm/fixtures/optional-search-provider-info.json
-@@ -0,0 +1,330 @@
+@@ -0,0 +1,376 @@
 +{
 +  "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
-+  "captured_utc": "2026-10-18T17:45:00.739898+00:00",
++  "captured_utc": "2026-10-18T19:48:29.487639+00:00",
 +  "catalog_sha256": "da798353b3d14c09a13f2f1fe42eaf903212c158bb6d94d724836225c68d865d",
 +  "model_info": {
 +    "claude-sonnet-4-5": {
@@ -2545,11 +2549,57 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + "supports_output_config": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 4096 ++ }, ++ "gpt-5.3-codex": { ++ "key": "gpt-5.3-codex", ++ "cache_read_input_token_cost": 1.75e-07, ++ "cache_read_input_token_cost_priority": 3.5e-07, ++ "input_cost_per_token": 1.75e-06, ++ "input_cost_per_token_priority": 3.5e-06, ++ "litellm_provider": "openai", ++ "max_input_tokens": 272000, ++ "max_output_tokens": 128000, ++ "max_tokens": 128000, ++ "mode": "responses", ++ "output_cost_per_token": 1.4e-05, ++ "output_cost_per_token_priority": 2.8e-05, ++ "search_context_cost_per_query": { ++ "search_context_size_high": 0.01, ++ "search_context_size_low": 0.01, ++ "search_context_size_medium": 0.01 ++ }, ++ "source": "https://developers.openai.com/api/docs/pricing", ++ "supported_endpoints": [ ++ "/v1/responses" ++ ], ++ "supported_modalities": [ ++ "text", ++ "image" ++ ], ++ "supported_output_modalities": [ ++ "text" ++ ], ++ "supports_function_calling": true, ++ "supports_native_streaming": true, ++ "supports_parallel_function_calling": true, ++ "supports_pdf_input": true, ++ "supports_prompt_caching": true, ++ "supports_reasoning": true, ++ "supports_response_schema": true, ++ "supports_system_messages": false, ++ "supports_tool_choice": true, ++ "supports_tool_search": true, ++ "supports_vision": true, ++ "supports_web_search": true, ++ "supports_none_reasoning_effort": false, ++ "supports_xhigh_reasoning_effort": false, ++ "supports_minimal_reasoning_effort": true, ++ "deprecation_date": "2027-04-01" + } + } +} -

✏️ changedtests/sdk/llm/test_budget_denial_http.py+192 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
-index 5f626dded..d319ebf44 100644
+
✏️ changedtests/sdk/llm/test_budget_denial_http.py+262 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
+index 5f626dded..3ce395e0c 100644
 --- a/tests/sdk/llm/test_budget_denial_http.py
 +++ b/tests/sdk/llm/test_budget_denial_http.py
 @@ -1,13 +1,20 @@
@@ -2574,7 +2624,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @pytest.fixture -@@ -42,6 +49,77 @@ def provider(): +@@ -42,6 +49,147 @@ def provider(): thread.join() @@ -2648,11 +2698,81 @@

Failed attempts cannot receive a blind refund

A separate deterministi + finally: + conv.close() + ++ ++@pytest.mark.parametrize("model", ["openai/gpt-5.3-codex", "openhands/gpt-5.3-codex"]) ++@pytest.mark.parametrize("use_async", [False, True], ids=["run", "arun"]) ++async def test_optional_search_prices_allow_responses_conversation_over_http( ++ provider, tmp_path, monkeypatch, model, use_async ++): ++ base_url, replies, requests = provider ++ fixture = ( ++ Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" ++ ) ++ info = json.loads(fixture.read_text())["model_info"]["gpt-5.3-codex"] ++ monkeypatch.setattr(llm_module, "get_litellm_model_info", lambda **_: info) ++ replies.append( ++ ( ++ 200, ++ { ++ "id": "resp-budget-codex", ++ "object": "response", ++ "created_at": 1, ++ "model": "gpt-5.3-codex", ++ "status": "completed", ++ "parallel_tool_calls": False, ++ "tool_choice": "auto", ++ "tools": [], ++ "output": [ ++ { ++ "type": "message", ++ "id": "msg-budget-codex", ++ "role": "assistant", ++ "status": "completed", ++ "content": [ ++ {"type": "output_text", "text": "done", "annotations": []} ++ ], ++ } ++ ], ++ "usage": {"input_tokens": 100, "output_tokens": 5, "total_tokens": 105}, ++ }, ++ ) ++ ) ++ conv = Conversation( ++ agent=Agent( ++ llm=LLM( ++ model=model, ++ base_url=base_url, ++ api_key=SecretStr("fixture-key"), ++ num_retries=0, ++ max_output_tokens=100, ++ ), ++ tools=[], ++ ), ++ workspace=str(tmp_path), ++ persistence_dir=str(tmp_path / "persist"), ++ max_budget_per_run=50, ++ visualizer=None, ++ ) ++ try: ++ conv.send_message("Reply briefly.") ++ if use_async: ++ await conv.arun() ++ else: ++ conv.run() ++ ++ assert conv.state.execution_status == ConversationExecutionStatus.FINISHED ++ assert len(requests) == 1 ++ assert requests[0]["input"] ++ assert requests[0]["max_output_tokens"] == 100 ++ assert conv.conversation_stats.get_combined_metrics().accumulated_cost > 0 ++ finally: ++ conv.close() ++ + @pytest.mark.asyncio @pytest.mark.parametrize("api_mode", ["chat", "responses"]) @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) -@@ -145,3 +223,116 @@ async def test_http_rate_limit_retry_and_budget_recovery( +@@ -145,3 +293,116 @@ async def test_http_rate_limit_retry_and_budget_recovery( response = await call() assert response.message.content == [TextContent(text="resumed")] assert len(requests) == 2 @@ -2769,12 +2889,12 @@

Failed attempts cannot receive a blind refund

A separate deterministi + assert len(requests) == 1 + assert budget.reserved == 0 + assert 0 < budget.spent <= budget.limit -

🟢 addedtests/sdk/llm/test_cost_budget.py+869 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
+
🟢 addedtests/sdk/llm/test_cost_budget.py+876 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
 new file mode 100644
-index 000000000..e73f8b850
+index 000000000..51ee16a30
 --- /dev/null
 +++ b/tests/sdk/llm/test_cost_budget.py
-@@ -0,0 +1,869 @@
+@@ -0,0 +1,876 @@
 +import asyncio
 +import inspect
 +import json
@@ -3240,6 +3360,8 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + ("vertex_ai/claude-opus-4-6", "vertex_ai/claude-opus-4-6"), + ("vertex_ai/gemini-3-pro-preview", "vertex_ai/gemini-3-pro-preview"), + ("azure_ai/claude-opus-4-6", "azure_ai/claude-opus-4-6"), ++ ("openai/gpt-5.3-codex", "gpt-5.3-codex"), ++ ("openhands/gpt-5.3-codex", "gpt-5.3-codex"), + ], +) +@pytest.mark.parametrize( @@ -3429,21 +3551,26 @@

Failed attempts cannot receive a blind refund

A separate deterministi +async def test_budget_refuses_prepared_native_search_tools( + budget_call, monkeypatch, invoke, tool_type +): -+ _, make_llm, messages, providers = budget_call ++ info, make_llm, messages, providers = budget_call ++ fixture = ( ++ Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" ++ ) ++ info.clear() ++ info.update(json.loads(fixture.read_text())["model_info"]["gpt-5.3-codex"]) + method = ( + "to_openai_tool" + if invoke in (LLM.completion, LLM.acompletion) + else "to_responses_tool" + ) + monkeypatch.setattr(FinishTool, method, lambda *args, **kwargs: {"type": tool_type}) -+ llm = make_llm(native_tool_calling=True) ++ llm = make_llm(model="openai/gpt-5.3-codex", native_tool_calling=True) + -+ with pytest.raises(LLMBudgetExceededError): ++ with pytest.raises(LLMBudgetExceededError, match="native/legacy server tools"): + result = invoke( + llm, + messages, + tools=list(FinishTool.create()), -+ call_context=LLMCallContext(cost_budget=CostBudget(0.2)), ++ call_context=LLMCallContext(cost_budget=CostBudget(50)), + ) + if inspect.isawaitable(result): + await result @@ -3646,10 +3773,10 @@

Failed attempts cannot receive a blind refund

A separate deterministi + assert budget.spent == pytest.approx(0.11)

-

06Evidence has an explicit boundary

Source manifest
-

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:115 · tests/sdk/llm/test_cost_budget.py:108 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

-
ValidationObserved result and limit
Final focused run
3227900
191 focused Python cases passed, with no failures or skips; no paid provider calls. The 191 cases comprise 119 cost cases, 66 real loopback HTTP cases, and 6 local conversation budget cases. Edited-file and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:230 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Final broader run1,794 LLM and local/remote conversation tests passed with ordinary current public LiteLLM metadata, with no failures or skips.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
-
Historical forced-catalog diagnosis

An earlier pre-final run passed 1,698 of 1,709 cases; 11 Kimi image/model tests failed with forced packaged metadata. All 11 failures reproduced at 091882a under that catalog and passed on the repaired code with ordinary public metadata. These are historical environment/catalog results; no image/vision product patch was made. The final ordinary-metadata broader run above passed all 1,794 cases.

+

06Evidence has an explicit boundary

Source manifest
+

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:115 · tests/sdk/llm/test_cost_budget.py:108 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

+
ValidationObserved result and limit
Final focused run
feeb719
203 focused Python cases passed, with no failures or skips; no paid provider calls. The 203 cases comprise 127 cost cases, 70 real loopback HTTP cases, and 6 local conversation budget cases. Edited-file and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:300 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Final broader run1,806 LLM and local/remote conversation tests passed with ordinary current public LiteLLM metadata, with no failures or skips.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
+
Historical forced-catalog diagnosis

An earlier pre-final run passed 1,698 of 1,709 cases; 11 Kimi image/model tests failed with forced packaged metadata. All 11 failures reproduced at 091882a under that catalog and passed on the repaired code with ordinary public metadata. These are historical environment/catalog results; no image/vision product patch was made. The final ordinary-metadata broader run above passed all 1,806 cases.

Commands and result provenance

Sanitized verification summaries and XML result counts recorded on 18 October 2026; raw local logs and profile data are absent from this portable page. Final focused command scope:

pytest tests/sdk/llm/test_cost_budget.py \
   tests/sdk/llm/test_budget_denial_http.py \
   tests/sdk/conversation/local/test_cost_budget_enforcement.py -q

Prior contract validation at 091882a also included:

pytest tests/sdk/llm tests/sdk/conversation \
@@ -3661,5 +3788,5 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi npm --script-shell=/bin/sh run test:coverage -- --maxWorkers=1 npm --script-shell=/bin/sh run format:check

That earlier broad run reported 2,437 passed, one existing skip, and two environment-path failures; both passed in an environment-only 11-test rerun. It is historical evidence, not a wholly green final-head run.

ⓘ Integration boundary: this SDK PR adds the run cap, not a Canvas budget meter. A meter needs matching current-run cost reporting and frontend integration; cumulative lifetime cost and a legacy task-budget property do not express this run's remaining allowance.
-

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

-
Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: 322790065776568285eb852ea36cbb260a3df47e. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file +

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

+
Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: feeb7197cc0498b72edd03af2ebc55bfb5310d49. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file From 0c527da01d3c2139808deba9d8e0cf3ebfa37e0e Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 19 Oct 2026 18:26:41 +0000 Subject: [PATCH 17/24] fix(llm): close search-named routes whose catalog entry omits the search fee `has_unbounded_search_cost()` early-returned False whenever the installed catalog entry omitted `search_context_cost_per_query`, before the search-name check that should keep those routes closed. Admission was then decided by whether a catalog entry happened to publish the fee, not by whether the route intrinsically searches: `openai/o3-deep-research` and `openai/o4-mini-deep-research` were admitted into a token-only reservation while their intrinsic search charge went unbounded, whereas `azure/o3-deep-research` was refused only because its entry carries the fee. Move the search-name check ahead of the fee-presence shortcut so a dated entry that omits the fee cannot admit a search-only route, matching the function's documented invariant. Ordinary GPT/o, Claude, Gemini and other chat/Responses routes without a search fee remain admitted. Adds a regression test over all four call wrappers using a captured catalog fixture of intrinsic-search routes that omit the fee. Co-authored-by: openhands --- .../openhands/sdk/llm/utils/model_features.py | 8 +- .../llm/fixtures/omitted-search-fee-info.json | 289 ++++++++++++++++++ tests/sdk/llm/test_cost_budget.py | 52 ++++ 3 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 tests/sdk/llm/fixtures/omitted-search-fee-info.json diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py index 40d5c2493e5..026dc36f3ed 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py @@ -172,7 +172,11 @@ def has_unbounded_search_cost( normalized, ("gpt-4o-search-preview", "gpt-4o-mini-search-preview") ) or OpenAIGPT5Config.is_model_gpt_5_search_model(normalized): return True - if normalized.split("/")[-1].startswith("sonar"): + resolved = normalized.split("/")[-1] + # Search-named routes search even without a hosted tool. This check + # must run before the fee-presence shortcut below: a dated catalog + # entry that omits the search fee cannot admit them. + if resolved.startswith("sonar") or "search" in resolved: return True if info.get("search_context_cost_per_query") is None: return False @@ -182,8 +186,6 @@ def has_unbounded_search_cost( }: return True resolved_name = (_normalize_model_for_litellm(catalog_key) or "").split("/")[-1] - if "search" in resolved_name: - return True if provider in {"openai", "azure"}: return not resolved_name.startswith(("gpt-", "o1", "o3", "o4")) if provider in { diff --git a/tests/sdk/llm/fixtures/omitted-search-fee-info.json b/tests/sdk/llm/fixtures/omitted-search-fee-info.json new file mode 100644 index 00000000000..d3c7f1cefb2 --- /dev/null +++ b/tests/sdk/llm/fixtures/omitted-search-fee-info.json @@ -0,0 +1,289 @@ +{ + "captured_utc": "2026-10-19T18:19:19.771147+00:00", + "catalog_sha256": "cc1955f7f1cdc2cdd9ebf216e7eebbc90bbb3528b62439327f7e8b893eda7c12", + "model_info": { + "aihubmix/gemini-3-flash-preview-search": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 5e-07, + "key": "aihubmix/gemini-3-flash-preview-search", + "litellm_provider": "aihubmix", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://aihubmix.com/api/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_vision": true, + "supports_web_search": true + }, + "o3-deep-research-2025-06-26": { + "cache_read_input_token_cost": 2.5e-06, + "deprecation_date": "2026-07-23", + "input_cost_per_token": 1e-05, + "key": "o3-deep-research-2025-06-26", + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 4e-05, + "source": "https://developers.openai.com/api/docs/models/o3-deep-research", + "supported_endpoints": [ + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_openai_params": [ + "logit_bias", + "max_tokens", + "max_completion_tokens", + "modalities", + "prediction", + "n", + "seed", + "stop", + "stream", + "stream_options", + "temperature", + "max_retries", + "extra_headers", + "audio", + "web_search_options", + "service_tier", + "safety_identifier", + "prompt_cache_key", + "prompt_cache_retention", + "store", + "user", + "reasoning_effort" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true + }, + "openai/o3-deep-research": { + "cache_read_input_token_cost": 2.5e-06, + "deprecation_date": "2026-07-23", + "input_cost_per_token": 1e-05, + "key": "o3-deep-research", + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 4e-05, + "source": "https://developers.openai.com/api/docs/models/o3-deep-research", + "supported_endpoints": [ + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_openai_params": [ + "logit_bias", + "max_tokens", + "max_completion_tokens", + "modalities", + "prediction", + "n", + "seed", + "stop", + "stream", + "stream_options", + "temperature", + "max_retries", + "extra_headers", + "audio", + "web_search_options", + "service_tier", + "safety_identifier", + "prompt_cache_key", + "prompt_cache_retention", + "store", + "reasoning_effort" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true + }, + "openai/o4-mini-deep-research": { + "cache_read_input_token_cost": 5e-07, + "deprecation_date": "2026-07-23", + "input_cost_per_token": 2e-06, + "key": "o4-mini-deep-research", + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-06, + "source": "https://developers.openai.com/api/docs/models/o4-mini-deep-research", + "supported_endpoints": [ + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_openai_params": [ + "logit_bias", + "max_tokens", + "max_completion_tokens", + "modalities", + "prediction", + "n", + "seed", + "stop", + "stream", + "stream_options", + "temperature", + "max_retries", + "extra_headers", + "audio", + "web_search_options", + "service_tier", + "safety_identifier", + "prompt_cache_key", + "prompt_cache_retention", + "store", + "reasoning_effort" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true + }, + "perplexity/preset/advanced-deep-research": { + "input_cost_per_token": 0, + "key": "perplexity/preset/advanced-deep-research", + "litellm_provider": "perplexity", + "mode": "responses", + "output_cost_per_token": 0, + "supported_openai_params": [ + "frequency_penalty", + "max_tokens", + "max_completion_tokens", + "presence_penalty", + "response_format", + "stream", + "temperature", + "top_p", + "max_retries", + "extra_headers", + "web_search_options" + ], + "supports_function_calling": true, + "supports_web_search": true + }, + "perplexity/preset/deep-research": { + "input_cost_per_token": 0, + "key": "perplexity/preset/deep-research", + "litellm_provider": "perplexity", + "mode": "responses", + "output_cost_per_token": 0, + "supported_openai_params": [ + "frequency_penalty", + "max_tokens", + "max_completion_tokens", + "presence_penalty", + "response_format", + "stream", + "temperature", + "top_p", + "max_retries", + "extra_headers", + "web_search_options" + ], + "supports_function_calling": true, + "supports_web_search": true + }, + "vertex_ai/deep-research-pro-preview-12-2025": { + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_batches": 1e-07, + "input_cost_per_image": 0.0011, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "key": "vertex_ai/deep-research-pro-preview-12-2025", + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_image": 0.134, + "output_cost_per_image_token": 0.00012, + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_batches": 6e-06, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "supported_openai_params": [ + "frequency_penalty", + "logit_bias", + "logprobs", + "top_logprobs", + "max_tokens", + "max_completion_tokens", + "modalities", + "prediction", + "n", + "presence_penalty", + "seed", + "stop", + "stream", + "stream_options", + "temperature", + "top_p", + "tools", + "tool_choice", + "function_call", + "functions", + "extra_headers", + "parallel_tool_calls", + "audio", + "web_search_options", + "service_tier", + "safety_identifier", + "prompt_cache_key", + "prompt_cache_retention", + "store", + "response_format" + ] + } + }, + "note": "Intrinsic-search routes whose installed catalog entry omits search_context_cost_per_query. Resolved via get_litellm_model_info with null fields dropped.", + "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" +} diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index 51ee16a3034..d2df7dfd540 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -766,6 +766,58 @@ def test_budget_keeps_intrinsic_or_unresolved_search_fees_closed( providers[0].assert_not_called() +@pytest.mark.parametrize( + "model", + [ + "openai/o3-deep-research", + "openai/o4-mini-deep-research", + "o3-deep-research-2025-06-26", + "perplexity/preset/deep-research", + "perplexity/preset/advanced-deep-research", + "vertex_ai/deep-research-pro-preview-12-2025", + "aihubmix/gemini-3-flash-preview-search", + ], +) +@pytest.mark.parametrize( + "invoke", [LLM.completion, LLM.acompletion, LLM.responses, LLM.aresponses] +) +async def test_budget_refuses_intrinsic_search_when_catalog_omits_the_fee( + budget_call, model, invoke +): + """A dated entry omitting the search fee must not open a search-only route. + + ``has_unbounded_search_cost`` early-returns on an absent + ``search_context_cost_per_query``. If that shortcut runs before the + search-name check, a token-only reservation admits these routes while the + intrinsic search charge goes unbounded. + """ + info, make_llm, messages, providers = budget_call + fixture = Path(__file__).with_name("fixtures") / "omitted-search-fee-info.json" + catalog = json.loads(fixture.read_text())["model_info"] + info.clear() + info.update(catalog[model]) + assert info.get("search_context_cost_per_query") is None + llm = make_llm( + model=model, + input_cost_per_token=0.00001, + output_cost_per_token=0.001, + ) + + with pytest.raises( + LLMBudgetExceededError, match="non-token audio/search request costs" + ): + result = invoke( + llm, + messages, + call_context=LLMCallContext(cost_budget=CostBudget(50)), + ) + if inspect.isawaitable(result): + await result + + for provider in providers: + provider.assert_not_called() + + @pytest.mark.parametrize( "name,value", [("num_retries", 2), ("model_fallbacks", ["another-model"]), ("drop_params", True)], From dfc078408f5cab9fee6df0f4340fb540a993339c Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 20 Oct 2026 05:24:02 +0000 Subject: [PATCH 18/24] fix(agent-server): close ACP budget worktree leak and admit bedrock_mantle routes Finding 1: an ACP budget was rejected only inside LocalConversation.__init__, which the agent server reaches after _create_conversation_worktree has already run ``git worktree add -b openhands/``. The conversation is never registered, so delete_conversation returns False and the leftover worktree and branch cannot be removed through any API path. Reject the budget in _create_conversation once the launched agent is known and before the worktree exists, so a rejected ACP start leaves no host-local state behind. Finding 2: has_unbounded_search_cost's provider allowlist omitted bedrock_mantle, whose catalog entries are ordinary GPT/o and Claude Responses routes with a published search fee. They fell through every branch to ``return True``, so _reserve_budget refused every call on those routes. Add bedrock_mantle (and generalize the family match to try every dot-suffix, so region and vendor namespace labels are matched past) so the ordinary families stay bounded while non-ordinary namespaces remain closed. Co-authored-by: openhands --- .../agent_server/conversation_service.py | 10 +- .../conversation/impl/local_conversation.py | 10 +- .../openhands/sdk/llm/utils/model_features.py | 43 ++- .../agent_server/test_conversation_service.py | 43 +++ .../optional-search-provider-info.json | 270 ++++++++++++++++++ tests/sdk/llm/test_budget_denial_http.py | 13 +- tests/sdk/llm/test_cost_budget.py | 5 + tests/sdk/llm/test_model_features.py | 44 +++ 8 files changed, 420 insertions(+), 18 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index 7f3df6d1047..95fdf43aaf0 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -63,7 +63,10 @@ from openhands.sdk.agent import ACPAgent from openhands.sdk.agent.acp_file_credentials import CODEX_AUTH_SECRET_NAME from openhands.sdk.agent.base import AgentBase -from openhands.sdk.conversation.impl.local_conversation import LocalConversation +from openhands.sdk.conversation.impl.local_conversation import ( + LocalConversation, + reject_acp_budget, +) from openhands.sdk.conversation.persistence_const import BASE_STATE from openhands.sdk.conversation.state import ( ConversationExecutionStatus, @@ -1600,6 +1603,11 @@ async def _create_conversation( "The agent uses tools whose modules the agent server cannot " f"import: {', '.join(missing_tools)}" ) + # Reject an unenforceable ACP budget before the worktree/branch below + # exists. ``LocalConversation.__init__`` would also reject, but by then + # this method has already run ``git worktree add`` and the conversation + # is never registered, so ``delete_conversation`` could not clean it up. + reject_acp_budget(launched.agent, request.max_budget_per_run) workspace = ( _create_conversation_worktree(worktree) if worktree is not None diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 447231fb6f8..68e586baada 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -182,7 +182,7 @@ def _copy_event_for_fork(event: Event) -> Event: return Event.model_validate_json(event.model_dump_json(exclude_none=True)) -def _reject_acp_budget( +def reject_acp_budget( agent: AgentBase | None, max_budget_per_run: float | None ) -> None: """Reject a per-run budget on an ACP agent, which cannot enforce one. @@ -191,6 +191,10 @@ def _reject_acp_budget( turn, so the SDK cannot reserve a call's worst-case cost before sending and a single prompt can exceed the ceiling. Accepting the budget would silently promise enforcement the SDK cannot deliver. + + Callers that create host-local state for a conversation (the agent server's + worktree/branch, for instance) must call this once the agent is known and + before that state exists, so a rejection leaves nothing behind. """ if max_budget_per_run is None or not isinstance(agent, ACPAgent): return @@ -443,7 +447,7 @@ def __init__( # The resumed agent is not known until after ``create``, so only a # caller-supplied agent can be checked here; the resume path re-checks # once the persisted agent is loaded. - _reject_acp_budget(agent, max_budget_per_run) + reject_acp_budget(agent, max_budget_per_run) self._state = ConversationState.create( id=desired_id, agent=agent, @@ -465,7 +469,7 @@ def __init__( # Resume path: the persisted agent is known only now. No state file # is created here (base_state.json already exists), so re-checking # the budget cannot orphan anything. - _reject_acp_budget(agent, max_budget_per_run) + reject_acp_budget(agent, max_budget_per_run) # The persisted agent carries client-tool ``Tool`` specs, but in a # fresh process the ``ClientTool`` *classes* are absent from the # global registry. Re-register them from the persisted specs so diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py index 026dc36f3ed..711f35931cf 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py @@ -103,6 +103,35 @@ def _normalize_model_for_litellm(model: str | None) -> str | None: # resolution -- doing so re-introduces the 400 UnsupportedParamsError from #5325. _PASSTHROUGH_PROVIDERS: frozenset[str] = frozenset({"litellm_proxy", "openhands"}) +# Providers whose search price is optional for their ordinary model families: +# a plain call searches only when the request enables a hosted tool, so the +# token-only reservation stays a valid ceiling once such tools are refused. +# ``bedrock_mantle`` hosts several vendor namespaces in one catalog. +_ORDINARY_SEARCH_FAMILIES: dict[str, tuple[str, ...]] = { + "openai": ("gpt-", "o1", "o3", "o4"), + "azure": ("gpt-", "o1", "o3", "o4"), + "anthropic": ("claude-",), + "azure_ai": ("claude-",), + "bedrock": ("claude-",), + "bedrock_converse": ("claude-",), + "bedrock_mantle": ("claude-", "gpt-", "o1", "o3", "o4"), + "vertex_ai-anthropic_models": ("claude-",), +} + + +def _is_ordinary_search_family(resolved_name: str, families: tuple[str, ...]) -> bool: + """Whether a resolved catalog key leaf names an ordinary searchable family. + + Bedrock keys prefix the id with a region and a vendor namespace + (``us.anthropic.claude-sonnet-5-5``, ``openai.gpt-5.5``). Every dot-suffix is + tried so the family is matched past those labels, while a suffix keeps the + id's own dots intact. + """ + parts = resolved_name.split(".") + return any( + ".".join(parts[index:]).startswith(families) for index in range(len(parts)) + ) + def _real_litellm_provider( model_info: Mapping[str, Any] | None, @@ -186,17 +215,9 @@ def has_unbounded_search_cost( }: return True resolved_name = (_normalize_model_for_litellm(catalog_key) or "").split("/")[-1] - if provider in {"openai", "azure"}: - return not resolved_name.startswith(("gpt-", "o1", "o3", "o4")) - if provider in { - "anthropic", - "azure_ai", - "bedrock", - "bedrock_converse", - "vertex_ai-anthropic_models", - }: - # Bedrock catalog keys include a region and anthropic namespace. - return not resolved_name.split(".")[-1].startswith("claude-") + families = _ORDINARY_SEARCH_FAMILIES.get(provider or "") + if families is not None: + return not _is_ordinary_search_family(resolved_name, families) if provider in {"gemini", "vertex_ai", "vertex_ai-language-models"}: return not resolved_name.startswith("gemini-") return True diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py index 56394feba0e..c419be697fa 100644 --- a/tests/agent_server/test_conversation_service.py +++ b/tests/agent_server/test_conversation_service.py @@ -252,6 +252,49 @@ async def test_rejected_acp_budget_does_not_orphan_state_for_retry(tmp_path): assert isinstance(info.agent, Agent) +@pytest.mark.asyncio +async def test_rejected_acp_budget_does_not_orphan_worktree_or_branch(tmp_path): + """A rejected ACP budget must leave no host-local state behind. + + The server plans a git worktree before constructing the conversation, so a + rejection raised only inside ``LocalConversation.__init__`` would leave the + worktree directory and its ``openhands/`` branch behind. The + conversation is never registered, so ``delete_conversation`` cannot clean + them up either. + """ + repo_dir = tmp_path / "repo" + _init_git_repo(repo_dir) + conversation_id = uuid4() + worktree_root = tmp_path / "conversation-worktrees" + request = StartConversationRequest( + conversation_id=conversation_id, + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=LocalWorkspace(working_dir=repo_dir), + confirmation_policy=NeverConfirm(), + max_budget_per_run=1.0, + worktree=True, + ) + + service = ConversationService( + conversations_dir=tmp_path / "conversations", + conversation_worktree_root=worktree_root, + ) + service._event_services = {} + with pytest.raises(CostBudgetUnsupportedError): + await service.start_conversation(request) + + worktree = worktree_root / str(conversation_id) / repo_dir.name + assert not worktree.exists() + assert ( + run_git_command( + ["git", "--no-pager", "branch", "--list", f"openhands/{conversation_id}"], + repo_dir, + ) + == "" + ) + assert not (tmp_path / "conversations" / conversation_id.hex).exists() + + @pytest.mark.asyncio async def test_agent_tool_from_unimportable_module_rejects_create(tmp_path): """The create fails with a client error instead of every message failing.""" diff --git a/tests/sdk/llm/fixtures/optional-search-provider-info.json b/tests/sdk/llm/fixtures/optional-search-provider-info.json index 50296b1d7ad..e76459c66c0 100644 --- a/tests/sdk/llm/fixtures/optional-search-provider-info.json +++ b/tests/sdk/llm/fixtures/optional-search-provider-info.json @@ -371,6 +371,276 @@ "supports_xhigh_reasoning_effort": false, "supports_minimal_reasoning_effort": true, "deprecation_date": "2027-04-01" + }, + "bedrock_mantle/openai.gpt-5.5": { + "key": "bedrock_mantle/openai.gpt-5.5", + "max_tokens": 128000, + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_flex": null, + "input_cost_per_token_priority": null, + "cache_creation_input_token_cost": null, + "cache_creation_input_token_cost_above_200k_tokens": null, + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_200k_tokens": null, + "cache_read_input_token_cost_above_200k_tokens_priority": null, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": null, + "cache_read_input_token_cost_above_512k_tokens": null, + "cache_read_input_token_cost_flex": null, + "cache_read_input_token_cost_priority": null, + "cache_creation_input_token_cost_above_1hr": null, + "input_cost_per_character": null, + "input_cost_per_token_above_128k_tokens": null, + "input_cost_per_token_above_200k_tokens": null, + "input_cost_per_token_above_200k_tokens_priority": null, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_above_272k_tokens_priority": null, + "input_cost_per_token_above_512k_tokens": null, + "input_cost_per_query": null, + "input_cost_per_second": null, + "input_cost_per_audio_token": null, + "input_cost_per_image_token": null, + "input_cost_per_image": null, + "input_cost_per_audio_per_second": null, + "input_cost_per_video_per_second": null, + "input_cost_per_token_batches": null, + "output_cost_per_token_batches": null, + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_flex": null, + "output_cost_per_token_priority": null, + "regional_processing_uplift_multiplier_eu": null, + "regional_processing_uplift_multiplier_us": null, + "output_cost_per_audio_token": null, + "output_cost_per_character": null, + "output_cost_per_reasoning_token": null, + "output_cost_per_token_above_128k_tokens": null, + "output_cost_per_character_above_128k_tokens": null, + "output_cost_per_token_above_200k_tokens": null, + "output_cost_per_token_above_200k_tokens_priority": null, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_above_272k_tokens_priority": null, + "output_cost_per_token_above_512k_tokens": null, + "output_cost_per_second": null, + "output_cost_per_second_1080p": null, + "output_cost_per_video_per_second": null, + "output_cost_per_image": null, + "output_cost_per_image_token": null, + "output_vector_size": null, + "citation_cost_per_token": null, + "tiered_pricing": null, + "litellm_provider": "bedrock_mantle", + "mode": "responses", + "supports_system_messages": null, + "supports_response_schema": true, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_assistant_prefill": null, + "supports_prompt_caching": true, + "supports_audio_input": null, + "supports_audio_output": null, + "supports_pdf_input": null, + "supports_embedding_image_input": null, + "supports_native_streaming": null, + "supports_native_structured_output": null, + "supports_web_search": true, + "supports_url_context": null, + "supports_reasoning": true, + "supports_adaptive_thinking": null, + "supports_mid_conversation_system": null, + "supports_none_reasoning_effort": null, + "supports_minimal_reasoning_effort": false, + "supports_low_reasoning_effort": null, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": null, + "bedrock_output_config_effort_ceiling": null, + "bedrock_converse_supports_strict_tools": null, + "supports_computer_use": null, + "search_context_cost_per_query": { + "search_context_size_high": 0.012, + "search_context_size_low": 0.012, + "search_context_size_medium": 0.012 + }, + "web_search_billing_unit": null, + "tpm": null, + "rpm": null, + "ocr_cost_per_page": null, + "ocr_cost_per_credit": null, + "annotation_cost_per_page": null, + "provider_specific_entry": null, + "uses_embed_content": null, + "supports_image_size": null, + "supported_openai_params": [ + "frequency_penalty", + "logit_bias", + "logprobs", + "top_logprobs", + "max_tokens", + "max_completion_tokens", + "modalities", + "prediction", + "n", + "presence_penalty", + "seed", + "stop", + "stream", + "stream_options", + "temperature", + "top_p", + "tools", + "tool_choice", + "function_call", + "functions", + "max_retries", + "extra_headers", + "parallel_tool_calls", + "audio", + "web_search_options", + "service_tier", + "safety_identifier", + "prompt_cache_key", + "prompt_cache_retention", + "store", + "response_format", + "reasoning_effort" + ] + }, + "bedrock_mantle/anthropic.claude-sonnet-5-5": { + "key": "bedrock_mantle/anthropic.claude-sonnet-5-5", + "max_tokens": 128000, + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "input_cost_per_token": 2.2e-06, + "input_cost_per_token_flex": null, + "input_cost_per_token_priority": null, + "cache_creation_input_token_cost": 2.75e-06, + "cache_creation_input_token_cost_above_200k_tokens": null, + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_above_200k_tokens": null, + "cache_read_input_token_cost_above_200k_tokens_priority": null, + "cache_read_input_token_cost_above_272k_tokens": null, + "cache_read_input_token_cost_above_272k_tokens_priority": null, + "cache_read_input_token_cost_above_512k_tokens": null, + "cache_read_input_token_cost_flex": null, + "cache_read_input_token_cost_priority": null, + "cache_creation_input_token_cost_above_1hr": 4.4e-06, + "input_cost_per_character": null, + "input_cost_per_token_above_128k_tokens": null, + "input_cost_per_token_above_200k_tokens": null, + "input_cost_per_token_above_200k_tokens_priority": null, + "input_cost_per_token_above_272k_tokens": null, + "input_cost_per_token_above_272k_tokens_priority": null, + "input_cost_per_token_above_512k_tokens": null, + "input_cost_per_query": null, + "input_cost_per_second": null, + "input_cost_per_audio_token": null, + "input_cost_per_image_token": null, + "input_cost_per_image": null, + "input_cost_per_audio_per_second": null, + "input_cost_per_video_per_second": null, + "input_cost_per_token_batches": null, + "output_cost_per_token_batches": null, + "output_cost_per_token": 1.1e-05, + "output_cost_per_token_flex": null, + "output_cost_per_token_priority": null, + "regional_processing_uplift_multiplier_eu": null, + "regional_processing_uplift_multiplier_us": null, + "output_cost_per_audio_token": null, + "output_cost_per_character": null, + "output_cost_per_reasoning_token": null, + "output_cost_per_token_above_128k_tokens": null, + "output_cost_per_character_above_128k_tokens": null, + "output_cost_per_token_above_200k_tokens": null, + "output_cost_per_token_above_200k_tokens_priority": null, + "output_cost_per_token_above_272k_tokens": null, + "output_cost_per_token_above_272k_tokens_priority": null, + "output_cost_per_token_above_512k_tokens": null, + "output_cost_per_second": null, + "output_cost_per_second_1080p": null, + "output_cost_per_video_per_second": null, + "output_cost_per_image": null, + "output_cost_per_image_token": null, + "output_vector_size": null, + "citation_cost_per_token": null, + "tiered_pricing": null, + "litellm_provider": "bedrock_mantle", + "mode": "chat", + "supports_system_messages": null, + "supports_response_schema": true, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_assistant_prefill": false, + "supports_prompt_caching": true, + "supports_audio_input": null, + "supports_audio_output": null, + "supports_pdf_input": true, + "supports_embedding_image_input": null, + "supports_native_streaming": null, + "supports_native_structured_output": false, + "supports_web_search": null, + "supports_url_context": null, + "supports_reasoning": true, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "supports_none_reasoning_effort": null, + "supports_minimal_reasoning_effort": null, + "supports_low_reasoning_effort": null, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "bedrock_converse_supports_strict_tools": false, + "supports_computer_use": true, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "web_search_billing_unit": null, + "tpm": null, + "rpm": null, + "ocr_cost_per_page": null, + "ocr_cost_per_credit": null, + "annotation_cost_per_page": null, + "provider_specific_entry": null, + "uses_embed_content": null, + "supports_image_size": null, + "supported_openai_params": [ + "frequency_penalty", + "logit_bias", + "logprobs", + "top_logprobs", + "max_tokens", + "max_completion_tokens", + "modalities", + "prediction", + "n", + "presence_penalty", + "seed", + "stop", + "stream", + "stream_options", + "temperature", + "top_p", + "tools", + "tool_choice", + "function_call", + "functions", + "max_retries", + "extra_headers", + "parallel_tool_calls", + "audio", + "web_search_options", + "service_tier", + "safety_identifier", + "prompt_cache_key", + "prompt_cache_retention", + "store", + "response_format", + "reasoning_effort" + ] } } } diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py index 3ce395e0c6d..b3e7b7488c3 100644 --- a/tests/sdk/llm/test_budget_denial_http.py +++ b/tests/sdk/llm/test_budget_denial_http.py @@ -120,16 +120,23 @@ async def test_optional_search_prices_allow_ordinary_conversation_over_http( conv.close() -@pytest.mark.parametrize("model", ["openai/gpt-5.3-codex", "openhands/gpt-5.3-codex"]) +@pytest.mark.parametrize( + "model,fixture_key", + [ + ("openai/gpt-5.3-codex", "gpt-5.3-codex"), + ("openhands/gpt-5.3-codex", "gpt-5.3-codex"), + ("bedrock_mantle/openai.gpt-5.5", "bedrock_mantle/openai.gpt-5.5"), + ], +) @pytest.mark.parametrize("use_async", [False, True], ids=["run", "arun"]) async def test_optional_search_prices_allow_responses_conversation_over_http( - provider, tmp_path, monkeypatch, model, use_async + provider, tmp_path, monkeypatch, model, fixture_key, use_async ): base_url, replies, requests = provider fixture = ( Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" ) - info = json.loads(fixture.read_text())["model_info"]["gpt-5.3-codex"] + info = json.loads(fixture.read_text())["model_info"][fixture_key] monkeypatch.setattr(llm_module, "get_litellm_model_info", lambda **_: info) replies.append( ( diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index d2df7dfd540..8112879f205 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -465,6 +465,11 @@ async def test_budget_allows_plain_function_calls_with_optional_search_prices( ("azure_ai/claude-opus-4-6", "azure_ai/claude-opus-4-6"), ("openai/gpt-5.3-codex", "gpt-5.3-codex"), ("openhands/gpt-5.3-codex", "gpt-5.3-codex"), + ("bedrock_mantle/openai.gpt-5.5", "bedrock_mantle/openai.gpt-5.5"), + ( + "bedrock_mantle/anthropic.claude-sonnet-5-5", + "bedrock_mantle/anthropic.claude-sonnet-5-5", + ), ], ) @pytest.mark.parametrize( diff --git a/tests/sdk/llm/test_model_features.py b/tests/sdk/llm/test_model_features.py index 09e5b5c0b89..d4e1bedf879 100644 --- a/tests/sdk/llm/test_model_features.py +++ b/tests/sdk/llm/test_model_features.py @@ -20,6 +20,7 @@ VISION_MODEL_OVERRIDES, _normalized_supported_openai_params, get_features, + has_unbounded_search_cost, model_matches, ) from openhands.sdk.llm.utils.model_info import get_litellm_model_info @@ -41,6 +42,49 @@ def test_model_matches(name, pattern, expected): assert model_matches(name, [pattern]) is expected +_SEARCH_FEE = {"search_context_size_medium": 0.01} + + +@pytest.mark.parametrize( + "key,provider,ordinary", + [ + # Ordinary families on providers whose search price is optional. + ("bedrock_mantle/openai.gpt-5.5", "bedrock_mantle", True), + ("bedrock_mantle/openai.gpt-5.4", "bedrock_mantle", True), + ("bedrock_mantle/anthropic.claude-sonnet-5-5", "bedrock_mantle", True), + ("bedrock_mantle/openai.gpt-5.6-sol", "bedrock_mantle", True), + ("bedrock_mantle/us.openai.gpt-5.5", "bedrock_mantle", True), + ("us.anthropic.claude-opus-4-8-v1:0", "bedrock_converse", True), + ("anthropic.claude-sonnet-5", "bedrock", True), + ("gpt-4.1", "openai", True), + ("azure/gpt-5.2-pro", "azure", True), + ("claude-sonnet-4-5", "anthropic", True), + ("vertex_ai/claude-opus-4-6", "vertex_ai-anthropic_models", True), + # Non-ordinary families keep their fee-bearing routes closed. + ("bedrock_mantle/qwen.qwen3-max", "bedrock_mantle", False), + ("bedrock_mantle/google.gemma-3-27b", "bedrock_mantle", False), + ("bedrock_mantle/xai.grok-4.1", "bedrock_mantle", False), + ("meta/muse-spark-1.1", "meta", False), + ("groq/openai/gpt-oss-120b", "groq", False), + ("perplexity/sonar", "perplexity", False), + ], +) +def test_unbounded_search_cost_tracks_ordinary_families(key, provider, ordinary): + """A published search fee is bounded only for an ordinary request-time family. + + ``bedrock_mantle`` hosts several vendor namespaces in one catalog and was + absent from the provider allowlist, so every one of its GPT/o Responses + routes was refused for budgeted calls. + """ + info = { + "key": key, + "litellm_provider": provider, + "mode": "responses" if "gpt-" in key.split(".")[-1] else "chat", + "search_context_cost_per_query": dict(_SEARCH_FEE), + } + assert has_unbounded_search_cost(key, model_info=info) is not ordinary + + @pytest.mark.parametrize( "model,expected_reasoning", [ From a5021b62c3f31803f20c123022a1c5e802887527 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 20 Oct 2026 09:46:20 +0000 Subject: [PATCH 19/24] test(deps): install boto3 in the dev group so bedrock_mantle routes resolve LiteLLM's bedrock_mantle provider config imports botocore at module import time, so get_supported_openai_params(custom_llm_provider="bedrock_mantle") raises ModuleNotFoundError in the default `uv sync --frozen --group dev` environment. The new bedrock_mantle budget/feature cases exercise both the feature lookup and the real transport, so they failed there (10 failures). boto3 is already declared as the optional extra openhands-sdk[boto3]; adding it to the dev group makes the test environment representative of a user configuring that route. Co-authored-by: openhands --- pyproject.toml | 3 +++ uv.lock | 1 + 2 files changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2da88a5a9f2..11830039343 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ openhands-agent-server = { workspace = true } [dependency-groups] dev = [ "pre-commit>=4.3.0", + # LiteLLM's bedrock_mantle provider imports botocore at module import, so the + # bedrock_mantle budget/feature tests need the optional extra in dev. + "boto3>=1.35.0", "packaging>=26.3", "pillow>=12.3.0", "psutil>=7.2.2", diff --git a/uv.lock b/uv.lock index d20e8256a22..eaadb38a377 100644 --- a/uv.lock +++ b/uv.lock @@ -43,6 +43,7 @@ constraints = [ [manifest.dependency-groups] dev = [ + { name = "boto3", specifier = ">=1.35.0" }, { name = "griffe", extras = ["pypi"], specifier = ">=2.3.0" }, { name = "packaging", specifier = ">=26.3" }, { name = "pillow", specifier = ">=12.3.0" }, From 46603bb4abcd256a0a36e3e59b3c80c70dbef7ca Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 27 Oct 2026 04:30:09 +0000 Subject: [PATCH 20/24] fix(sdk): address cost budget review findings Settle each attempt from its own recorded cost, keyed by the response id it returned, instead of a shared cumulative metric delta that a concurrent call on the same LLM could advance. Reject an ACP agent whenever the effective budget is non-None, including a budget inherited from a delegating parent, so an ACP child cannot bypass reservation. Measure each budget ceiling against the subtree it governs: a view's own ceiling sees only itself and its descendants while an ancestor ceiling sees everything below it, so a child's cap is no longer charged for parent or sibling spending. Forward maxBudgetPerRun through the regular ConversationManager factory. Co-authored-by: openhands --- .../conversation-create-options.test.ts | 17 ++ .../src/conversation/conversation-manager.ts | 7 + .../conversation/impl/local_conversation.py | 45 ++-- .../openhands/sdk/llm/cost_budget.py | 206 +++++++++++------- openhands-sdk/openhands/sdk/llm/llm.py | 41 ++-- .../openhands/sdk/llm/utils/telemetry.py | 27 +++ .../local/test_cost_budget_enforcement.py | 46 ++++ tests/sdk/conversation/local/test_fork.py | 6 +- tests/sdk/llm/test_cost_budget.py | 90 ++++++++ 9 files changed, 378 insertions(+), 107 deletions(-) diff --git a/clients/typescript/src/__tests__/conversation-create-options.test.ts b/clients/typescript/src/__tests__/conversation-create-options.test.ts index a75a1e1b706..4eb5e2d7d60 100644 --- a/clients/typescript/src/__tests__/conversation-create-options.test.ts +++ b/clients/typescript/src/__tests__/conversation-create-options.test.ts @@ -56,6 +56,23 @@ describe('conversation create options', () => { expect(bodies[0]).toMatchObject({ max_iterations: 500, stuck_detection: true }); }); + it('ConversationManager.createConversation forwards the per-run budget', async () => { + const manager = new ConversationManager({ host }); + // A structurally typed options variable must not silently drop the budget. + const options = { initialMessage: 'hello', maxBudgetPerRun: 2.5 }; + await manager.createConversation(agent, options); + + expect(bodies).toHaveLength(1); + expect(bodies[0]).toMatchObject({ max_budget_per_run: 2.5 }); + }); + + it('ConversationManager.createConversation omits the budget when unset', async () => { + const manager = new ConversationManager({ host }); + await manager.createConversation(agent, { initialMessage: 'hello' }); + + expect(bodies[0]).toMatchObject({ max_budget_per_run: null }); + }); + it('RemoteConversation uses its constructor options, overridden by start() options', async () => { const workspace = new RemoteWorkspace({ host, workingDir: '/workspace' }); const options = { maxIterations: 7, stuckDetection: false }; diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts index 3f3c66fc741..2dc8f3d646f 100644 --- a/clients/typescript/src/conversation/conversation-manager.ts +++ b/clients/typescript/src/conversation/conversation-manager.ts @@ -210,6 +210,11 @@ export class ConversationManager { stuckDetection?: boolean; workingDir?: string; userId?: string; + /** + * Per-run cost ceiling in USD. Must be > 0 when set. Enforced + * server-side before each LLM call. + */ + maxBudgetPerRun?: number; } = {} ): Promise { const workspace = new RemoteWorkspace({ @@ -222,6 +227,7 @@ export class ConversationManager { maxIterations: options.maxIterations, stuckDetection: options.stuckDetection, userId: options.userId, + maxBudgetPerRun: options.maxBudgetPerRun, }); await conversation.start({ @@ -229,6 +235,7 @@ export class ConversationManager { maxIterations: options.maxIterations, stuckDetection: options.stuckDetection, userId: options.userId, + maxBudgetPerRun: options.maxBudgetPerRun, }); return conversation; diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 68e586baada..8c7125188eb 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -182,21 +182,24 @@ def _copy_event_for_fork(event: Event) -> Event: return Event.model_validate_json(event.model_dump_json(exclude_none=True)) -def reject_acp_budget( - agent: AgentBase | None, max_budget_per_run: float | None -) -> None: - """Reject a per-run budget on an ACP agent, which cannot enforce one. +def reject_acp_budget(agent: AgentBase | None, effective_budget: float | None) -> None: + """Reject an ACP agent that would run under a cost budget. ACP prompts run in an external process that reports usage only after the turn, so the SDK cannot reserve a call's worst-case cost before sending and a single prompt can exceed the ceiling. Accepting the budget would silently promise enforcement the SDK cannot deliver. + ``effective_budget`` is the ceiling that actually applies to this + conversation -- its own ``max_budget_per_run`` or one inherited from a + parent that delegated to it. Either way the ACP agent would bypass + reservation, so both are refused. + Callers that create host-local state for a conversation (the agent server's worktree/branch, for instance) must call this once the agent is known and before that state exists, so a rejection leaves nothing behind. """ - if max_budget_per_run is None or not isinstance(agent, ACPAgent): + if effective_budget is None or not isinstance(agent, ACPAgent): return raise CostBudgetUnsupportedError( "max_budget_per_run is not supported for ACP agents: their " @@ -440,6 +443,19 @@ def __init__( ws_path = Path(self.workspace.working_dir) if not ws_path.exists(): ws_path.mkdir(parents=True, exist_ok=True) + # A delegate inherits its parent's budget even without one of its own, + # and an ACP agent bypasses reservation either way, so the ceiling that + # actually applies decides whether this agent is admissible. + inherited_budget = ( + _parent_llm_call_context.cost_budget + if _parent_llm_call_context is not None + else None + ) + effective_budget = ( + max_budget_per_run + if max_budget_per_run is not None + else (inherited_budget.limit if inherited_budget is not None else None) + ) # Reject an ACP budget before ``ConversationState.create`` writes # ``base_state.json``. A later rejection would leave an orphaned state # file behind, so a retry with the same id but a different agent would @@ -447,7 +463,7 @@ def __init__( # The resumed agent is not known until after ``create``, so only a # caller-supplied agent can be checked here; the resume path re-checks # once the persisted agent is loaded. - reject_acp_budget(agent, max_budget_per_run) + reject_acp_budget(agent, effective_budget) self._state = ConversationState.create( id=desired_id, agent=agent, @@ -469,7 +485,7 @@ def __init__( # Resume path: the persisted agent is known only now. No state file # is created here (base_state.json already exists), so re-checking # the budget cannot orphan anything. - reject_acp_budget(agent, max_budget_per_run) + reject_acp_budget(agent, effective_budget) # The persisted agent carries client-tool ``Tool`` specs, but in a # fresh process the ``ClientTool`` *classes* are absent from the # global registry. Re-register them from the persisted specs so @@ -559,14 +575,9 @@ def _default_callback(e): # Hard cost ceiling (USD) per run; None disables it. self.max_budget_per_run = max_budget_per_run self._budget_run_baseline = 0.0 - inherited_budget = ( - _parent_llm_call_context.cost_budget - if _parent_llm_call_context is not None - else None - ) self._cost_budget: CostBudget | None = None # True only for the conversation that created the budget; a conversation - # that inherited a parent's budget shares its ledger and must never + # that inherited a parent's budget shares its tree and must never # re-seed it mid-flight. self._owns_budget = False if max_budget_per_run is not None: @@ -837,7 +848,7 @@ def _budget_exceeded_detail(self) -> str | None: return detail if self.max_budget_per_run is None: # Inherited a parent's ceiling without one of its own; the shared - # ledger already enforces it. + # tree already enforces it. return None spent = self._combined_cost() if spent < self.max_budget_per_run: @@ -850,10 +861,10 @@ def _budget_exceeded_detail(self) -> str | None: def _begin_budget_run(self) -> None: """Start a fresh per-run budget allowance for a top-level run. - Records the run-start baseline and reseeds the ledger, so each + Records the run-start baseline and reseeds the tree, so each ``run()``/``arun()`` gets its own ``max_budget_per_run`` allowance rather than inheriting earlier runs' spend. A conversation that only inherited a - parent's budget shares the parent's ledger and must not reseed it + parent's budget shares the parent's tree and must not reseed it mid-flight. """ self._budget_run_baseline = self._combined_cost_total() @@ -1018,7 +1029,7 @@ def fork( tags=tags, # A fork is an independent conversation: inherit the source's # non-budget call context but not its ceiling, so the fork's - # calls never reserve against the source's ledger. Its own + # calls never reserve against the source's tree. Its own # max_budget_per_run (copied from the source) starts fresh. _parent_llm_call_context=replace( self._llm_call_context, cost_budget=None diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py index da9142417b6..3c9bbad5992 100644 --- a/openhands-sdk/openhands/sdk/llm/cost_budget.py +++ b/openhands-sdk/openhands/sdk/llm/cost_budget.py @@ -9,13 +9,15 @@ their bound; these conservative charges are budget usage, not billed cost. Settled + reserved is a live upper bound on what the current run can owe. -Settled cost is carried on a ledger shared by a parent and its subagents, so the -whole tree contends on one running total even though a subagent's metrics join -the parent only after it finishes. The ledger is reset at the start of each -top-level run (see :meth:`reseed`), which makes ``max_budget_per_run`` a -per-run ceiling rather than a lifetime cap. A derived child view keeps every -ancestor ceiling and enforces the tightest one, so a delegate declaring a larger -budget than its parent cannot spend past the parent's cap. +A parent and its subagents share one reservation tree so they contend on one +running total, but each ceiling is measured against the usage of the *subtree +it governs*: a view's own ceiling sees only itself and its descendants, while +an ancestor ceiling sees everything below it. A subagent's +``max_budget_per_run`` therefore bounds that subagent's own work, and a +delegate cannot spend past its parent's cap even when it declares a larger +budget. The tree is reset at the start of each top-level run (see +:meth:`reseed`), which makes ``max_budget_per_run`` a per-run ceiling rather +than a lifetime cap. Pre-call reservation only covers the LLM call wrappers that install a reservation (``LLM.completion``/``acompletion``/...). Agents that delegate to an @@ -30,15 +32,17 @@ from dataclasses import dataclass -class _Ledger: - """Shared settled/reserved totals for one budget tree, guarded by one lock.""" +class _Tree: + """Shared state for one budget tree: one lock and one reseed generation.""" - __slots__ = ("lock", "settled", "reserved") + __slots__ = ("lock", "generation") def __init__(self) -> None: self.lock = threading.Lock() - self.settled = 0.0 - self.reserved = 0.0 + # Bumped by ``reseed``. A view whose ``_generation`` lags this value + # discards its subtree totals on next access, so a fresh run starts from + # zero without walking the whole tree. + self.generation = 0 @dataclass(frozen=True) @@ -52,130 +56,184 @@ class BudgetReservation: class CostBudget: """A cost ceiling (USD) enforced by pre-call reservation. - ``limit`` bounds the cost accrued in the current run (see :meth:`reseed`). - The ledger is shared by derived child views, each of which honours the - tightest ``limit`` among itself and its ancestors over the same running - total. + ``limit`` bounds the cost accrued in the current run by this view and its + descendants (see :meth:`reseed`). Derived child views share the tree's lock + so reservations stay atomic, but each ceiling is compared against the + subtree it governs rather than the whole tree. """ def __init__(self, limit: float) -> None: if limit <= 0: raise ValueError("Cost budget limit must be strictly positive") self._limit = limit - self._ledger = _Ledger() - self._ancestor_limits: tuple[float, ...] = () + self._tree = _Tree() + self._parent: CostBudget | None = None + self._generation = 0 + # Settled/reserved budget usage for this view and its descendants. + self._subtree_settled = 0.0 + self._subtree_reserved = 0.0 def child(self, limit: float) -> CostBudget: """Derive a subagent view with its own (usually smaller) ceiling. - Shares the parent's ledger, so the whole tree contends on one running - total, and enforces its own limit *and* every ancestor ceiling. A child - cannot declare a larger budget than its parent and spend past the - parent's cap. + Shares the parent's lock so the whole tree contends on one running + total, and enforces its own limit over its own subtree *and* every + ancestor ceiling over that ancestor's subtree. A child cannot declare a + larger budget than its parent and spend past the parent's cap. """ if limit <= 0: raise ValueError("Cost budget limit must be strictly positive") view = CostBudget.__new__(CostBudget) view._limit = limit - view._ledger = self._ledger - view._ancestor_limits = (*self._ancestor_limits, self._limit) + view._tree = self._tree + view._parent = self + view._generation = self._tree.generation + view._subtree_settled = 0.0 + view._subtree_reserved = 0.0 return view + def _sync(self) -> None: + """Discard this view's totals if the tree has been reseeded. Lock held.""" + generation = self._tree.generation + if self._generation != generation: + self._subtree_settled = 0.0 + self._subtree_reserved = 0.0 + self._generation = generation + @property def limit(self) -> float: - """Tightest ceiling this view enforces, including ancestor limits.""" - return min((self._limit, *self._ancestor_limits)) - - def _breaches(self, total: float) -> bool: - """True if ``total`` would exceed this view's ceiling or an ancestor's.""" - if total > self._limit: - return True - return any(total > ceiling for ceiling in self._ancestor_limits) + """Tightest ceiling among this view and its ancestors. - def _reached(self, total: float) -> bool: - """True if ``total`` has already reached a ceiling (``>=`` for reporting).""" - if total >= self._limit: - return True - return any(total >= ceiling for ceiling in self._ancestor_limits) + Reported by denial messages so a caller sees the binding cap. Note that + each ceiling is *enforced* over its own subtree, so this is a reporting + summary rather than the value compared in :meth:`try_reserve`. + """ + tightest = self._limit + node = self._parent + while node is not None: + tightest = min(tightest, node._limit) + node = node._parent + return tightest + + def _breached_limit(self, amount: float) -> float | None: + """Tightest ceiling ``amount`` would breach, or ``None`` if it fits. + + Walks this view and its ancestors, so a child view cannot admit a call + that breaches an ancestor's cap. Lock held. + """ + node: CostBudget | None = self + while node is not None: + node._sync() + total = node._subtree_settled + node._subtree_reserved + if total + amount > node._limit: + return node._limit + node = node._parent + return None @property def spent(self) -> float: - """Settled budget usage, including bounds retained for unknown work.""" - with self._ledger.lock: - return max(0.0, self._ledger.settled) + """Settled budget usage in this subtree, including bounds for unknown work.""" + with self._tree.lock: + self._sync() + return max(0.0, self._subtree_settled) @property def reserved(self) -> float: - with self._ledger.lock: - return self._ledger.reserved + with self._tree.lock: + self._sync() + return self._subtree_reserved @property def live_total(self) -> float: - """Live upper bound on cost: settled + in-flight reservations.""" - with self._ledger.lock: - return self._ledger.settled + self._ledger.reserved + """Live upper bound on subtree cost: settled + in-flight reservations.""" + with self._tree.lock: + self._sync() + return self._subtree_settled + self._subtree_reserved def reseed(self) -> None: - """Start a fresh per-run allowance. + """Start a fresh per-run allowance for the whole tree. - Zeroes the shared ledger so each top-level ``run()``/``arun()`` gets its - own ``max_budget_per_run`` allowance rather than inheriting earlier runs' - spend. A run that inherited a parent's budget shares the parent's ledger - and must not reseed it mid-flight. + Bumps the tree generation so every view resets its subtree totals on + next access, giving each top-level ``run()``/``arun()`` its own + ``max_budget_per_run`` allowance rather than inheriting earlier runs' + spend. A run that inherited a parent's budget shares the parent's + generation and must not reseed it mid-flight. """ - with self._ledger.lock: - self._ledger.settled = 0.0 - self._ledger.reserved = 0.0 + with self._tree.lock: + self._tree.generation += 1 def try_reserve(self, amount: float) -> BudgetReservation | None: """Atomically reserve ``amount`` or return ``None`` if it would exceed. The comparison and the reservation happen under one lock, so parallel - callers cannot all pass the same check. Every ancestor ceiling is checked - alongside this view's own, so a child view cannot admit a call that - breaches a parent's cap. + callers cannot all pass the same check. This view's ceiling and every + ancestor ceiling are checked over their own subtrees, so a child view + cannot admit a call that breaches a parent's cap. """ if amount < 0: raise ValueError("Reservation amount cannot be negative") - with self._ledger.lock: - total = self._ledger.settled + self._ledger.reserved + amount - if self._breaches(total): + with self._tree.lock: + if self._breached_limit(amount) is not None: return None - self._ledger.reserved += amount + node: CostBudget | None = self + while node is not None: + node._subtree_reserved += amount + node = node._parent return BudgetReservation(self, amount) def settle(self, reservation: BudgetReservation, actual: float) -> None: """Release a reservation and add its settled budget usage. The caller supplies recorded cost or retains a conservative bound for - failed/unknown work. This ledger does not replace billing metrics. + failed/unknown work. This tree does not replace billing metrics. """ - with self._ledger.lock: - self._ledger.reserved = max(0.0, self._ledger.reserved - reservation.amount) - self._ledger.settled += max(0.0, actual) + with self._tree.lock: + node: CostBudget | None = reservation.budget + while node is not None: + node._sync() + node._subtree_reserved = max( + 0.0, node._subtree_reserved - reservation.amount + ) + node._subtree_settled += max(0.0, actual) + node = node._parent def exceeded_detail(self) -> str | None: """Error detail if the live total has reached a ceiling, else ``None``.""" - with self._ledger.lock: - total = self._ledger.settled + self._ledger.reserved - reached = self._reached(total) - if not reached: + with self._tree.lock: + self._sync() + total = self._subtree_settled + self._subtree_reserved + limit = self._reached_limit() + if limit is None: return None - return self._limit_detail(accumulated=max(total, 0.0)) + return self._limit_detail(limit, accumulated=max(total, 0.0)) + + def _reached_limit(self) -> float | None: + """Tightest ceiling this subtree's live total has reached. Lock held.""" + node: CostBudget | None = self + while node is not None: + node._sync() + if node._subtree_settled + node._subtree_reserved >= node._limit: + return node._limit + node = node._parent + return None def denial_detail(self, amount: float) -> str: """Message for a reservation of ``amount`` that would exceed a ceiling.""" - with self._ledger.lock: - total = self._ledger.settled + self._ledger.reserved + with self._tree.lock: + self._sync() + total = self._subtree_settled + self._subtree_reserved + limit = self._breached_limit(amount) return self._limit_detail( - accumulated=max(total, 0.0), attempted=max(total, 0.0) + amount + limit if limit is not None else self._limit, + accumulated=max(total, 0.0), + attempted=max(total, 0.0) + amount, ) def _limit_detail( - self, *, accumulated: float, attempted: float | None = None + self, limit: float, *, accumulated: float, attempted: float | None = None ) -> str: detail = ( - f"Agent reached maximum budget limit (${self.limit:.4f}); " + f"Agent reached maximum budget limit (${limit:.4f}); " f"budget usage ${accumulated:.4f} (settled and reserved)." ) if attempted is not None: diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index 49069f94c36..2d24dc8f9d6 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -1957,8 +1957,8 @@ def _budget_guarded_attempt( Failed/cancelled attempts retain their bound because the provider may have processed work without returning complete usage. Successful calls settle to recorded positive cost when its pricing is complete; absent - or endpoint-premium-unaware telemetry retains the bound too. This ledger - is budget usage, not a provider billing record. Unbudgeted calls keep + or endpoint-premium-unaware telemetry retains the bound too. This tracked + usage is budget usage, not a provider billing record. Unbudgeted calls keep their existing transport options and behavior. """ @@ -1972,7 +1972,6 @@ async def _reserved_async(*args: Any, **kwargs: Any) -> Any: ) if reservation is None: return await attempt(*args, **kwargs) - cost_before = self.metrics.accumulated_cost # Keep retries in the SDK guard and never drop the cap as # an unsupported parameter inside LiteLLM. kwargs = { @@ -1986,12 +1985,14 @@ async def _reserved_async(*args: Any, **kwargs: Any) -> Any: except BaseException: reservation.budget.settle( reservation, - max(reservation.amount, self._cost_delta(cost_before)), + max(reservation.amount, self._attempt_cost(result=None)), ) raise - cost = self._cost_delta(cost_before) reservation.budget.settle( - reservation, self._budget_success_cost(reservation, cost) + reservation, + self._budget_success_cost( + reservation, self._attempt_cost(result=result) + ), ) return result @@ -2004,7 +2005,6 @@ def _reserved_sync(*args: Any, **kwargs: Any) -> Any: ) if reservation is None: return attempt(*args, **kwargs) - cost_before = self.metrics.accumulated_cost kwargs = { **kwargs, "drop_params": False, @@ -2016,12 +2016,14 @@ def _reserved_sync(*args: Any, **kwargs: Any) -> Any: except BaseException: reservation.budget.settle( reservation, - max(reservation.amount, self._cost_delta(cost_before)), + max(reservation.amount, self._attempt_cost(result=None)), ) raise - cost = self._cost_delta(cost_before) reservation.budget.settle( - reservation, self._budget_success_cost(reservation, cost) + reservation, + self._budget_success_cost( + reservation, self._attempt_cost(result=result) + ), ) return result @@ -2029,9 +2031,22 @@ def _reserved_sync(*args: Any, **kwargs: Any) -> Any: return decorate - def _cost_delta(self, cost_before: float) -> float: - """Cost added to this LLM's own metrics by the call just completed.""" - return max(0.0, self.metrics.accumulated_cost - cost_before) + def _attempt_cost( + self, result: ModelResponse | ResponsesAPIResponse | None + ) -> float: + """Cost recorded for the attempt just completed, or ``0.0`` if unknown. + + Keyed by the response id the attempt returned, so a concurrent call + sharing this LLM cannot settle the reservation with its own cost. The + telemetry callback runs inside the transport call, so the entry exists + by the time the attempt returns; ``0.0`` means the price could not be + established and the caller retains the full reservation. + """ + assert self._telemetry is not None + if result is None: + return 0.0 + cost = self._telemetry.costs_by_response_id.get(result.id) + return cost if cost is not None else 0.0 def _budget_success_cost( self, reservation: BudgetReservation, cost: float diff --git a/openhands-sdk/openhands/sdk/llm/utils/telemetry.py b/openhands-sdk/openhands/sdk/llm/utils/telemetry.py index 93195f2b8e8..c9f49fb51dd 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/telemetry.py +++ b/openhands-sdk/openhands/sdk/llm/utils/telemetry.py @@ -22,6 +22,9 @@ logger = get_logger(__name__) +# Cap on remembered per-response costs; only in-flight attempts need matching. +_MAX_REMEMBERED_RESPONSE_COSTS = 1024 + @dataclass(frozen=True, slots=True) class UsageSnapshot: @@ -155,6 +158,11 @@ class Telemetry(BaseModel): _req_start: float = PrivateAttr(default=0.0) _req_ctx: dict[str, Any] = PrivateAttr(default_factory=dict) _last_latency: float = PrivateAttr(default=0.0) + # Cost recorded for each response of an attempt in flight, keyed by response + # id. Scoped per response so a caller can read the attempt's own cost instead + # of a shared cumulative total that other concurrent calls on the same LLM + # may have advanced. A missing id means the price could not be established. + _costs_by_response_id: dict[str, float | None] = PrivateAttr(default_factory=dict) _log_completions_callback: Callable[[str, str], None] | None = PrivateAttr( default=None ) @@ -210,6 +218,7 @@ def on_response( # 2) cost cost = self._compute_cost(resp, provider_info=provider_info) + self._remember_response_cost(response_id, cost) # Intentionally skip logging zero-cost (0.0) responses; only record # positive cost if cost: @@ -240,6 +249,24 @@ def on_response( return self.metrics.deep_copy() + @property + def costs_by_response_id(self) -> dict[str, float | None]: + """Cost recorded for each response of an attempt in flight, by response id. + + A caller identifies its attempt's own cost by the response id it + returned, rather than reading a shared cumulative total that concurrent + calls on the same LLM may have advanced. A missing id means the price + could not be established. + """ + return self._costs_by_response_id + + def _remember_response_cost(self, response_id: str, cost: float | None) -> None: + # Bound the map: it is only needed to match an in-flight response, and + # response ids are unique per call, so the oldest entries are dead. + if len(self._costs_by_response_id) >= _MAX_REMEMBERED_RESPONSE_COSTS: + self._costs_by_response_id.clear() + self._costs_by_response_id[response_id] = cost + def on_error(self, _err: BaseException) -> None: # Best-effort logging for failed requests (so we can debug malformed # request payloads, e.g. orphaned Responses reasoning items). diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py index eed344f1dfa..17434a2bbdf 100644 --- a/tests/sdk/conversation/local/test_cost_budget_enforcement.py +++ b/tests/sdk/conversation/local/test_cost_budget_enforcement.py @@ -175,3 +175,49 @@ def test_acp_budget_rejection_leaves_no_orphan_state(): delete_on_close=False, ) assert not (persist / cid.hex / "base_state.json").exists() + + +def test_acp_delegate_rejects_inherited_budget(): + """An ACP delegate that inherits a parent's budget must be rejected. + + ``DelegateExecutor`` forwards the parent's LLM call context without an + explicit cap, so a delegate's effective budget is the inherited one. Its + prompts bypass reservation, so the ceiling must be checked even when + ``max_budget_per_run`` is unset. + """ + from openhands.sdk.agent.acp_agent import ACPAgent + from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + parent = _conversation(tmp, max_budget_per_run=1.0) + with pytest.raises(CostBudgetUnsupportedError, match="ACP agents"): + LocalConversation( + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=str(tmp), + persistence_dir=str(tmp / "sub"), + max_budget_per_run=None, + visualizer=None, + delete_on_close=False, + _parent_llm_call_context=parent.get_llm_call_context(), + ) + # Rejection happened before any conversation state was created. + assert not (tmp / "sub").exists() + + +def test_acp_delegate_without_inherited_budget_is_allowed(): + from openhands.sdk.agent.acp_agent import ACPAgent + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + parent = _conversation(tmp, max_budget_per_run=None) + conv = LocalConversation( + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=str(tmp), + persistence_dir=str(tmp / "sub"), + max_budget_per_run=None, + visualizer=None, + delete_on_close=False, + _parent_llm_call_context=parent.get_llm_call_context(), + ) + assert conv._cost_budget is None diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py index e6d326e8e6f..be6580d6428 100644 --- a/tests/sdk/conversation/local/test_fork.py +++ b/tests/sdk/conversation/local/test_fork.py @@ -178,7 +178,7 @@ def test_fork_preserves_metrics_when_requested(): assert combined.accumulated_cost == pytest.approx(1.5) -def test_fork_does_not_share_source_budget_ledger(): +def test_fork_does_not_share_source_budget_tree(): """A fork is independent: it must not reserve against the source's budget.""" with tempfile.TemporaryDirectory() as tmpdir: src = Conversation( @@ -192,10 +192,10 @@ def test_fork_does_not_share_source_budget_ledger(): # The fork carries the source's ceiling... assert fork.max_budget_per_run == pytest.approx(1.0) assert fork._cost_budget is not None - # ...but owns its own ledger rather than sharing the source's. + # ...but owns its own tree rather than sharing the source's. assert src._cost_budget is not None assert fork._cost_budget is not src._cost_budget - assert fork._cost_budget._ledger is not src._cost_budget._ledger + assert fork._cost_budget._tree is not src._cost_budget._tree def test_fork_copies_agent_state(): diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index 8112879f205..0c24410c49f 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -112,6 +112,47 @@ def test_child_denial_reports_its_own_tighter_ceiling(): assert "maximum budget limit ($1.0000)" in child.denial_detail(2.0) +def test_child_ceiling_measures_only_its_own_subtree(): + """A child's own cap must not be charged for its parent's spending. + + Regression: the child's first request was denied because the shared total + included the parent's earlier work, even though the child had spent nothing. + """ + parent = CostBudget(5.0) + child = parent.child(1.31) + _settle(parent, 0.02) # the parent's own work, before delegating + + # $1.30 fits the child's $1.31 cap: the parent's $0.02 belongs to the + # parent's subtree, not the child's. + assert child.try_reserve(1.30) is not None + + +def test_sibling_spend_does_not_consume_a_siblings_ceiling(): + parent = CostBudget(10.0) + left = parent.child(1.0) + right = parent.child(1.0) + _settle(left, 0.9) + + # The right sibling has spent nothing of its own $1 allowance. + assert right.try_reserve(0.9) is not None + # The left sibling's own cap is exhausted. + assert left.try_reserve(0.9) is None + + +def test_reseed_resets_descendant_subtree_totals(): + parent = CostBudget(10.0) + child = parent.child(1.0) + _settle(child, 0.9) + assert child.spent == pytest.approx(0.9) + + parent.reseed() + + assert parent.spent == 0.0 + assert child.spent == 0.0 + # The child gets its own per-run allowance back. + assert child.try_reserve(0.9) is not None + + def test_concurrent_reservations_cannot_overshoot(): """A shared budget admits at most the calls that fit, even in parallel.""" budget = CostBudget(1.0) @@ -931,3 +972,52 @@ def test_budget_retains_successful_work_when_cost_is_unknown(budget_call, monkey providers[0].assert_called_once() assert llm.metrics.accumulated_cost == 0 assert budget.spent == pytest.approx(0.11) + + +@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) +async def test_budget_settles_from_the_attempt_not_a_concurrent_call( + budget_call, use_async +): + """Another in-flight call's cost must not settle this attempt's reservation. + + Regression: settlement read the cumulative ``metrics.accumulated_cost`` + delta, so a concurrent call sharing this LLM could release a reservation + whose own price was unknown and admit a request the cap should deny. + """ + _, make_llm, messages, providers = budget_call + llm = make_llm() + budget = CostBudget(0.15) + context = LLMCallContext(cost_budget=budget) + assert llm._telemetry is not None + + def concurrent_charge(**_kwargs): + # A sibling attempt on the same LLM records its own priced response + # while this attempt sits between reservation and settlement. + llm._telemetry.on_response( + create_mock_litellm_response( + "sibling", + response_id="sibling-resp", + prompt_tokens=100, + completion_tokens=100, + ) + ) + return create_mock_litellm_response( + "ok", response_id="unknown-cost-resp", prompt_tokens=0, completion_tokens=0 + ) + + providers[0].side_effect = concurrent_charge + providers[1].side_effect = concurrent_charge + + if use_async: + await llm.acompletion(messages, call_context=context) + else: + llm.completion(messages, call_context=context) + + # This attempt reported no usage, so its own price is unknown and the full + # reservation is retained -- not the sibling's recorded cost. + assert budget.spent == pytest.approx(0.11) + with pytest.raises(LLMBudgetExceededError): + if use_async: + await llm.acompletion(messages, call_context=context) + else: + llm.completion(messages, call_context=context) From 105141271a20c3bbfcd994305e92813875ade59e Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 27 Oct 2026 04:30:18 +0000 Subject: [PATCH 21/24] docs(pr): refresh budget design evidence to the review-fix head Regenerate the per-file diffs, prose, and commit-pinned source links against the merge base so the temporary review page matches the current settlement, subtree-ceiling, and ACP-rejection code. Co-authored-by: openhands --- .pr/budget-design.html | 1346 ++++++++++++++++++++++++++++++++-------- 1 file changed, 1102 insertions(+), 244 deletions(-) diff --git a/.pr/budget-design.html b/.pr/budget-design.html index 7b9582398f1..999c56b883a 100644 --- a/.pr/budget-design.html +++ b/.pr/budget-design.html @@ -4,10 +4,10 @@ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.6 var(--sans)}.wrap{display:grid;grid-template-columns:192px minmax(0,1fr);gap:44px;max-width:1210px;margin:auto;padding:38px 32px 80px}nav{position:sticky;top:30px;align-self:start;font-size:13px}nav h2{font-size:10px;text-transform:uppercase;letter-spacing:.13em;color:var(--muted);margin:0 0 14px}nav ol{list-style:none;margin:0;padding:0;counter-reset:toc}nav li{counter-increment:toc;margin:10px 0}nav li::before{content:counter(toc,decimal-leading-zero) " ";color:var(--subtle);font:11px var(--mono)}nav a{color:var(--fg);text-decoration:none}nav small{display:block;margin-top:24px;color:var(--muted);line-height:1.6}a{color:var(--accent);text-underline-offset:3px}a:hover{color:var(--warn)}header{padding-bottom:24px;border-bottom:1px solid var(--rule)}.eyebrow{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);font-weight:750}h1{font-size:40px;line-height:1.12;letter-spacing:-.04em;margin:13px 0 16px;max-width:760px}.sub{font-size:18px;line-height:1.6;color:var(--muted);max-width:760px;margin:0}.meta{font:11px/1.8 var(--mono);color:var(--muted);margin-top:17px;overflow-wrap:anywhere}h2{font-size:25px;line-height:1.25;letter-spacing:-.025em;margin:0 0 14px}h2 .num{color:var(--subtle);font:13px var(--mono);margin-right:12px}section{padding-top:40px;scroll-margin-top:24px}h3{font-size:17px;line-height:1.35;margin:23px 0 8px}p{margin:10px 0 14px}code{font:.87em var(--mono);padding:2px 4px;background:var(--code-bg);border-radius:3px}pre{font:12px/1.65 var(--mono);padding:16px;background:var(--code-bg);border:1px solid var(--rule);border-radius:5px;overflow:auto;margin:8px 0}pre code{font:inherit;padding:0;background:none}.kw{color:var(--accent);font-weight:700}.com{color:var(--subtle)}.str{color:var(--ok)}.ba{display:grid;grid-template-columns:1fr 1fr;gap:16px}.label{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:750}.callout{margin:22px 0 20px;border-left:3px solid var(--accent);border-radius:0 5px 5px 0;background:var(--accent-soft);padding:13px 16px;font-size:14px}.callout.warn{border-color:var(--warn);background:var(--warn-soft)}.callout.note{border-color:var(--subtle);background:var(--code-bg)}.sources{font-size:12px;color:var(--muted);margin:8px 0 16px;line-height:1.8}.src{font:11px/1.7 var(--mono);text-decoration:none;border-bottom:1px dotted var(--accent);overflow-wrap:anywhere}.sources .src{font-size:10.5px}table{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}th,td{text-align:left;vertical-align:top;padding:10px 11px;border-bottom:1px solid var(--rule)}th{font-size:11px;color:var(--muted);background:var(--code-bg)}td:first-child{width:24%;font-weight:650}.fig{margin:18px 0 10px}.fig svg{display:block;width:100%;height:auto;background:var(--card);border:1px solid var(--rule);border-radius:6px}.fig figcaption{font-size:12px;line-height:1.5;color:var(--muted);margin:8px 2px}details{border:1px solid var(--rule);border-radius:5px;margin:10px 0}summary{cursor:pointer;padding:9px 12px;font-size:12px;color:var(--muted)}details>div{padding:3px 12px 12px}details pre{margin:0;border:0;border-radius:0;font-size:10.5px;max-height:550px}details.manifest{border:0;border-bottom:1px solid var(--rule);border-radius:0;margin:-4px 0 18px}details.manifest summary{padding:5px 0;font-size:11px}details.manifest>div{padding:3px 0 10px}.files summary{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}.files .path{font:11px var(--mono);color:var(--fg);overflow-wrap:anywhere}.files .counts{margin-left:auto;white-space:nowrap;font:10px var(--mono)}.add{color:var(--ok)}.del{color:var(--crit)}.tag{font-size:10px;color:var(--subtle)}.group{margin:25px 0 8px;font-size:13px;font-weight:750}.files p{font-size:12px;color:var(--muted)}footer{border-top:1px solid var(--rule);margin-top:38px;padding-top:16px;color:var(--muted);font-size:11px}.pending{color:var(--warn);font-weight:700}@media(max-width:900px){.wrap{grid-template-columns:1fr;padding:24px 20px 64px;gap:25px}nav{position:static}nav ol{display:flex;flex-wrap:wrap;gap:0 17px}nav li{margin:4px 0}nav small{display:none}h1{font-size:34px}}@media(max-width:600px){.ba{grid-template-columns:1fr}h1{font-size:30px}.sub{font-size:16px}th,td{padding:8px 6px;font-size:12px}.wrap{padding:22px 14px 54px}}@media print{.wrap{display:block;padding:0}nav{display:none}details{break-inside:avoid}h2{break-after:avoid}.fig{break-inside:avoid}a{color:var(--fg)}}
-
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a ledger shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ feeb7197cc0498b72edd03af2ebc55bfb5310d49
33 changed files · +2872 / −21 against merge base · Admission repair included

Source links open commit-pinned files; displayed path:line labels identify the exact lines.

+
Agent Server → Python & TypeScript · PR #5603

A budget belongs to a run.

Expose max_budget_per_run through conversation creation, persistence, and clients. Before each SDK LLM attempt, reserve its bounded cost against a tree shared with delegates; each top-level run receives a fresh allowance.

★ Review the admission boundary first: the next request needs a defensible finite cost bound, and attempted work must remain accounted for across retries. The focused repair addresses pricing, request caps, and failure settlement.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846

Before: OpenHands/software-agent-sdk @ 3f26a0570e96e92c6af0b75388e31c9bf4f23efc
After source links: analisaperlengkapan/software-agent-sdk @ 46603bb4abcd256a0a36e3e59b3c80c70dbef7ca
33 changed files · +2872 / −21 against merge base · Admission repair included

Source links open commit-pinned files; displayed path:line labels identify the exact lines.

-

01The option now crosses the public API

Source manifest
-

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

+

01The option now crosses the public API

Source manifest
+

The base had a LocalConversation budget option and a step-level cost check. It lacked the option on the public factory, shared creation contract, remote proxy, and handwritten TypeScript conversation interfaces. This PR threads one nullable positive USD value through those surfaces.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:249 · openhands-sdk/openhands/sdk/conversation/conversation.py:76 · openhands-sdk/openhands/sdk/conversation/request.py:210 · clients/typescript/src/models/conversation.ts:64

Before · merge base
# Local implementation only
 LocalConversation(..., max_budget_per_run=None)
 
@@ -19,21 +19,21 @@
 
 // TypeScript remote constructor or start option
 { maxBudgetPerRun: 2.5 }
-

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

-
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
+

Sources: openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:173 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:885 · clients/typescript/src/conversation/remote-conversation.ts:188

+
SurfaceCompatibility and behavior
DefaultsNone, omitted, or REST null leaves the run unbounded. A configured budget must be greater than zero. openhands-sdk/openhands/sdk/conversation/request.py:210
Python remoteThe new public constructor argument is appended after existing positional slots. Attach reads the server's stored value. openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:780 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
ACPA configured budget raises CostBudgetUnsupportedError; REST returns HTTP 422. ACP prompts bypass the SDK LLM reservation path. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:185 · openhands-agent-server/openhands/agent_server/conversation_router.py:347
Generated TypeScriptThe generated snapshot remains pinned to Agent Server 1.55.1. The new conversation create/info field lives in the handwritten models; this PR does not regenerate that release snapshot. clients/typescript/src/generated/agent-server-schema.ts:2 · clients/typescript/src/models/conversation.ts:64
-

02Reserve before each SDK LLM attempt

Source manifest
-
The admission check moves before each SDK LLM attemptBefore: a local run checks accumulated lifetime metrics after a step. After: SDK LLM attempts reserve against one shared ledger before transport, then settle each attempt. Authoritative bounds, transport controls, and conservative settlement are the focused repair described in section 3.

02Reserve before each SDK LLM attempt

Source manifest
+
The admission check moves before each SDK LLM attemptBefore: a local run checks accumulated lifetime metrics after a step. After: SDK LLM attempts reserve against one shared tree before transport, then settle each attempt. Authoritative bounds, transport controls, and conservative settlement are the focused repair described in section 3.LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1950CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2129new gateCostBudget.settle()complete cost: actual; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
-

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

-

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

-

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost when it accounts for the applicable rates. Published endpoint-uplift routes retain the reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. After an attempt starts, failed, cancelled, or unknown-cost successful work also retains at least its reserved bound and any larger recorded delta. Setup or refusal before admission remains uncharged. The ledger tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1163 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

+LocalConversation._run()agent.step() performs worklocal_conversation.py:2039_budget_exceeded_detail()reads lifetime accumulated_costlocal_conversation.py:737_emit_run_limit_error()stops after ceiling is reachedlocal_conversation.py:753LLM.*completion / *responsesfull input cap + final output capllm.py:1950CostBudget.try_reserve()settled + reserved + next ≤ limitcost_budget.py:130LLM._transport_call()SDK-owned · LiteLLM retries=0llm.py:2129new gateCostBudget.settle()complete cost: actual; else: boundcost_budget.py:147Denied reservation → LLMBudgetExceededErrorrun status ERROR + MaxBudgetReached
Fig 1 · The added gate reserves before transport. A post-step check remains, but it no longer has to admit concurrent LLM work using only completed metrics. Failed or uncertain attempted work remains accounted for before retry admission.
+

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:737 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

+

CostBudget.try_reserve() compares and reserves under one lock. Parent and child views contend on the same settled/reserved totals; a child enforces its own limit and every ancestor limit over that shared total.

Sources: openhands-sdk/openhands/sdk/llm/cost_budget.py:68 · openhands-sdk/openhands/sdk/llm/cost_budget.py:130 · openhands-sdk/openhands/sdk/llm/cost_budget.py:85

+

The guard wraps each retry attempt for sync/async completions and responses. Known positive success settles to recorded cost when it accounts for the applicable rates. Published endpoint-uplift routes retain the reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. After an attempt starts, failed, cancelled, or unknown-cost successful work also retains at least its reserved bound; a known success settles to the attempt's own recorded cost, keyed by the response id it returned so a concurrent call on the same LLM cannot supply the figure. Setup or refusal before admission remains uncharged. The tree tracks conservative budget usage separately from recorded lifetime metrics and provider billing.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:147

A denial becomes LLMBudgetExceededError, excluded from retry/fallback and surfaced by the run loop as MaxBudgetReached with status ERROR.

Sources: openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:144 · openhands-sdk/openhands/sdk/llm/llm.py:1163 · openhands-sdk/openhands/sdk/llm/fallback_strategy.py:70 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2226

-

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

-
⚠ The original PR's admission projection at 091882a invented rates for missing price data and a 32,768-token cap for missing limits. A finite fallback is an estimate, not an upper bound; published context tiers can also exceed the selected base rates.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:180 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1765 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1789 @ 091882a

+

03A finite bound must survive retries

Source manifest

Every budgeted call reserves the authoritative published or exact runtime input/context maximum plus the actual outgoing finite output cap, each priced at its directional bound. Attempts remain under SDK retry control. Uncertain attempted work retains conservative budget usage. The repair adds no REST field or reporting surface.

+
⚠ The original PR's admission projection at 091882a invented rates for missing price data and a 32,768-token cap for missing limits. A finite fallback is an estimate, not an upper bound; published context tiers can also exceed the selected base rates.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:180 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1765 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1789 @ 091882a

A deterministic probe at 091882a, using packaged LiteLLM metadata for xai/grok-4-fast-reasoning, admitted a $0.1640005 reservation under a $0.20 cap while the published tier arithmetic was $0.2512004. This was metadata arithmetic with no provider request or billed usage.

Reproduction inputs and arithmetic · original PR at 091882a
input tokens:          128,001
 maximum output tokens: 200,000
@@ -45,26 +45,26 @@
 No credentials, network, or model completion used.
 Metadata snapshot SHA-256:
 cc1955f7f1cdc2cdd9ebf216e7eebbc90bbb3528b62439327f7e8b893eda7c12

Observed against the pre-repair admission helper; the focused regression tests belong with the repair.

-

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1781 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1857

-

Failed attempts cannot receive a blind refund

A separate deterministic fixture at 091882a followed the real SDK completion, telemetry, validation, and retry path. Two invalid-response attempts each reserved $0.11; under a $0.15 cap, SDK telemetry recorded $0.22 while the budget ledger finished at zero. A timeout fixture separately reproduced reuse of allowance for uncertain attempted work. These probes establish SDK accounting behavior, without a provider request or billing claim.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:1833 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1887 @ 091882a

-
  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Bound standard token-priced text and SDK function calls. Refuse audio/non-text output, web_search_options even when empty, prepared non-function/native server tools, nonempty legacy functions overrides, and opaque cached_content references. A search price schedule alone does not imply search work: resolved ordinary GPT/o, Claude, and Gemini routes in chat or Responses mode are allowed when no search is requested; intrinsic search and unresolved fee-bearing routes remain closed. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost when it can account for the applicable rates. Published endpoint-uplift routes retain their full reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. Failed, cancelled, or unknown-cost successful attempts also retain at least the reserved bound; a larger recorded delta counts too. Setup or refusal before admission remains uncharged.
-
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

-

has_unbounded_search_cost() distinguishes an optional search price schedule from intrinsic search work. Resolved ordinary GPT/o, Claude, and Gemini routes retain text/function-call support in chat or Responses mode when no search is requested, including supported Azure, Bedrock, and Vertex routes. Intrinsic search aliases, raw and dated routes, and unresolved fee-bearing routes remain refused. The shared guard checks prepared tools, including the Responses wire payload, and rejects non-function/native server tools. Nonempty legacy functions overrides and opaque cached_content references also fail before transport because they can hide hosted tools.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:146 · openhands-sdk/openhands/sdk/llm/llm.py:1889 · openhands-sdk/openhands/sdk/llm/llm.py:1893 · tests/sdk/llm/test_cost_budget.py:425 · tests/sdk/llm/test_cost_budget.py:479 · tests/sdk/llm/test_cost_budget.py:654 · tests/sdk/llm/test_cost_budget.py:696 · tests/sdk/llm/test_cost_budget.py:743 · tests/sdk/llm/fixtures/optional-search-model-info.json:1 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

-

The bot’s second review reproduced ordinary Claude and Gemini conversations failing only when a budget was enabled. Regression coverage now runs all four public LLM wrappers across resolved provider routes and exercises real Conversation.run()/arun() paths against local Anthropic/Gemini HTTP adapters; those conversations finish with positive recorded cost and exactly one provider request.

Sources: tests/sdk/llm/test_budget_denial_http.py:56

-

The third review caught the same false refusal for ordinary models whose catalog mode is responses. The classification now applies the existing provider/family rules to both chat and responses. Captured gpt-5.3-codex metadata exercises all four public wrappers; direct OpenAI and OpenHands aliases also run Conversation.run()/arun() through the automatically selected Responses HTTP adapter. Native-search requests still refuse before transport.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:146 · tests/sdk/llm/test_cost_budget.py:479 · tests/sdk/llm/test_budget_denial_http.py:125 · tests/sdk/llm/test_cost_budget.py:654 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

-

The resolved Vertex Claude fixture also publishes a regional endpoint uplift. Admission includes that multiplier as well as regional processing uplifts; a ceiling that covers the base token rates but misses the endpoint surcharge is refused. Older catalog snapshots can omit that premium: published pricing on those Vertex Claude routes is refused until its endpoint multiplier is known. Since installed LiteLLM telemetry may omit the premium even when the catalog includes it, successful calls retain at least the full reservation. Paired custom prices remain an explicit override. Recorded lifetime metrics are unchanged.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1850 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

-

Opaque clients, an empty web_search_options object, and audio/non-text output options still fail before transport. These cases do not add vendor-fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:369 · tests/sdk/llm/test_cost_budget.py:398

-

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/call_context.py:29

+

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1781 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1857

+

Failed attempts cannot receive a blind refund

A separate deterministic fixture at 091882a followed the real SDK completion, telemetry, validation, and retry path. Two invalid-response attempts each reserved $0.11; under a $0.15 cap, SDK telemetry recorded $0.22 while the budget tree finished at zero. A timeout fixture separately reproduced reuse of allowance for uncertain attempted work. These probes establish SDK accounting behavior, without a provider request or billing claim.

Repair before-state: openhands-sdk/openhands/sdk/llm/llm.py:1833 @ 091882a · openhands-sdk/openhands/sdk/llm/llm.py:1887 @ 091882a

+
  1. Use the provider’s published or exact runtime input/context maximum for every budgeted call. Generic token estimates and smaller user max_input_tokens hints do not reduce this bound.
  2. Add the actual outgoing finite output cap. Each direction uses its highest published context, cache, or reasoning rate and regional uplift; paired configured input/output prices are an explicit override.
  3. Refuse unknown price or cap, opaque client/route/body overrides, and multiple completions. Supported output-cap overrides must be included in the prepared request bound; arbitrary kwargs are not promised safe.
  4. Bound standard token-priced text and SDK function calls. Refuse audio/non-text output, web_search_options even when empty, prepared non-function/native server tools, nonempty legacy functions overrides, and opaque cached_content references. A search price schedule alone does not imply search work: resolved ordinary GPT/o, Claude, and Gemini routes in chat or Responses mode are allowed when no search is requested; intrinsic search and unresolved fee-bearing routes remain closed. Vendor fee accounting is outside this bound.
  5. Keep each admitted attempt under SDK control: set both LiteLLM num_retries and max_retries to zero, and refuse unsafe global retry, fallback, or parameter-dropping policies without mutating shared globals.
  6. Known positive success settles to recorded cost when it can account for the applicable rates. Published endpoint-uplift routes retain their full reservation because installed telemetry may omit that premium, unless paired custom prices override the catalog. Failed, cancelled, or unknown-cost successful attempts also retain at least the reserved bound; a known success settles to the attempt's own cost, keyed by its response id rather than a shared cumulative metric. Setup or refusal before admission remains uncharged.
+
⚠ Conservative admission can refuse a cheap prompt: the remaining allowance must cover the full possible input request and outgoing output cap, not the tokenizer’s estimate of this prompt. Conservative ledger usage may also exceed recorded lifetime cost when attempted work has no trustworthy usage report. This is a budget decision, not a claim about the provider’s bill.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/llm.py:1811 · openhands-sdk/openhands/sdk/llm/llm.py:1950 · openhands-sdk/openhands/sdk/llm/cost_budget.py:102 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807

+

has_unbounded_search_cost() distinguishes an optional search price schedule from intrinsic search work. Resolved ordinary GPT/o, Claude, and Gemini routes retain text/function-call support in chat or Responses mode when no search is requested, including supported Azure, Bedrock, and Vertex routes. Intrinsic search aliases, raw and dated routes, and unresolved fee-bearing routes remain refused. The shared guard checks prepared tools, including the Responses wire payload, and rejects non-function/native server tools. Nonempty legacy functions overrides and opaque cached_content references also fail before transport because they can hide hosted tools.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:146 · openhands-sdk/openhands/sdk/llm/llm.py:1889 · openhands-sdk/openhands/sdk/llm/llm.py:1893 · tests/sdk/llm/test_cost_budget.py:425 · tests/sdk/llm/test_cost_budget.py:479 · tests/sdk/llm/test_cost_budget.py:654 · tests/sdk/llm/test_cost_budget.py:696 · tests/sdk/llm/test_cost_budget.py:743 · tests/sdk/llm/fixtures/optional-search-model-info.json:1 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

+

The bot’s second review reproduced ordinary Claude and Gemini conversations failing only when a budget was enabled. Regression coverage now runs all four public LLM wrappers across resolved provider routes and exercises real Conversation.run()/arun() paths against local Anthropic/Gemini HTTP adapters; those conversations finish with positive recorded cost and exactly one provider request.

Sources: tests/sdk/llm/test_budget_denial_http.py:56

+

The third review caught the same false refusal for ordinary models whose catalog mode is responses. The classification now applies the existing provider/family rules to both chat and responses. Captured gpt-5.3-codex metadata exercises all four public wrappers; direct OpenAI and OpenHands aliases also run Conversation.run()/arun() through the automatically selected Responses HTTP adapter. Native-search requests still refuse before transport.

Sources: openhands-sdk/openhands/sdk/llm/utils/model_features.py:146 · tests/sdk/llm/test_cost_budget.py:479 · tests/sdk/llm/test_budget_denial_http.py:125 · tests/sdk/llm/test_cost_budget.py:654 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

+

The resolved Vertex Claude fixture also publishes a regional endpoint uplift. Admission includes that multiplier as well as regional processing uplifts; a ceiling that covers the base token rates but misses the endpoint surcharge is refused. Older catalog snapshots can omit that premium: published pricing on those Vertex Claude routes is refused until its endpoint multiplier is known. Since installed LiteLLM telemetry may omit the premium even when the catalog includes it, successful calls retain at least the full reservation. Paired custom prices remain an explicit override. Recorded lifetime metrics are unchanged.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1850 · tests/sdk/llm/fixtures/optional-search-provider-info.json:1

+

Opaque clients, an empty web_search_options object, and audio/non-text output options still fail before transport. These cases do not add vendor-fee accounting or establish image/vision behavior.

Sources: tests/sdk/llm/test_cost_budget.py:369 · tests/sdk/llm/test_cost_budget.py:398

+

The focused repair does not add a REST option, rename the budget, or add current-run reporting. Unbudgeted calls keep their existing path; the stricter admission applies when a budget is bound to the SDK LLM call context.

Sources: openhands-sdk/openhands/sdk/llm/llm.py:1857 · openhands-sdk/openhands/sdk/llm/call_context.py:29

-

04Each top-level run gets a fresh allowance

Source manifest
-
Each top-level run receives a fresh allowance; delegates share itA new top-level run resets its owned ledger while lifetime metrics remain cumulative. Delegated children share the ledger and ancestor ceilings. A fork copies the configured limit but owns a separate ledger.

04Each top-level run gets a fresh allowance

Source manifest
+
Each top-level run receives a fresh allowance; delegates share itA new top-level run resets its owned tree while lifetime metrics remain cumulative. Delegated children share the tree and every ancestor ceiling is enforced over its own subtree. A fork copies the configured limit but owns a separate tree.ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned ledgerlocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)same total; tightest ancestor ceilingcost_budget.py:68LocalConversation.fork()same configured cap; own ledgerlocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent ledger.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned ledger define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

-
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a ledger it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent ledger. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
+ConversationStatslifetime cost kept for reportinglocal_conversation.py:807_begin_budget_run()save baseline; reset owned treelocal_conversation.py:846CostBudget.reseed()fresh top-level run allowancecost_budget.py:118CostBudget.child(limit)own subtree total; ancestor ceilingscost_budget.py:68LocalConversation.fork()same configured cap; own treelocal_conversation.py:1010delegated childindependent ownershipBefore: every later run compared the same lifetime cost against the ceiling.After: current-run cost = lifetime cost − run-start baseline; children never reset a parent tree.
Fig 2 · Lifetime reporting remains cumulative. The run-start baseline and owned tree define the current allowance; a fork is independent, while a delegate shares the parent's live budget.

Sources: openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:807 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010 · openhands-sdk/openhands/sdk/llm/cost_budget.py:68

+
TransitionOwner and resulting behavior
New run / arunLocalConversation records lifetime cost as the baseline and reseeds only a tree it owns. Later runs do not consume an earlier run's allowance. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:846
DelegateInherits the parent ledger; an optional child limit uses a derived view. Starting the delegate never reseeds the shared tree. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:558 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:567
ForkCopies the configured cap and strips the source budget from the inherited call context, yielding an independent tree. openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1010
Server restart / attachThe configured cap persists through ConversationConfig → StoredConversation; hydration forwards it, response composition exposes it, and the Python remote proxy reads it. openhands-sdk/openhands/sdk/conversation/request.py:210 · openhands-agent-server/openhands/agent_server/models.py:104 · openhands-agent-server/openhands/agent_server/event_service.py:1303 · openhands-agent-server/openhands/agent_server/conversation_service.py:347 · openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1080
Step snapshotmark_dirty() makes in-place stats mutations flush through the existing state lock/snapshot path. It updates lifetime cost snapshots; it does not add a current-run meter field. openhands-sdk/openhands/sdk/conversation/state.py:772 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:2164
-

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
+

05Follow the contract, then the admission gate

Grouped source index with per-file deltas. Expand a row for its exact diff against the merge base. Added files have no before-state link.

Public contract & clients

✏️ changedopenhands-sdk/openhands/sdk/conversation/conversation.py+5 / −0

Adds the keyword option to local and remote factory overloads and forwarding.

openhands-sdk/openhands/sdk/conversation/conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py
 index c508fef15..510ac4b9a 100644
 --- a/openhands-sdk/openhands/sdk/conversation/conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/conversation.py
@@ -108,7 +108,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=stuck_detection, stuck_detection_thresholds=stuck_detection_thresholds, visualizer=visualizer, -

✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/request.py+13 / −0

Adds a nullable, positive USD budget to the common create/persisted configuration.

openhands-sdk/openhands/sdk/conversation/request.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/request.py b/openhands-sdk/openhands/sdk/conversation/request.py
 index 4885b35ef..64ad8aff9 100644
 --- a/openhands-sdk/openhands/sdk/conversation/request.py
 +++ b/openhands-sdk/openhands/sdk/conversation/request.py
@@ -132,8 +132,8 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="If true, the conversation will use stuck detection to " -

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+6 / −0

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
-index 907b700d3..ca4d8b0fd 100644
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py+38 / −2

Sends the field on create and restores it when attaching; existing public constructor positions stay stable.

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
+index 907b700d3..9a45b140d 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py
 @@ -777,6 +777,7 @@ class RemoteConversation(BaseConversation):
@@ -184,7 +184,60 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi self.workspace = workspace self._client = workspace.client self._cleanup_initiated = False -

✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
+@@ -1203,13 +1209,40 @@ class RemoteConversation(BaseConversation):
+         all_callbacks = self._callbacks + [run_complete_callback]
+         composed_callback = BaseConversation.compose_callbacks(all_callbacks)
+ 
++        # Track which events the callbacks have seen, so that events the
++        # server recorded while the socket was reconnecting (and that only
++        # reached the cache through REST reconciliation) can be replayed to
++        # them once, without the live socket delivering them a second time.
++        delivered_ids: set[str] = set()
++        replayed_ids: set[str] = set()
++
++        def socket_callback(event: Event) -> None:
++            if event.id in replayed_ids:
++                replayed_ids.discard(event.id)
++                return
++            delivered_ids.add(event.id)
++            composed_callback(event)
++
++        def replay_missed_events() -> None:
++            self._state.events.reconcile()
++            replayed_ids.clear()
++            for event in self._state.events[:]:
++                if event.id in delivered_ids:
++                    continue
++                delivered_ids.add(event.id)
++                replayed_ids.add(event.id)
++                try:
++                    composed_callback(event)
++                except Exception:
++                    logger.exception("ws_replay_callback_error", stack_info=True)
++
+         # Initialize WebSocket client for callbacks
+         self._ws_client = WebSocketCallbackClient(
+             host=self.workspace.host,
+             conversation_id=str(self._id),
+-            callback=composed_callback,
++            callback=socket_callback,
+             api_key=self.workspace.api_key,
+-            on_reconnect=self._state.events.reconcile,
++            on_reconnect=replay_missed_events,
+         )
+         self._ws_client.start()
+ 
+@@ -1247,6 +1280,9 @@ class RemoteConversation(BaseConversation):
+         # were emitted between the initial REST sync and WebSocket subscription.
+         # This is the "reconciliation" part of the subscription handshake.
+         self._state.events.reconcile()
++        # Callbacks are not replayed history: only events recorded after this
++        # point and missed by the socket are replayed on reconnect.
++        delivered_ids.update(event.id for event in self._state.events[:])
+ 
+     def _create_llm_completion_log_callback(self) -> ConversationCallbackType:
+         """Create a callback that writes LLM completion logs to client filesystem."""
+
✏️ changedopenhands-agent-server/openhands/agent_server/models.py+11 / −0

Exposes the field in conversation info; StoredConversation inherits its persisted config.

openhands-agent-server/openhands/agent_server/models.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py
 index ea2d8d332..30b317ac8 100644
 --- a/openhands-agent-server/openhands/agent_server/models.py
 +++ b/openhands-agent-server/openhands/agent_server/models.py
@@ -206,11 +259,23 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: bool = Field( default=True, description="Whether to enable stuck detection for the agent.", -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+1 / −0

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
-index e7df77d9a..49a2d879f 100644
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_service.py+14 / −2

Composes the response budget from stored configuration.

openhands-agent-server/openhands/agent_server/conversation_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py
+index e7df77d9a..95fdf43aa 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py
-@@ -344,6 +344,7 @@ def _compose_conversation_info(
+@@ -63,7 +63,10 @@ from openhands.sdk import LLM, Event, Message, TextContent
+ from openhands.sdk.agent import ACPAgent
+ from openhands.sdk.agent.acp_file_credentials import CODEX_AUTH_SECRET_NAME
+ from openhands.sdk.agent.base import AgentBase
+-from openhands.sdk.conversation.impl.local_conversation import LocalConversation
++from openhands.sdk.conversation.impl.local_conversation import (
++    LocalConversation,
++    reject_acp_budget,
++)
+ from openhands.sdk.conversation.persistence_const import BASE_STATE
+ from openhands.sdk.conversation.state import (
+     ConversationExecutionStatus,
+@@ -344,6 +347,7 @@ def _compose_conversation_info(
      )
      return ConversationInfo(
          **state.model_dump(mode="json"),
@@ -218,8 +283,39 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi title=stored.title, metrics=stored.metrics, created_at=stored.created_at, -

✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+1 / −0

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
-index 83721a6da..923713fbe 100644
+@@ -1599,6 +1603,11 @@ class ConversationService:
+                 "The agent uses tools whose modules the agent server cannot "
+                 f"import: {', '.join(missing_tools)}"
+             )
++        # Reject an unenforceable ACP budget before the worktree/branch below
++        # exists. ``LocalConversation.__init__`` would also reject, but by then
++        # this method has already run ``git worktree add`` and the conversation
++        # is never registered, so ``delete_conversation`` could not clean it up.
++        reject_acp_budget(launched.agent, request.max_budget_per_run)
+         workspace = (
+             _create_conversation_worktree(worktree)
+             if worktree is not None
+@@ -2747,7 +2756,7 @@ class WebhookSubscriber(Subscriber):
+         if self._buffer_full():
+             if self._post_lock.locked():
+                 # The in-flight _post_events re-checks the buffer when it is
+-                # done; the timer covers a failed post.
++                # done and re-arms the timer if a post fails.
+                 self._start_flush_timer()
+                 return
+             # Cancel timer since we're flushing due to buffer size
+@@ -2775,6 +2784,9 @@ class WebhookSubscriber(Subscriber):
+                 events_to_post, event_data = self._take_batch(events_remaining)
+                 if not await self._post_batch(event_data):
+                     self._requeue(events_to_post)
++                    # A timer that fired during the POST saw an empty queue
++                    # and exited, so arm one for the requeued events.
++                    self._start_flush_timer()
+                     return
+                 events_remaining -= len(events_to_post)
+                 if not events_remaining and self._buffer_full():
+
✏️ changedopenhands-agent-server/openhands/agent_server/event_service.py+13 / −4

Forwards stored configuration when hydrating the execution service.

openhands-agent-server/openhands/agent_server/event_service.py:1 · before:1

diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py
+index 83721a6da..6f7bd31bc 100644
 --- a/openhands-agent-server/openhands/agent_server/event_service.py
 +++ b/openhands-agent-server/openhands/agent_server/event_service.py
 @@ -1300,6 +1300,7 @@ class EventService:
@@ -230,7 +326,43 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection=self.stored.stuck_detection, visualizer=None, secrets=self.stored.secrets, -

✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
+@@ -1486,23 +1487,31 @@ class EventService:
+ 
+         # Use lock to make check-and-set atomic, preventing race conditions
+         async with self._run_lock:
++            # A stale internal ACP rerun is dropped before any refusal: an
++            # explicit stop that landed while we waited on _run_lock must win,
++            # and a refusal would make the caller re-arm the rerun.
+             if (
++                acp_internal_rerun_generation is not None
++                and self._explicit_interrupt_generation != acp_internal_rerun_generation
++            ):
++                return
++            # _run_task first: a live run holds the state lock across its step
++            # (a tool call can last minutes), so reading execution status would
++            # block this request behind it instead of answering at once.
++            if (self._run_task is not None and not self._run_task.done()) or (
+                 await self._get_execution_status()
+                 == ConversationExecutionStatus.RUNNING
+             ):
+                 raise ValueError("conversation_already_running")
+             if self._closing:
+                 raise ValueError("inactive_service")
++            # Re-check: the status read above can yield to a late stop too.
+             if (
+                 acp_internal_rerun_generation is not None
+                 and self._explicit_interrupt_generation != acp_internal_rerun_generation
+             ):
+                 return
+ 
+-            # Check if there's already a running task
+-            if self._run_task is not None and not self._run_task.done():
+-                raise ValueError("conversation_already_running")
+-
+             # Capture conversation reference for the closure
+             conversation = self._conversation
+ 
+
✏️ changedopenhands-agent-server/openhands/agent_server/conversation_router.py+2 / −1
diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py
 index 23968b2d8..dc8e39b81 100644
 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py
 +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py
@@ -251,7 +383,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) ) from e -

✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
+
✏️ changedclients/typescript/src/models/conversation.ts+13 / −0

Adds the optional nullable property to handwritten create and info interfaces.

clients/typescript/src/models/conversation.ts:1 · before:1

diff --git a/clients/typescript/src/models/conversation.ts b/clients/typescript/src/models/conversation.ts
 index bfd1b6eec..f887053d8 100644
 --- a/clients/typescript/src/models/conversation.ts
 +++ b/clients/typescript/src/models/conversation.ts
@@ -289,7 +421,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: boolean; workspace: Record<string, unknown>; hook_config?: HookConfig | null; -

✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
+
✏️ changedclients/typescript/src/conversation/remote-conversation.ts+11 / −0

Forwards constructor/start options, with start precedence; absent budget serializes as null.

clients/typescript/src/conversation/remote-conversation.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/remote-conversation.ts b/clients/typescript/src/conversation/remote-conversation.ts
 index 04091d075..9a61d6257 100644
 --- a/clients/typescript/src/conversation/remote-conversation.ts
 +++ b/clients/typescript/src/conversation/remote-conversation.ts
@@ -344,11 +476,39 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? this.stuckDetection ?? true, workspace: { type: 'local', working_dir: this.workspace.workingDir }, hook_config: hookConfig ?? null, -

✏️ changedclients/typescript/src/conversation/conversation-manager.ts+7 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
-index 8ac58e347..3f3c66fc7 100644
+
✏️ changedclients/typescript/src/conversation/conversation-manager.ts+14 / −0

Forwards ACP creation options; server rejection remains the enforcement boundary.

clients/typescript/src/conversation/conversation-manager.ts:1 · before:1

diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts
+index 8ac58e347..2dc8f3d64 100644
 --- a/clients/typescript/src/conversation/conversation-manager.ts
 +++ b/clients/typescript/src/conversation/conversation-manager.ts
-@@ -341,6 +341,12 @@ export class ConversationManager {
+@@ -210,6 +210,11 @@ export class ConversationManager {
+       stuckDetection?: boolean;
+       workingDir?: string;
+       userId?: string;
++      /**
++       * Per-run cost ceiling in USD. Must be > 0 when set. Enforced
++       * server-side before each LLM call.
++       */
++      maxBudgetPerRun?: number;
+     } = {}
+   ): Promise<RemoteConversation> {
+     const workspace = new RemoteWorkspace({
+@@ -222,6 +227,7 @@ export class ConversationManager {
+       maxIterations: options.maxIterations,
+       stuckDetection: options.stuckDetection,
+       userId: options.userId,
++      maxBudgetPerRun: options.maxBudgetPerRun,
+     });
+ 
+     await conversation.start({
+@@ -229,6 +235,7 @@ export class ConversationManager {
+       maxIterations: options.maxIterations,
+       stuckDetection: options.stuckDetection,
+       userId: options.userId,
++      maxBudgetPerRun: options.maxBudgetPerRun,
+     });
+ 
+     return conversation;
+@@ -341,6 +348,12 @@ export class ConversationManager {
        stuckDetection?: boolean;
        workingDir?: string;
        userId?: string;
@@ -361,7 +521,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi } = {} ): Promise<ACPConversationInfo> { let initialMessage: CreateACPConversationRequest['initial_message']; -@@ -355,6 +361,7 @@ export class ConversationManager { +@@ -355,6 +368,7 @@ export class ConversationManager { agent, initial_message: initialMessage, max_iterations: options.maxIterations || 500, @@ -369,8 +529,8 @@

Failed attempts cannot receive a blind refund

A separate deterministi stuck_detection: options.stuckDetection ?? true, workspace: { type: 'local', working_dir: options.workingDir || '/tmp' }, user_id: options.userId ?? null, -

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+151 / −9

Owns/reset run allowance, shares delegated budgets, separates fork ledgers, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
-index 9c56cf0b1..447231fb6 100644
+

Admission & lifecycle

✏️ changedopenhands-sdk/openhands/sdk/conversation/impl/local_conversation.py+166 / −9

Owns/reset run allowance, shares delegated budgets, separates fork trees, rejects ACP, flushes stats, and surfaces denials.

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
+index 9c56cf0b1..8c7125188 100644
 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
 @@ -5,6 +5,7 @@ import copy
@@ -403,21 +563,28 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi from openhands.sdk.llm.llm_profile_store import LLMProfileStore from openhands.sdk.llm.llm_registry import LLMRegistry from openhands.sdk.logger import get_logger -@@ -177,6 +182,26 @@ def _copy_event_for_fork(event: Event) -> Event: +@@ -177,6 +182,33 @@ def _copy_event_for_fork(event: Event) -> Event: return Event.model_validate_json(event.model_dump_json(exclude_none=True)) -+def _reject_acp_budget( -+ agent: AgentBase | None, max_budget_per_run: float | None -+) -> None: -+ """Reject a per-run budget on an ACP agent, which cannot enforce one. ++def reject_acp_budget(agent: AgentBase | None, effective_budget: float | None) -> None: ++ """Reject an ACP agent that would run under a cost budget. + + ACP prompts run in an external process that reports usage only after the + turn, so the SDK cannot reserve a call's worst-case cost before sending and + a single prompt can exceed the ceiling. Accepting the budget would silently + promise enforcement the SDK cannot deliver. ++ ++ ``effective_budget`` is the ceiling that actually applies to this ++ conversation -- its own ``max_budget_per_run`` or one inherited from a ++ parent that delegated to it. Either way the ACP agent would bypass ++ reservation, so both are refused. ++ ++ Callers that create host-local state for a conversation (the agent server's ++ worktree/branch, for instance) must call this once the agent is known and ++ before that state exists, so a rejection leaves nothing behind. + """ -+ if max_budget_per_run is None or not isinstance(agent, ACPAgent): ++ if effective_budget is None or not isinstance(agent, ACPAgent): + return + raise CostBudgetUnsupportedError( + "max_budget_per_run is not supported for ACP agents: their " @@ -430,7 +597,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def _same_llm_config(a: LLM, b: LLM) -> bool: """Return whether two LLMs serialize to the same config, secrets included.""" if a is b: -@@ -217,6 +242,9 @@ class LocalConversation(BaseConversation): +@@ -217,6 +249,9 @@ class LocalConversation(BaseConversation): _mcp_tool_provider: MCPToolProvider _mcp_config_from_parent: bool _llm_call_context: LLMCallContext @@ -440,7 +607,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def __init__( self, -@@ -283,6 +311,11 @@ class LocalConversation(BaseConversation): +@@ -283,6 +318,11 @@ class LocalConversation(BaseConversation): hook_config: Optional hook configuration to auto-wire session hooks. If plugins are loaded, their hooks are combined with this config. max_iteration_per_run: Maximum number of iterations per run @@ -452,10 +619,23 @@

Failed attempts cannot receive a blind refund

A separate deterministi visualizer: Visualization configuration. Can be: - ConversationVisualizerBase subclass: Class to instantiate (default: ConversationVisualizer) -@@ -403,6 +436,14 @@ class LocalConversation(BaseConversation): +@@ -403,6 +443,27 @@ class LocalConversation(BaseConversation): ws_path = Path(self.workspace.working_dir) if not ws_path.exists(): ws_path.mkdir(parents=True, exist_ok=True) ++ # A delegate inherits its parent's budget even without one of its own, ++ # and an ACP agent bypasses reservation either way, so the ceiling that ++ # actually applies decides whether this agent is admissible. ++ inherited_budget = ( ++ _parent_llm_call_context.cost_budget ++ if _parent_llm_call_context is not None ++ else None ++ ) ++ effective_budget = ( ++ max_budget_per_run ++ if max_budget_per_run is not None ++ else (inherited_budget.limit if inherited_budget is not None else None) ++ ) + # Reject an ACP budget before ``ConversationState.create`` writes + # ``base_state.json``. A later rejection would leave an orphaned state + # file behind, so a retry with the same id but a different agent would @@ -463,22 +643,22 @@

Failed attempts cannot receive a blind refund

A separate deterministi + # The resumed agent is not known until after ``create``, so only a + # caller-supplied agent can be checked here; the resume path re-checks + # once the persisted agent is loaded. -+ _reject_acp_budget(agent, max_budget_per_run) ++ reject_acp_budget(agent, effective_budget) self._state = ConversationState.create( id=desired_id, agent=agent, -@@ -421,6 +462,10 @@ class LocalConversation(BaseConversation): +@@ -421,6 +482,10 @@ class LocalConversation(BaseConversation): # ``self.agent`` and ``self._state.agent`` are the same object. if agent is None: agent = self._state.agent + # Resume path: the persisted agent is known only now. No state file + # is created here (base_state.json already exists), so re-checking + # the budget cannot orphan anything. -+ _reject_acp_budget(agent, max_budget_per_run) ++ reject_acp_budget(agent, effective_budget) # The persisted agent carries client-tool ``Tool`` specs, but in a # fresh process the ``ClientTool`` *classes* are absent from the # global registry. Re-register them from the persisted specs so -@@ -507,8 +552,34 @@ class LocalConversation(BaseConversation): +@@ -507,8 +572,29 @@ class LocalConversation(BaseConversation): ) self.max_iteration_per_run = max_iteration_per_run @@ -486,14 +666,9 @@

Failed attempts cannot receive a blind refund

A separate deterministi + # Hard cost ceiling (USD) per run; None disables it. self.max_budget_per_run = max_budget_per_run + self._budget_run_baseline = 0.0 -+ inherited_budget = ( -+ _parent_llm_call_context.cost_budget -+ if _parent_llm_call_context is not None -+ else None -+ ) + self._cost_budget: CostBudget | None = None + # True only for the conversation that created the budget; a conversation -+ # that inherited a parent's budget shares its ledger and must never ++ # that inherited a parent's budget shares its tree and must never + # re-seed it mid-flight. + self._owns_budget = False + if max_budget_per_run is not None: @@ -514,7 +689,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi # Initialize stuck detector if stuck_detection: -@@ -725,6 +796,18 @@ class LocalConversation(BaseConversation): +@@ -725,6 +811,18 @@ class LocalConversation(BaseConversation): def conversation_stats(self): return self._state.stats @@ -533,7 +708,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def _latest_acp_prompt_message_id(self) -> str | None: """Id of the most recent ACP prompt message, or None if there is none.""" acp_prompt_messages = [ -@@ -737,19 +820,43 @@ class LocalConversation(BaseConversation): +@@ -737,19 +835,43 @@ class LocalConversation(BaseConversation): def _budget_exceeded_detail(self) -> str | None: """Error detail if the run has hit its cost budget, else None. @@ -552,7 +727,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi + return detail if self.max_budget_per_run is None: + # Inherited a parent's ceiling without one of its own; the shared -+ # ledger already enforces it. ++ # tree already enforces it. return None - spent = self.conversation_stats.get_combined_metrics().accumulated_cost + spent = self._combined_cost() @@ -568,10 +743,10 @@

Failed attempts cannot receive a blind refund

A separate deterministi + def _begin_budget_run(self) -> None: + """Start a fresh per-run budget allowance for a top-level run. + -+ Records the run-start baseline and reseeds the ledger, so each ++ Records the run-start baseline and reseeds the tree, so each + ``run()``/``arun()`` gets its own ``max_budget_per_run`` allowance rather + than inheriting earlier runs' spend. A conversation that only inherited a -+ parent's budget shares the parent's ledger and must not reseed it ++ parent's budget shares the parent's tree and must not reseed it + mid-flight. + """ + self._budget_run_baseline = self._combined_cost_total() @@ -582,7 +757,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def _emit_run_limit_error(self, code: str, detail: str) -> None: """Mark the run failed with a run-limit ConversationErrorEvent.""" logger.error(detail) -@@ -900,11 +1007,18 @@ class LocalConversation(BaseConversation): +@@ -900,11 +1022,18 @@ class LocalConversation(BaseConversation): persistence_dir=fork_persistence, conversation_id=fork_id, max_iteration_per_run=self.max_iteration_per_run, @@ -594,7 +769,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi - _parent_llm_call_context=self._llm_call_context, + # A fork is an independent conversation: inherit the source's + # non-budget call context but not its ceiling, so the fork's -+ # calls never reserve against the source's ledger. Its own ++ # calls never reserve against the source's tree. Its own + # max_budget_per_run (copied from the source) starts fresh. + _parent_llm_call_context=replace( + self._llm_call_context, cost_budget=None @@ -602,7 +777,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi ) # Branch slice copies path_to_root(event) (root-first, re-rootable); -@@ -1963,6 +2077,7 @@ class LocalConversation(BaseConversation): +@@ -1963,6 +2092,7 @@ class LocalConversation(BaseConversation): # Ensure agent is fully initialized (loads plugins and initializes agent) self._ensure_agent_ready() self._cancel_token = CancellationToken() @@ -610,7 +785,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi with self._state: if self._state.execution_status in [ -@@ -2042,6 +2157,11 @@ class LocalConversation(BaseConversation): +@@ -2042,6 +2172,11 @@ class LocalConversation(BaseConversation): finally: self._step_holds_state_lock = False iteration += 1 @@ -622,7 +797,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi # Check for non-finished terminal conditions # Note: We intentionally do NOT check for FINISHED status here. -@@ -2103,6 +2223,12 @@ class LocalConversation(BaseConversation): +@@ -2103,6 +2238,12 @@ class LocalConversation(BaseConversation): raise ConversationRunError( self._state.id, e, persistence_dir=self._state.persistence_dir ) from e @@ -635,7 +810,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi except Exception as e: with self._state: self._state.execution_status = ConversationExecutionStatus.ERROR -@@ -2167,6 +2293,7 @@ class LocalConversation(BaseConversation): +@@ -2167,6 +2308,7 @@ class LocalConversation(BaseConversation): # self-deadlock that ReadTimeouts after 30s (agent-canvas#1072). # _ensure_agent_ready is thread-safe and already runs off-loop in run(). await asyncio.to_thread(self._ensure_agent_ready) @@ -643,7 +818,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi with self._state: if isinstance(self.agent, ACPAgent) and self._state.execution_status in ( -@@ -2321,6 +2448,11 @@ class LocalConversation(BaseConversation): +@@ -2321,6 +2463,11 @@ class LocalConversation(BaseConversation): finally: self._step_holds_state_lock = False iteration += 1 @@ -655,7 +830,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi # astep releases the state lock for the LLM call, so a # message can land mid-step with status still RUNNING and -@@ -2473,6 +2605,10 @@ class LocalConversation(BaseConversation): +@@ -2473,6 +2620,10 @@ class LocalConversation(BaseConversation): ) with self._state: iteration += 1 @@ -666,7 +841,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi pause_requested_during_acp_step = any( isinstance(event, PauseEvent) for event in self._state.events[acp_step_start_event_count:] -@@ -2623,6 +2759,12 @@ class LocalConversation(BaseConversation): +@@ -2623,6 +2774,12 @@ class LocalConversation(BaseConversation): raise ConversationRunError( self._state.id, e, persistence_dir=self._state.persistence_dir ) from e @@ -679,12 +854,12 @@

Failed attempts cannot receive a blind refund

A separate deterministi except Exception as e: with self._state: updated_agent_state = dict(self._state.agent_state) -

🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+186 / −0

Adds the locked shared settled/reserved ledger and child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
+
🟢 addedopenhands-sdk/openhands/sdk/llm/cost_budget.py+244 / −0

Adds the locked shared reservation tree and subtree-scoped child views.

openhands-sdk/openhands/sdk/llm/cost_budget.py:1

diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py
 new file mode 100644
-index 000000000..da9142417
+index 000000000..3c9bbad59
 --- /dev/null
 +++ b/openhands-sdk/openhands/sdk/llm/cost_budget.py
-@@ -0,0 +1,186 @@
+@@ -0,0 +1,244 @@
 +"""Per-conversation cost budget with pre-call reservation.
 +
 +A conversation budget must not merely compare "spent so far" before a call:
@@ -696,13 +871,15 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi +their bound; these conservative charges are budget usage, not billed cost. +Settled + reserved is a live upper bound on what the current run can owe. + -+Settled cost is carried on a ledger shared by a parent and its subagents, so the -+whole tree contends on one running total even though a subagent's metrics join -+the parent only after it finishes. The ledger is reset at the start of each -+top-level run (see :meth:`reseed`), which makes ``max_budget_per_run`` a -+per-run ceiling rather than a lifetime cap. A derived child view keeps every -+ancestor ceiling and enforces the tightest one, so a delegate declaring a larger -+budget than its parent cannot spend past the parent's cap. ++A parent and its subagents share one reservation tree so they contend on one ++running total, but each ceiling is measured against the usage of the *subtree ++it governs*: a view's own ceiling sees only itself and its descendants, while ++an ancestor ceiling sees everything below it. A subagent's ++``max_budget_per_run`` therefore bounds that subagent's own work, and a ++delegate cannot spend past its parent's cap even when it declares a larger ++budget. The tree is reset at the start of each top-level run (see ++:meth:`reseed`), which makes ``max_budget_per_run`` a per-run ceiling rather ++than a lifetime cap. + +Pre-call reservation only covers the LLM call wrappers that install a +reservation (``LLM.completion``/``acompletion``/...). Agents that delegate to an @@ -717,15 +894,17 @@

Failed attempts cannot receive a blind refund

A separate deterministi +from dataclasses import dataclass + + -+class _Ledger: -+ """Shared settled/reserved totals for one budget tree, guarded by one lock.""" ++class _Tree: ++ """Shared state for one budget tree: one lock and one reseed generation.""" + -+ __slots__ = ("lock", "settled", "reserved") ++ __slots__ = ("lock", "generation") + + def __init__(self) -> None: + self.lock = threading.Lock() -+ self.settled = 0.0 -+ self.reserved = 0.0 ++ # Bumped by ``reseed``. A view whose ``_generation`` lags this value ++ # discards its subtree totals on next access, so a fresh run starts from ++ # zero without walking the whole tree. ++ self.generation = 0 + + +@dataclass(frozen=True) @@ -739,130 +918,184 @@

Failed attempts cannot receive a blind refund

A separate deterministi +class CostBudget: + """A cost ceiling (USD) enforced by pre-call reservation. + -+ ``limit`` bounds the cost accrued in the current run (see :meth:`reseed`). -+ The ledger is shared by derived child views, each of which honours the -+ tightest ``limit`` among itself and its ancestors over the same running -+ total. ++ ``limit`` bounds the cost accrued in the current run by this view and its ++ descendants (see :meth:`reseed`). Derived child views share the tree's lock ++ so reservations stay atomic, but each ceiling is compared against the ++ subtree it governs rather than the whole tree. + """ + + def __init__(self, limit: float) -> None: + if limit <= 0: + raise ValueError("Cost budget limit must be strictly positive") + self._limit = limit -+ self._ledger = _Ledger() -+ self._ancestor_limits: tuple[float, ...] = () ++ self._tree = _Tree() ++ self._parent: CostBudget | None = None ++ self._generation = 0 ++ # Settled/reserved budget usage for this view and its descendants. ++ self._subtree_settled = 0.0 ++ self._subtree_reserved = 0.0 + + def child(self, limit: float) -> CostBudget: + """Derive a subagent view with its own (usually smaller) ceiling. + -+ Shares the parent's ledger, so the whole tree contends on one running -+ total, and enforces its own limit *and* every ancestor ceiling. A child -+ cannot declare a larger budget than its parent and spend past the -+ parent's cap. ++ Shares the parent's lock so the whole tree contends on one running ++ total, and enforces its own limit over its own subtree *and* every ++ ancestor ceiling over that ancestor's subtree. A child cannot declare a ++ larger budget than its parent and spend past the parent's cap. + """ + if limit <= 0: + raise ValueError("Cost budget limit must be strictly positive") + view = CostBudget.__new__(CostBudget) + view._limit = limit -+ view._ledger = self._ledger -+ view._ancestor_limits = (*self._ancestor_limits, self._limit) ++ view._tree = self._tree ++ view._parent = self ++ view._generation = self._tree.generation ++ view._subtree_settled = 0.0 ++ view._subtree_reserved = 0.0 + return view + ++ def _sync(self) -> None: ++ """Discard this view's totals if the tree has been reseeded. Lock held.""" ++ generation = self._tree.generation ++ if self._generation != generation: ++ self._subtree_settled = 0.0 ++ self._subtree_reserved = 0.0 ++ self._generation = generation ++ + @property + def limit(self) -> float: -+ """Tightest ceiling this view enforces, including ancestor limits.""" -+ return min((self._limit, *self._ancestor_limits)) -+ -+ def _breaches(self, total: float) -> bool: -+ """True if ``total`` would exceed this view's ceiling or an ancestor's.""" -+ if total > self._limit: -+ return True -+ return any(total > ceiling for ceiling in self._ancestor_limits) ++ """Tightest ceiling among this view and its ancestors. + -+ def _reached(self, total: float) -> bool: -+ """True if ``total`` has already reached a ceiling (``>=`` for reporting).""" -+ if total >= self._limit: -+ return True -+ return any(total >= ceiling for ceiling in self._ancestor_limits) ++ Reported by denial messages so a caller sees the binding cap. Note that ++ each ceiling is *enforced* over its own subtree, so this is a reporting ++ summary rather than the value compared in :meth:`try_reserve`. ++ """ ++ tightest = self._limit ++ node = self._parent ++ while node is not None: ++ tightest = min(tightest, node._limit) ++ node = node._parent ++ return tightest ++ ++ def _breached_limit(self, amount: float) -> float | None: ++ """Tightest ceiling ``amount`` would breach, or ``None`` if it fits. ++ ++ Walks this view and its ancestors, so a child view cannot admit a call ++ that breaches an ancestor's cap. Lock held. ++ """ ++ node: CostBudget | None = self ++ while node is not None: ++ node._sync() ++ total = node._subtree_settled + node._subtree_reserved ++ if total + amount > node._limit: ++ return node._limit ++ node = node._parent ++ return None + + @property + def spent(self) -> float: -+ """Settled budget usage, including bounds retained for unknown work.""" -+ with self._ledger.lock: -+ return max(0.0, self._ledger.settled) ++ """Settled budget usage in this subtree, including bounds for unknown work.""" ++ with self._tree.lock: ++ self._sync() ++ return max(0.0, self._subtree_settled) + + @property + def reserved(self) -> float: -+ with self._ledger.lock: -+ return self._ledger.reserved ++ with self._tree.lock: ++ self._sync() ++ return self._subtree_reserved + + @property + def live_total(self) -> float: -+ """Live upper bound on cost: settled + in-flight reservations.""" -+ with self._ledger.lock: -+ return self._ledger.settled + self._ledger.reserved ++ """Live upper bound on subtree cost: settled + in-flight reservations.""" ++ with self._tree.lock: ++ self._sync() ++ return self._subtree_settled + self._subtree_reserved + + def reseed(self) -> None: -+ """Start a fresh per-run allowance. ++ """Start a fresh per-run allowance for the whole tree. + -+ Zeroes the shared ledger so each top-level ``run()``/``arun()`` gets its -+ own ``max_budget_per_run`` allowance rather than inheriting earlier runs' -+ spend. A run that inherited a parent's budget shares the parent's ledger -+ and must not reseed it mid-flight. ++ Bumps the tree generation so every view resets its subtree totals on ++ next access, giving each top-level ``run()``/``arun()`` its own ++ ``max_budget_per_run`` allowance rather than inheriting earlier runs' ++ spend. A run that inherited a parent's budget shares the parent's ++ generation and must not reseed it mid-flight. + """ -+ with self._ledger.lock: -+ self._ledger.settled = 0.0 -+ self._ledger.reserved = 0.0 ++ with self._tree.lock: ++ self._tree.generation += 1 + + def try_reserve(self, amount: float) -> BudgetReservation | None: + """Atomically reserve ``amount`` or return ``None`` if it would exceed. + + The comparison and the reservation happen under one lock, so parallel -+ callers cannot all pass the same check. Every ancestor ceiling is checked -+ alongside this view's own, so a child view cannot admit a call that -+ breaches a parent's cap. ++ callers cannot all pass the same check. This view's ceiling and every ++ ancestor ceiling are checked over their own subtrees, so a child view ++ cannot admit a call that breaches a parent's cap. + """ + if amount < 0: + raise ValueError("Reservation amount cannot be negative") -+ with self._ledger.lock: -+ total = self._ledger.settled + self._ledger.reserved + amount -+ if self._breaches(total): ++ with self._tree.lock: ++ if self._breached_limit(amount) is not None: + return None -+ self._ledger.reserved += amount ++ node: CostBudget | None = self ++ while node is not None: ++ node._subtree_reserved += amount ++ node = node._parent + return BudgetReservation(self, amount) + + def settle(self, reservation: BudgetReservation, actual: float) -> None: + """Release a reservation and add its settled budget usage. + + The caller supplies recorded cost or retains a conservative bound for -+ failed/unknown work. This ledger does not replace billing metrics. ++ failed/unknown work. This tree does not replace billing metrics. + """ -+ with self._ledger.lock: -+ self._ledger.reserved = max(0.0, self._ledger.reserved - reservation.amount) -+ self._ledger.settled += max(0.0, actual) ++ with self._tree.lock: ++ node: CostBudget | None = reservation.budget ++ while node is not None: ++ node._sync() ++ node._subtree_reserved = max( ++ 0.0, node._subtree_reserved - reservation.amount ++ ) ++ node._subtree_settled += max(0.0, actual) ++ node = node._parent + + def exceeded_detail(self) -> str | None: + """Error detail if the live total has reached a ceiling, else ``None``.""" -+ with self._ledger.lock: -+ total = self._ledger.settled + self._ledger.reserved -+ reached = self._reached(total) -+ if not reached: ++ with self._tree.lock: ++ self._sync() ++ total = self._subtree_settled + self._subtree_reserved ++ limit = self._reached_limit() ++ if limit is None: + return None -+ return self._limit_detail(accumulated=max(total, 0.0)) ++ return self._limit_detail(limit, accumulated=max(total, 0.0)) ++ ++ def _reached_limit(self) -> float | None: ++ """Tightest ceiling this subtree's live total has reached. Lock held.""" ++ node: CostBudget | None = self ++ while node is not None: ++ node._sync() ++ if node._subtree_settled + node._subtree_reserved >= node._limit: ++ return node._limit ++ node = node._parent ++ return None + + def denial_detail(self, amount: float) -> str: + """Message for a reservation of ``amount`` that would exceed a ceiling.""" -+ with self._ledger.lock: -+ total = self._ledger.settled + self._ledger.reserved ++ with self._tree.lock: ++ self._sync() ++ total = self._subtree_settled + self._subtree_reserved ++ limit = self._breached_limit(amount) + return self._limit_detail( -+ accumulated=max(total, 0.0), attempted=max(total, 0.0) + amount ++ limit if limit is not None else self._limit, ++ accumulated=max(total, 0.0), ++ attempted=max(total, 0.0) + amount, + ) + + def _limit_detail( -+ self, *, accumulated: float, attempted: float | None = None ++ self, limit: float, *, accumulated: float, attempted: float | None = None + ) -> str: + detail = ( -+ f"Agent reached maximum budget limit (${self.limit:.4f}); " ++ f"Agent reached maximum budget limit (${limit:.4f}); " + f"budget usage ${accumulated:.4f} (settled and reserved)." + ) + if attempted is not None: @@ -871,7 +1104,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi + + +__all__ = ["BudgetReservation", "CostBudget"] -

✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/call_context.py+7 / −1

Carries the budget through the existing context scope.

openhands-sdk/openhands/sdk/llm/call_context.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/call_context.py b/openhands-sdk/openhands/sdk/llm/call_context.py
 index 6c886595e..8dd7cb5d8 100644
 --- a/openhands-sdk/openhands/sdk/llm/call_context.py
 +++ b/openhands-sdk/openhands/sdk/llm/call_context.py
@@ -897,8 +1130,8 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def for_conversation( self, -

✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+323 / −1

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
-index 3e304d2ee..49069f94c 100644
+
✏️ changedopenhands-sdk/openhands/sdk/llm/llm.py+338 / −1

Prices and guards each synchronous/asynchronous completion and responses attempt.

openhands-sdk/openhands/sdk/llm/llm.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py
+index 3e304d2ee..2d24dc8f9 100644
 --- a/openhands-sdk/openhands/sdk/llm/llm.py
 +++ b/openhands-sdk/openhands/sdk/llm/llm.py
 @@ -2,7 +2,9 @@ from __future__ import annotations
@@ -951,7 +1184,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi from openhands.sdk.llm.utils.openhands_provider import ( LiteLLMCallKwargs, canonicalize_openhands_llm_payload, -@@ -1749,6 +1760,301 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1749,6 +1760,316 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # Chat Completion API # ========================================================================= @@ -1152,8 +1385,8 @@

Failed attempts cannot receive a blind refund

A separate deterministi + Failed/cancelled attempts retain their bound because the provider may + have processed work without returning complete usage. Successful calls + settle to recorded positive cost when its pricing is complete; absent -+ or endpoint-premium-unaware telemetry retains the bound too. This ledger -+ is budget usage, not a provider billing record. Unbudgeted calls keep ++ or endpoint-premium-unaware telemetry retains the bound too. This tracked ++ usage is budget usage, not a provider billing record. Unbudgeted calls keep + their existing transport options and behavior. + """ + @@ -1167,7 +1400,6 @@

Failed attempts cannot receive a blind refund

A separate deterministi + ) + if reservation is None: + return await attempt(*args, **kwargs) -+ cost_before = self.metrics.accumulated_cost + # Keep retries in the SDK guard and never drop the cap as + # an unsupported parameter inside LiteLLM. + kwargs = { @@ -1181,12 +1413,14 @@

Failed attempts cannot receive a blind refund

A separate deterministi + except BaseException: + reservation.budget.settle( + reservation, -+ max(reservation.amount, self._cost_delta(cost_before)), ++ max(reservation.amount, self._attempt_cost(result=None)), + ) + raise -+ cost = self._cost_delta(cost_before) + reservation.budget.settle( -+ reservation, self._budget_success_cost(reservation, cost) ++ reservation, ++ self._budget_success_cost( ++ reservation, self._attempt_cost(result=result) ++ ), + ) + return result + @@ -1199,7 +1433,6 @@

Failed attempts cannot receive a blind refund

A separate deterministi + ) + if reservation is None: + return attempt(*args, **kwargs) -+ cost_before = self.metrics.accumulated_cost + kwargs = { + **kwargs, + "drop_params": False, @@ -1211,12 +1444,14 @@

Failed attempts cannot receive a blind refund

A separate deterministi + except BaseException: + reservation.budget.settle( + reservation, -+ max(reservation.amount, self._cost_delta(cost_before)), ++ max(reservation.amount, self._attempt_cost(result=None)), + ) + raise -+ cost = self._cost_delta(cost_before) + reservation.budget.settle( -+ reservation, self._budget_success_cost(reservation, cost) ++ reservation, ++ self._budget_success_cost( ++ reservation, self._attempt_cost(result=result) ++ ), + ) + return result + @@ -1224,9 +1459,22 @@

Failed attempts cannot receive a blind refund

A separate deterministi + + return decorate + -+ def _cost_delta(self, cost_before: float) -> float: -+ """Cost added to this LLM's own metrics by the call just completed.""" -+ return max(0.0, self.metrics.accumulated_cost - cost_before) ++ def _attempt_cost( ++ self, result: ModelResponse | ResponsesAPIResponse | None ++ ) -> float: ++ """Cost recorded for the attempt just completed, or ``0.0`` if unknown. ++ ++ Keyed by the response id the attempt returned, so a concurrent call ++ sharing this LLM cannot settle the reservation with its own cost. The ++ telemetry callback runs inside the transport call, so the entry exists ++ by the time the attempt returns; ``0.0`` means the price could not be ++ established and the caller retains the full reservation. ++ """ ++ assert self._telemetry is not None ++ if result is None: ++ return 0.0 ++ cost = self._telemetry.costs_by_response_id.get(result.id) ++ return cost if cost is not None else 0.0 + + def _budget_success_cost( + self, reservation: BudgetReservation, cost: float @@ -1253,7 +1501,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def completion( self, messages: list[Message], -@@ -1785,6 +2091,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1785,6 +2106,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # no-op for providers without runtime metadata. self.resolve_runtime_metadata() @@ -1261,7 +1509,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() enable_streaming = bool(kwargs.get("stream", False)) or self.stream if enable_streaming and on_token is None: -@@ -1814,6 +2121,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1814,6 +2136,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): ) @self._make_retry_decorator() @@ -1269,7 +1517,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) -@@ -1850,6 +2158,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1850,6 +2173,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): tools, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1277,7 +1525,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -@@ -1904,6 +2213,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1904,6 +2228,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # calls are cheap. await self.aresolve_runtime_metadata() @@ -1285,7 +1533,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() enable_streaming = bool(kwargs.get("stream", False)) or self.stream if enable_streaming and on_token is None: -@@ -1934,6 +2244,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1934,6 +2259,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): @self._make_retry_decorator() @self._async_hard_timeout_decorator() @@ -1293,7 +1541,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi async def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) -@@ -1970,6 +2281,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -1970,6 +2296,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): tools, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1301,7 +1549,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -@@ -2043,6 +2355,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2043,6 +2370,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # metadata. self.resolve_runtime_metadata() @@ -1309,7 +1557,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream if user_enable_streaming and on_token is None and not self.requires_streaming: -@@ -2076,6 +2389,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2076,6 +2404,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): ) @self._make_retry_decorator() @@ -1319,7 +1567,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) -@@ -2151,6 +2467,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2151,6 +2482,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): store, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1327,7 +1575,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -@@ -2208,6 +2525,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2208,6 +2540,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # limits. No blocking network I/O in-process. await self.aresolve_runtime_metadata() @@ -1335,7 +1583,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi _caller_kwargs = kwargs.copy() user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream if user_enable_streaming and on_token is None and not self.requires_streaming: -@@ -2242,6 +2560,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2242,6 +2575,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): @self._make_retry_decorator() @self._async_hard_timeout_decorator() @@ -1345,7 +1593,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi async def _one_attempt( **retry_kwargs: Any, ) -> ResponsesAPIResponse: -@@ -2343,6 +2664,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +@@ -2343,6 +2679,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): store, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token, @@ -1353,8 +1601,8 @@

Failed attempts cannot receive a blind refund

A separate deterministi **_caller_kwargs, ) # If the credential was rejected (401) and a refresh hook can supply -

✏️ changedopenhands-sdk/openhands/sdk/llm/utils/model_features.py+73 / −0

Distinguishes optional search pricing on resolved ordinary GPT/o, Claude, and Gemini text/function routes in chat or Responses mode from intrinsic or unresolved search fees.

openhands-sdk/openhands/sdk/llm/utils/model_features.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
-index 3b40fae04..40d5c2493 100644
+
✏️ changedopenhands-sdk/openhands/sdk/llm/utils/model_features.py+96 / −0

Distinguishes optional search pricing on resolved ordinary GPT/o, Claude, and Gemini text/function routes in chat or Responses mode from intrinsic or unresolved search fees.

openhands-sdk/openhands/sdk/llm/utils/model_features.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
+index 3b40fae04..711f35931 100644
 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py
 +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py
 @@ -5,6 +5,7 @@ from functools import cache
@@ -1365,7 +1613,43 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi from litellm.utils import supports_vision as litellm_supports_vision from openhands.sdk.llm.utils.openhands_provider import OPENHANDS_PROVIDER_PREFIX -@@ -127,6 +128,78 @@ def _real_litellm_provider( +@@ -102,6 +103,35 @@ def _normalize_model_for_litellm(model: str | None) -> str | None: + # resolution -- doing so re-introduces the 400 UnsupportedParamsError from #5325. + _PASSTHROUGH_PROVIDERS: frozenset[str] = frozenset({"litellm_proxy", "openhands"}) + ++# Providers whose search price is optional for their ordinary model families: ++# a plain call searches only when the request enables a hosted tool, so the ++# token-only reservation stays a valid ceiling once such tools are refused. ++# ``bedrock_mantle`` hosts several vendor namespaces in one catalog. ++_ORDINARY_SEARCH_FAMILIES: dict[str, tuple[str, ...]] = { ++ "openai": ("gpt-", "o1", "o3", "o4"), ++ "azure": ("gpt-", "o1", "o3", "o4"), ++ "anthropic": ("claude-",), ++ "azure_ai": ("claude-",), ++ "bedrock": ("claude-",), ++ "bedrock_converse": ("claude-",), ++ "bedrock_mantle": ("claude-", "gpt-", "o1", "o3", "o4"), ++ "vertex_ai-anthropic_models": ("claude-",), ++} ++ ++ ++def _is_ordinary_search_family(resolved_name: str, families: tuple[str, ...]) -> bool: ++ """Whether a resolved catalog key leaf names an ordinary searchable family. ++ ++ Bedrock keys prefix the id with a region and a vendor namespace ++ (``us.anthropic.claude-sonnet-5-5``, ``openai.gpt-5.5``). Every dot-suffix is ++ tried so the family is matched past those labels, while a suffix keeps the ++ id's own dots intact. ++ """ ++ parts = resolved_name.split(".") ++ return any( ++ ".".join(parts[index:]).startswith(families) for index in range(len(parts)) ++ ) ++ + + def _real_litellm_provider( + model_info: Mapping[str, Any] | None, +@@ -127,6 +157,72 @@ def _real_litellm_provider( return provider @@ -1413,7 +1697,11 @@

Failed attempts cannot receive a blind refund

A separate deterministi + normalized, ("gpt-4o-search-preview", "gpt-4o-mini-search-preview") + ) or OpenAIGPT5Config.is_model_gpt_5_search_model(normalized): + return True -+ if normalized.split("/")[-1].startswith("sonar"): ++ resolved = normalized.split("/")[-1] ++ # Search-named routes search even without a hosted tool. This check ++ # must run before the fee-presence shortcut below: a dated catalog ++ # entry that omits the search fee cannot admit them. ++ if resolved.startswith("sonar") or "search" in resolved: + return True + if info.get("search_context_cost_per_query") is None: + return False @@ -1423,19 +1711,9 @@

Failed attempts cannot receive a blind refund

A separate deterministi + }: + return True + resolved_name = (_normalize_model_for_litellm(catalog_key) or "").split("/")[-1] -+ if "search" in resolved_name: -+ return True -+ if provider in {"openai", "azure"}: -+ return not resolved_name.startswith(("gpt-", "o1", "o3", "o4")) -+ if provider in { -+ "anthropic", -+ "azure_ai", -+ "bedrock", -+ "bedrock_converse", -+ "vertex_ai-anthropic_models", -+ }: -+ # Bedrock catalog keys include a region and anthropic namespace. -+ return not resolved_name.split(".")[-1].startswith("claude-") ++ families = _ORDINARY_SEARCH_FAMILIES.get(provider or "") ++ if families is not None: ++ return not _is_ordinary_search_family(resolved_name, families) + if provider in {"gemini", "vertex_ai", "vertex_ai-language-models"}: + return not resolved_name.startswith("gemini-") + return True @@ -1444,7 +1722,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi @cache def _normalized_supported_openai_params( model: str | None, provider_hint: str | None = None -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/types.py+13 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/types.py b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 index da9da3806..8a34d4045 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/types.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/types.py
@@ -1468,7 +1746,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class LLMContentPolicyViolationError(LLMBadRequestError): """Provider blocked the request/response via its content filter. -

✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
+
✏️ changedopenhands-sdk/openhands/sdk/llm/exceptions/classifier.py+2 / −1

Recognizes SDK budget denial so retry/fallback classifiers stop.

openhands-sdk/openhands/sdk/llm/exceptions/classifier.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 index 878fced0d..40452d159 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
@@ -1489,7 +1767,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi return True if not isinstance(exception, (RateLimitError, LLMRateLimitError)): return False -

⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
+
⚙️ mechanicalopenhands-sdk/openhands/sdk/llm/exceptions/__init__.py+2 / −0
diff --git a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 index 218c93a05..4e9123725 100644
 --- a/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
 +++ b/openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
@@ -1509,7 +1787,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi "UserCancelledError", "OperationCancelled", # Helpers -

✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/exceptions.py+12 / −0

Names the unsupported ACP budget condition.

openhands-sdk/openhands/sdk/conversation/exceptions.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/exceptions.py b/openhands-sdk/openhands/sdk/conversation/exceptions.py
 index 8fcc9b9bb..83ebab66d 100644
 --- a/openhands-sdk/openhands/sdk/conversation/exceptions.py
 +++ b/openhands-sdk/openhands/sdk/conversation/exceptions.py
@@ -1532,7 +1810,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi class WebSocketConnectionError(RuntimeError): """Raised when WebSocket connection fails to establish within the timeout.""" -

✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
+
✏️ changedopenhands-sdk/openhands/sdk/conversation/state.py+10 / −0

Provides mark_dirty() for nested metrics updates that do not trigger assignment autosave.

openhands-sdk/openhands/sdk/conversation/state.py:1 · before:1

diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py
 index ed7741759..aa0b9785f 100644
 --- a/openhands-sdk/openhands/sdk/conversation/state.py
 +++ b/openhands-sdk/openhands/sdk/conversation/state.py
@@ -1553,7 +1831,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def owned(self) -> bool: """ Return True if the lock is currently held by the calling thread. -

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
+

Behavioral evidence

✏️ changedclients/typescript/src/__tests__/api-clients.test.ts+40 / −0

Behavioral regression coverage; exact changes are shown below.

clients/typescript/src/__tests__/api-clients.test.ts:1 · before:1

diff --git a/clients/typescript/src/__tests__/api-clients.test.ts b/clients/typescript/src/__tests__/api-clients.test.ts
 index ee6aef487..f74030443 100644
 --- a/clients/typescript/src/__tests__/api-clients.test.ts
 +++ b/clients/typescript/src/__tests__/api-clients.test.ts
@@ -1604,7 +1882,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi it('HttpClient can parse blob responses when requested', async () => { global.fetch = vi.fn().mockResolvedValue( new Response(new Blob(['zip-data']), { -

✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
+
✏️ changedtests/agent_server/test_conversation_router.py+25 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_router.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py
 index eff6ce516..26687efd9 100644
 --- a/tests/agent_server/test_conversation_router.py
 +++ b/tests/agent_server/test_conversation_router.py
@@ -1647,8 +1925,8 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_start_conversation_merges_observability_headers( client, mock_conversation_service, sample_conversation_info ): -

✏️ changedtests/agent_server/test_conversation_service.py+106 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
-index 54824cde2..56394feba 100644
+
✏️ changedtests/agent_server/test_conversation_service.py+149 / −0

Behavioral regression coverage; exact changes are shown below.

tests/agent_server/test_conversation_service.py:1 · before:1

diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py
+index 54824cde2..c419be697 100644
 --- a/tests/agent_server/test_conversation_service.py
 +++ b/tests/agent_server/test_conversation_service.py
 @@ -38,6 +38,7 @@ from openhands.agent_server.models import (
@@ -1659,7 +1937,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.conversation.state import ( ConversationExecutionStatus, -@@ -146,6 +147,111 @@ async def test_server_resolved_tool_modules_are_persisted(tmp_path): +@@ -146,6 +147,154 @@ async def test_server_resolved_tool_modules_are_persisted(tmp_path): ) @@ -1767,11 +2045,54 @@

Failed attempts cannot receive a blind refund

A separate deterministi + assert info.id == conversation_id + assert isinstance(info.agent, Agent) + ++ ++@pytest.mark.asyncio ++async def test_rejected_acp_budget_does_not_orphan_worktree_or_branch(tmp_path): ++ """A rejected ACP budget must leave no host-local state behind. ++ ++ The server plans a git worktree before constructing the conversation, so a ++ rejection raised only inside ``LocalConversation.__init__`` would leave the ++ worktree directory and its ``openhands/<id>`` branch behind. The ++ conversation is never registered, so ``delete_conversation`` cannot clean ++ them up either. ++ """ ++ repo_dir = tmp_path / "repo" ++ _init_git_repo(repo_dir) ++ conversation_id = uuid4() ++ worktree_root = tmp_path / "conversation-worktrees" ++ request = StartConversationRequest( ++ conversation_id=conversation_id, ++ agent=ACPAgent(acp_command=["echo", "test"]), ++ workspace=LocalWorkspace(working_dir=repo_dir), ++ confirmation_policy=NeverConfirm(), ++ max_budget_per_run=1.0, ++ worktree=True, ++ ) ++ ++ service = ConversationService( ++ conversations_dir=tmp_path / "conversations", ++ conversation_worktree_root=worktree_root, ++ ) ++ service._event_services = {} ++ with pytest.raises(CostBudgetUnsupportedError): ++ await service.start_conversation(request) ++ ++ worktree = worktree_root / str(conversation_id) / repo_dir.name ++ assert not worktree.exists() ++ assert ( ++ run_git_command( ++ ["git", "--no-pager", "branch", "--list", f"openhands/{conversation_id}"], ++ repo_dir, ++ ) ++ == "" ++ ) ++ assert not (tmp_path / "conversations" / conversation_id.hex).exists() ++ + @pytest.mark.asyncio async def test_agent_tool_from_unimportable_module_rejects_create(tmp_path): """The create fails with a client error instead of every message failing.""" -

✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
+
✏️ changedtests/cross/test_remote_conversation_live_server.py+29 / −0

Behavioral regression coverage; exact changes are shown below.

tests/cross/test_remote_conversation_live_server.py:1 · before:1

diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py
 index dc1245e3e..44d2228b1 100644
 --- a/tests/cross/test_remote_conversation_live_server.py
 +++ b/tests/cross/test_remote_conversation_live_server.py
@@ -1811,7 +2132,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_openai_chat_completions_gateway_over_real_server( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patched_llm ): -

✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
+
✏️ changedtests/sdk/conversation/local/test_agent_status_transition.py+17 / −7

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_agent_status_transition.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py
 index 807f19a89..4b6ba77f1 100644
 --- a/tests/sdk/conversation/local/test_agent_status_transition.py
 +++ b/tests/sdk/conversation/local/test_agent_status_transition.py
@@ -1879,12 +2200,12 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi conversation.run() assert conversation.state.execution_status == ConversationExecutionStatus.ERROR -

🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+177 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
+
🟢 addedtests/sdk/conversation/local/test_cost_budget_enforcement.py+223 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_cost_budget_enforcement.py:1

diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
 new file mode 100644
-index 000000000..eed344f1d
+index 000000000..17434a2bb
 --- /dev/null
 +++ b/tests/sdk/conversation/local/test_cost_budget_enforcement.py
-@@ -0,0 +1,177 @@
+@@ -0,0 +1,223 @@
 +"""Per-conversation cost budget: server-enforced reservation + live cost."""
 +
 +import tempfile
@@ -2062,15 +2383,61 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + delete_on_close=False, + ) + assert not (persist / cid.hex / "base_state.json").exists() -

✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
-index b9b93141c..e6d326e8e 100644
++
++
++def test_acp_delegate_rejects_inherited_budget():
++    """An ACP delegate that inherits a parent's budget must be rejected.
++
++    ``DelegateExecutor`` forwards the parent's LLM call context without an
++    explicit cap, so a delegate's effective budget is the inherited one. Its
++    prompts bypass reservation, so the ceiling must be checked even when
++    ``max_budget_per_run`` is unset.
++    """
++    from openhands.sdk.agent.acp_agent import ACPAgent
++    from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError
++
++    with tempfile.TemporaryDirectory() as d:
++        tmp = Path(d)
++        parent = _conversation(tmp, max_budget_per_run=1.0)
++        with pytest.raises(CostBudgetUnsupportedError, match="ACP agents"):
++            LocalConversation(
++                agent=ACPAgent(acp_command=["echo", "test"]),
++                workspace=str(tmp),
++                persistence_dir=str(tmp / "sub"),
++                max_budget_per_run=None,
++                visualizer=None,
++                delete_on_close=False,
++                _parent_llm_call_context=parent.get_llm_call_context(),
++            )
++        # Rejection happened before any conversation state was created.
++        assert not (tmp / "sub").exists()
++
++
++def test_acp_delegate_without_inherited_budget_is_allowed():
++    from openhands.sdk.agent.acp_agent import ACPAgent
++
++    with tempfile.TemporaryDirectory() as d:
++        tmp = Path(d)
++        parent = _conversation(tmp, max_budget_per_run=None)
++        conv = LocalConversation(
++            agent=ACPAgent(acp_command=["echo", "test"]),
++            workspace=str(tmp),
++            persistence_dir=str(tmp / "sub"),
++            max_budget_per_run=None,
++            visualizer=None,
++            delete_on_close=False,
++            _parent_llm_call_context=parent.get_llm_call_context(),
++        )
++        assert conv._cost_budget is None
+
✏️ changedtests/sdk/conversation/local/test_fork.py+20 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_fork.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py
+index b9b93141c..be6580d64 100644
 --- a/tests/sdk/conversation/local/test_fork.py
 +++ b/tests/sdk/conversation/local/test_fork.py
 @@ -178,6 +178,26 @@ def test_fork_preserves_metrics_when_requested():
          assert combined.accumulated_cost == pytest.approx(1.5)
  
  
-+def test_fork_does_not_share_source_budget_ledger():
++def test_fork_does_not_share_source_budget_tree():
 +    """A fork is independent: it must not reserve against the source's budget."""
 +    with tempfile.TemporaryDirectory() as tmpdir:
 +        src = Conversation(
@@ -2084,16 +2451,16 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + # The fork carries the source's ceiling... + assert fork.max_budget_per_run == pytest.approx(1.0) + assert fork._cost_budget is not None -+ # ...but owns its own ledger rather than sharing the source's. ++ # ...but owns its own tree rather than sharing the source's. + assert src._cost_budget is not None + assert fork._cost_budget is not src._cost_budget -+ assert fork._cost_budget._ledger is not src._cost_budget._ledger ++ assert fork._cost_budget._tree is not src._cost_budget._tree + + def test_fork_copies_agent_state(): """agent_state dict should be carried over to the fork.""" with tempfile.TemporaryDirectory() as tmpdir: -

✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
+
✏️ changedtests/sdk/conversation/local/test_state_serialization.py+44 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/local/test_state_serialization.py:1 · before:1

diff --git a/tests/sdk/conversation/local/test_state_serialization.py b/tests/sdk/conversation/local/test_state_serialization.py
 index 16457eb93..075574f3a 100644
 --- a/tests/sdk/conversation/local/test_state_serialization.py
 +++ b/tests/sdk/conversation/local/test_state_serialization.py
@@ -2148,8 +2515,8 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi def test_resume_with_conversation_id_mismatch_raises_error(): """Test that resuming with mismatched conversation ID raises error.""" with tempfile.TemporaryDirectory() as temp_dir: -

✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+16 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
-index 86914e25e..5b48fe9b7 100644
+
✏️ changedtests/sdk/conversation/remote/test_remote_conversation.py+79 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/conversation/remote/test_remote_conversation.py:1 · before:1

diff --git a/tests/sdk/conversation/remote/test_remote_conversation.py b/tests/sdk/conversation/remote/test_remote_conversation.py
+index 86914e25e..c7a1a6bb9 100644
 --- a/tests/sdk/conversation/remote/test_remote_conversation.py
 +++ b/tests/sdk/conversation/remote/test_remote_conversation.py
 @@ -414,6 +414,22 @@ class TestRemoteConversation:
@@ -2175,7 +2542,74 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @patch( "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient" ) -

🟢 addedtests/sdk/llm/fixtures/optional-search-model-info.json+35 / −0

Captures public gpt-4o-mini model metadata with optional search pricing for deterministic function-call regression coverage.

tests/sdk/llm/fixtures/optional-search-model-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-model-info.json b/tests/sdk/llm/fixtures/optional-search-model-info.json
+@@ -2352,3 +2368,66 @@ class TestRemoteConversation:
+ 
+             # Verify register_conversation was called with the conversation ID
+             mock_register.assert_called_once_with(conversation_id)
++
++    @patch(
++        "openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient"
++    )
++    def test_reconnect_replays_missed_events_to_callbacks_once(self, mock_ws_client):
++        """Events stored while the socket was down reach callbacks on reconnect."""
++
++        def user_message(text: str) -> MessageEvent:
++            return MessageEvent(
++                source="user",
++                llm_message=Message(role="user", content=[TextContent(text=text)]),
++            )
++
++        history = user_message("history")
++        live = user_message("live")
++        missed_1 = user_message("missed 1")
++        missed_2 = user_message("missed 2")
++        after = user_message("after")
++        server_events = [history]
++
++        mock_client_instance = self.setup_mock_client()
++        default_side_effect = mock_client_instance.request.side_effect
++
++        def request_side_effect(method, url, **kwargs):
++            if method == "GET" and url.endswith("/events/search"):
++                return self.create_mock_events_response(
++                    [event.model_dump(mode="json") for event in server_events]
++                )
++            return default_side_effect(method, url, **kwargs)
++
++        mock_client_instance.request.side_effect = request_side_effect
++        mock_ws_client.return_value = Mock()
++        seen: list[str] = []
++
++        conversation = RemoteConversation(
++            agent=self.agent,
++            workspace=self.workspace,
++            callbacks=[
++                lambda e: seen.append(e.id) if isinstance(e, MessageEvent) else None
++            ],
++        )
++        ws_callback = mock_ws_client.call_args.kwargs["callback"]
++        on_reconnect = mock_ws_client.call_args.kwargs["on_reconnect"]
++
++        server_events.append(live)
++        ws_callback(live)
++        # The socket drops; the server records two events nobody streams.
++        server_events.extend([missed_1, missed_2])
++
++        on_reconnect()
++        # The new subscription may still deliver an event the replay covered.
++        ws_callback(missed_2)
++        server_events.append(after)
++        ws_callback(after)
++
++        assert seen == [live.id, missed_1.id, missed_2.id, after.id]
++        assert [e.id for e in conversation.state.events] == [
++            history.id,
++            live.id,
++            missed_1.id,
++            missed_2.id,
++            after.id,
++        ]
+
🟢 addedtests/sdk/llm/fixtures/optional-search-model-info.json+35 / −0

Captures public gpt-4o-mini model metadata with optional search pricing for deterministic function-call regression coverage.

tests/sdk/llm/fixtures/optional-search-model-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-model-info.json b/tests/sdk/llm/fixtures/optional-search-model-info.json
 new file mode 100644
 index 000000000..feff127e3
 --- /dev/null
@@ -2216,12 +2650,12 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + "source": "https://developers.openai.com/api/docs/pricing" + } +} -

🟢 addedtests/sdk/llm/fixtures/optional-search-provider-info.json+376 / −0

Captures public Claude, Gemini, and Responses-mode GPT-5.3-Codex metadata, including hosted provider routes, for deterministic optional-search regression coverage.

tests/sdk/llm/fixtures/optional-search-provider-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-provider-info.json b/tests/sdk/llm/fixtures/optional-search-provider-info.json
+
🟢 addedtests/sdk/llm/fixtures/optional-search-provider-info.json+646 / −0

Captures public Claude, Gemini, and Responses-mode GPT-5.3-Codex metadata, including hosted provider routes, for deterministic optional-search regression coverage.

tests/sdk/llm/fixtures/optional-search-provider-info.json:1

diff --git a/tests/sdk/llm/fixtures/optional-search-provider-info.json b/tests/sdk/llm/fixtures/optional-search-provider-info.json
 new file mode 100644
-index 000000000..50296b1d7
+index 000000000..e76459c66
 --- /dev/null
 +++ b/tests/sdk/llm/fixtures/optional-search-provider-info.json
-@@ -0,0 +1,376 @@
+@@ -0,0 +1,646 @@
 +{
 +  "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
 +  "captured_utc": "2026-10-18T19:48:29.487639+00:00",
@@ -2595,11 +3029,281 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + "supports_xhigh_reasoning_effort": false, + "supports_minimal_reasoning_effort": true, + "deprecation_date": "2027-04-01" ++ }, ++ "bedrock_mantle/openai.gpt-5.5": { ++ "key": "bedrock_mantle/openai.gpt-5.5", ++ "max_tokens": 128000, ++ "max_input_tokens": 1050000, ++ "max_output_tokens": 128000, ++ "input_cost_per_token": 5.5e-06, ++ "input_cost_per_token_flex": null, ++ "input_cost_per_token_priority": null, ++ "cache_creation_input_token_cost": null, ++ "cache_creation_input_token_cost_above_200k_tokens": null, ++ "cache_read_input_token_cost": 5.5e-07, ++ "cache_read_input_token_cost_above_200k_tokens": null, ++ "cache_read_input_token_cost_above_200k_tokens_priority": null, ++ "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, ++ "cache_read_input_token_cost_above_272k_tokens_priority": null, ++ "cache_read_input_token_cost_above_512k_tokens": null, ++ "cache_read_input_token_cost_flex": null, ++ "cache_read_input_token_cost_priority": null, ++ "cache_creation_input_token_cost_above_1hr": null, ++ "input_cost_per_character": null, ++ "input_cost_per_token_above_128k_tokens": null, ++ "input_cost_per_token_above_200k_tokens": null, ++ "input_cost_per_token_above_200k_tokens_priority": null, ++ "input_cost_per_token_above_272k_tokens": 1.1e-05, ++ "input_cost_per_token_above_272k_tokens_priority": null, ++ "input_cost_per_token_above_512k_tokens": null, ++ "input_cost_per_query": null, ++ "input_cost_per_second": null, ++ "input_cost_per_audio_token": null, ++ "input_cost_per_image_token": null, ++ "input_cost_per_image": null, ++ "input_cost_per_audio_per_second": null, ++ "input_cost_per_video_per_second": null, ++ "input_cost_per_token_batches": null, ++ "output_cost_per_token_batches": null, ++ "output_cost_per_token": 3.3e-05, ++ "output_cost_per_token_flex": null, ++ "output_cost_per_token_priority": null, ++ "regional_processing_uplift_multiplier_eu": null, ++ "regional_processing_uplift_multiplier_us": null, ++ "output_cost_per_audio_token": null, ++ "output_cost_per_character": null, ++ "output_cost_per_reasoning_token": null, ++ "output_cost_per_token_above_128k_tokens": null, ++ "output_cost_per_character_above_128k_tokens": null, ++ "output_cost_per_token_above_200k_tokens": null, ++ "output_cost_per_token_above_200k_tokens_priority": null, ++ "output_cost_per_token_above_272k_tokens": 4.95e-05, ++ "output_cost_per_token_above_272k_tokens_priority": null, ++ "output_cost_per_token_above_512k_tokens": null, ++ "output_cost_per_second": null, ++ "output_cost_per_second_1080p": null, ++ "output_cost_per_video_per_second": null, ++ "output_cost_per_image": null, ++ "output_cost_per_image_token": null, ++ "output_vector_size": null, ++ "citation_cost_per_token": null, ++ "tiered_pricing": null, ++ "litellm_provider": "bedrock_mantle", ++ "mode": "responses", ++ "supports_system_messages": null, ++ "supports_response_schema": true, ++ "supports_vision": true, ++ "supports_function_calling": true, ++ "supports_tool_choice": true, ++ "supports_assistant_prefill": null, ++ "supports_prompt_caching": true, ++ "supports_audio_input": null, ++ "supports_audio_output": null, ++ "supports_pdf_input": null, ++ "supports_embedding_image_input": null, ++ "supports_native_streaming": null, ++ "supports_native_structured_output": null, ++ "supports_web_search": true, ++ "supports_url_context": null, ++ "supports_reasoning": true, ++ "supports_adaptive_thinking": null, ++ "supports_mid_conversation_system": null, ++ "supports_none_reasoning_effort": null, ++ "supports_minimal_reasoning_effort": false, ++ "supports_low_reasoning_effort": null, ++ "supports_xhigh_reasoning_effort": true, ++ "supports_max_reasoning_effort": null, ++ "bedrock_output_config_effort_ceiling": null, ++ "bedrock_converse_supports_strict_tools": null, ++ "supports_computer_use": null, ++ "search_context_cost_per_query": { ++ "search_context_size_high": 0.012, ++ "search_context_size_low": 0.012, ++ "search_context_size_medium": 0.012 ++ }, ++ "web_search_billing_unit": null, ++ "tpm": null, ++ "rpm": null, ++ "ocr_cost_per_page": null, ++ "ocr_cost_per_credit": null, ++ "annotation_cost_per_page": null, ++ "provider_specific_entry": null, ++ "uses_embed_content": null, ++ "supports_image_size": null, ++ "supported_openai_params": [ ++ "frequency_penalty", ++ "logit_bias", ++ "logprobs", ++ "top_logprobs", ++ "max_tokens", ++ "max_completion_tokens", ++ "modalities", ++ "prediction", ++ "n", ++ "presence_penalty", ++ "seed", ++ "stop", ++ "stream", ++ "stream_options", ++ "temperature", ++ "top_p", ++ "tools", ++ "tool_choice", ++ "function_call", ++ "functions", ++ "max_retries", ++ "extra_headers", ++ "parallel_tool_calls", ++ "audio", ++ "web_search_options", ++ "service_tier", ++ "safety_identifier", ++ "prompt_cache_key", ++ "prompt_cache_retention", ++ "store", ++ "response_format", ++ "reasoning_effort" ++ ] ++ }, ++ "bedrock_mantle/anthropic.claude-sonnet-5-5": { ++ "key": "bedrock_mantle/anthropic.claude-sonnet-5-5", ++ "max_tokens": 128000, ++ "max_input_tokens": 1000000, ++ "max_output_tokens": 128000, ++ "input_cost_per_token": 2.2e-06, ++ "input_cost_per_token_flex": null, ++ "input_cost_per_token_priority": null, ++ "cache_creation_input_token_cost": 2.75e-06, ++ "cache_creation_input_token_cost_above_200k_tokens": null, ++ "cache_read_input_token_cost": 1.1e-07, ++ "cache_read_input_token_cost_above_200k_tokens": null, ++ "cache_read_input_token_cost_above_200k_tokens_priority": null, ++ "cache_read_input_token_cost_above_272k_tokens": null, ++ "cache_read_input_token_cost_above_272k_tokens_priority": null, ++ "cache_read_input_token_cost_above_512k_tokens": null, ++ "cache_read_input_token_cost_flex": null, ++ "cache_read_input_token_cost_priority": null, ++ "cache_creation_input_token_cost_above_1hr": 4.4e-06, ++ "input_cost_per_character": null, ++ "input_cost_per_token_above_128k_tokens": null, ++ "input_cost_per_token_above_200k_tokens": null, ++ "input_cost_per_token_above_200k_tokens_priority": null, ++ "input_cost_per_token_above_272k_tokens": null, ++ "input_cost_per_token_above_272k_tokens_priority": null, ++ "input_cost_per_token_above_512k_tokens": null, ++ "input_cost_per_query": null, ++ "input_cost_per_second": null, ++ "input_cost_per_audio_token": null, ++ "input_cost_per_image_token": null, ++ "input_cost_per_image": null, ++ "input_cost_per_audio_per_second": null, ++ "input_cost_per_video_per_second": null, ++ "input_cost_per_token_batches": null, ++ "output_cost_per_token_batches": null, ++ "output_cost_per_token": 1.1e-05, ++ "output_cost_per_token_flex": null, ++ "output_cost_per_token_priority": null, ++ "regional_processing_uplift_multiplier_eu": null, ++ "regional_processing_uplift_multiplier_us": null, ++ "output_cost_per_audio_token": null, ++ "output_cost_per_character": null, ++ "output_cost_per_reasoning_token": null, ++ "output_cost_per_token_above_128k_tokens": null, ++ "output_cost_per_character_above_128k_tokens": null, ++ "output_cost_per_token_above_200k_tokens": null, ++ "output_cost_per_token_above_200k_tokens_priority": null, ++ "output_cost_per_token_above_272k_tokens": null, ++ "output_cost_per_token_above_272k_tokens_priority": null, ++ "output_cost_per_token_above_512k_tokens": null, ++ "output_cost_per_second": null, ++ "output_cost_per_second_1080p": null, ++ "output_cost_per_video_per_second": null, ++ "output_cost_per_image": null, ++ "output_cost_per_image_token": null, ++ "output_vector_size": null, ++ "citation_cost_per_token": null, ++ "tiered_pricing": null, ++ "litellm_provider": "bedrock_mantle", ++ "mode": "chat", ++ "supports_system_messages": null, ++ "supports_response_schema": true, ++ "supports_vision": true, ++ "supports_function_calling": true, ++ "supports_tool_choice": true, ++ "supports_assistant_prefill": false, ++ "supports_prompt_caching": true, ++ "supports_audio_input": null, ++ "supports_audio_output": null, ++ "supports_pdf_input": true, ++ "supports_embedding_image_input": null, ++ "supports_native_streaming": null, ++ "supports_native_structured_output": false, ++ "supports_web_search": null, ++ "supports_url_context": null, ++ "supports_reasoning": true, ++ "supports_adaptive_thinking": true, ++ "supports_mid_conversation_system": true, ++ "supports_none_reasoning_effort": null, ++ "supports_minimal_reasoning_effort": null, ++ "supports_low_reasoning_effort": null, ++ "supports_xhigh_reasoning_effort": true, ++ "supports_max_reasoning_effort": true, ++ "bedrock_output_config_effort_ceiling": "xhigh", ++ "bedrock_converse_supports_strict_tools": false, ++ "supports_computer_use": true, ++ "search_context_cost_per_query": { ++ "search_context_size_high": 0.01, ++ "search_context_size_low": 0.01, ++ "search_context_size_medium": 0.01 ++ }, ++ "web_search_billing_unit": null, ++ "tpm": null, ++ "rpm": null, ++ "ocr_cost_per_page": null, ++ "ocr_cost_per_credit": null, ++ "annotation_cost_per_page": null, ++ "provider_specific_entry": null, ++ "uses_embed_content": null, ++ "supports_image_size": null, ++ "supported_openai_params": [ ++ "frequency_penalty", ++ "logit_bias", ++ "logprobs", ++ "top_logprobs", ++ "max_tokens", ++ "max_completion_tokens", ++ "modalities", ++ "prediction", ++ "n", ++ "presence_penalty", ++ "seed", ++ "stop", ++ "stream", ++ "stream_options", ++ "temperature", ++ "top_p", ++ "tools", ++ "tool_choice", ++ "function_call", ++ "functions", ++ "max_retries", ++ "extra_headers", ++ "parallel_tool_calls", ++ "audio", ++ "web_search_options", ++ "service_tier", ++ "safety_identifier", ++ "prompt_cache_key", ++ "prompt_cache_retention", ++ "store", ++ "response_format", ++ "reasoning_effort" ++ ] + } + } +} -

✏️ changedtests/sdk/llm/test_budget_denial_http.py+262 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
-index 5f626dded..3ce395e0c 100644
+
✏️ changedtests/sdk/llm/test_budget_denial_http.py+269 / −1

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_budget_denial_http.py:1 · before:1

diff --git a/tests/sdk/llm/test_budget_denial_http.py b/tests/sdk/llm/test_budget_denial_http.py
+index 5f626dded..b3e7b7488 100644
 --- a/tests/sdk/llm/test_budget_denial_http.py
 +++ b/tests/sdk/llm/test_budget_denial_http.py
 @@ -1,13 +1,20 @@
@@ -2624,7 +3328,7 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi @pytest.fixture -@@ -42,6 +49,147 @@ def provider(): +@@ -42,6 +49,154 @@ def provider(): thread.join() @@ -2699,16 +3403,23 @@

Failed attempts cannot receive a blind refund

A separate deterministi + conv.close() + + -+@pytest.mark.parametrize("model", ["openai/gpt-5.3-codex", "openhands/gpt-5.3-codex"]) ++@pytest.mark.parametrize( ++ "model,fixture_key", ++ [ ++ ("openai/gpt-5.3-codex", "gpt-5.3-codex"), ++ ("openhands/gpt-5.3-codex", "gpt-5.3-codex"), ++ ("bedrock_mantle/openai.gpt-5.5", "bedrock_mantle/openai.gpt-5.5"), ++ ], ++) +@pytest.mark.parametrize("use_async", [False, True], ids=["run", "arun"]) +async def test_optional_search_prices_allow_responses_conversation_over_http( -+ provider, tmp_path, monkeypatch, model, use_async ++ provider, tmp_path, monkeypatch, model, fixture_key, use_async +): + base_url, replies, requests = provider + fixture = ( + Path(__file__).with_name("fixtures") / "optional-search-provider-info.json" + ) -+ info = json.loads(fixture.read_text())["model_info"]["gpt-5.3-codex"] ++ info = json.loads(fixture.read_text())["model_info"][fixture_key] + monkeypatch.setattr(llm_module, "get_litellm_model_info", lambda **_: info) + replies.append( + ( @@ -2772,7 +3483,7 @@

Failed attempts cannot receive a blind refund

A separate deterministi @pytest.mark.asyncio @pytest.mark.parametrize("api_mode", ["chat", "responses"]) @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) -@@ -145,3 +293,116 @@ async def test_http_rate_limit_retry_and_budget_recovery( +@@ -145,3 +300,116 @@ async def test_http_rate_limit_retry_and_budget_recovery( response = await call() assert response.message.content == [TextContent(text="resumed")] assert len(requests) == 2 @@ -2889,12 +3600,12 @@

Failed attempts cannot receive a blind refund

A separate deterministi + assert len(requests) == 1 + assert budget.reserved == 0 + assert 0 < budget.spent <= budget.limit -

🟢 addedtests/sdk/llm/test_cost_budget.py+876 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
+
🟢 addedtests/sdk/llm/test_cost_budget.py+1023 / −0

Behavioral regression coverage; exact changes are shown below.

tests/sdk/llm/test_cost_budget.py:1

diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py
 new file mode 100644
-index 000000000..51ee16a30
+index 000000000..0c24410c4
 --- /dev/null
 +++ b/tests/sdk/llm/test_cost_budget.py
-@@ -0,0 +1,876 @@
+@@ -0,0 +1,1023 @@
 +import asyncio
 +import inspect
 +import json
@@ -3009,6 +3720,47 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi + assert "maximum budget limit ($1.0000)" in child.denial_detail(2.0) + + ++def test_child_ceiling_measures_only_its_own_subtree(): ++ """A child's own cap must not be charged for its parent's spending. ++ ++ Regression: the child's first request was denied because the shared total ++ included the parent's earlier work, even though the child had spent nothing. ++ """ ++ parent = CostBudget(5.0) ++ child = parent.child(1.31) ++ _settle(parent, 0.02) # the parent's own work, before delegating ++ ++ # $1.30 fits the child's $1.31 cap: the parent's $0.02 belongs to the ++ # parent's subtree, not the child's. ++ assert child.try_reserve(1.30) is not None ++ ++ ++def test_sibling_spend_does_not_consume_a_siblings_ceiling(): ++ parent = CostBudget(10.0) ++ left = parent.child(1.0) ++ right = parent.child(1.0) ++ _settle(left, 0.9) ++ ++ # The right sibling has spent nothing of its own $1 allowance. ++ assert right.try_reserve(0.9) is not None ++ # The left sibling's own cap is exhausted. ++ assert left.try_reserve(0.9) is None ++ ++ ++def test_reseed_resets_descendant_subtree_totals(): ++ parent = CostBudget(10.0) ++ child = parent.child(1.0) ++ _settle(child, 0.9) ++ assert child.spent == pytest.approx(0.9) ++ ++ parent.reseed() ++ ++ assert parent.spent == 0.0 ++ assert child.spent == 0.0 ++ # The child gets its own per-run allowance back. ++ assert child.try_reserve(0.9) is not None ++ ++ +def test_concurrent_reservations_cannot_overshoot(): + """A shared budget admits at most the calls that fit, even in parallel.""" + budget = CostBudget(1.0) @@ -3362,6 +4114,11 @@

Failed attempts cannot receive a blind refund

A separate deterministi + ("azure_ai/claude-opus-4-6", "azure_ai/claude-opus-4-6"), + ("openai/gpt-5.3-codex", "gpt-5.3-codex"), + ("openhands/gpt-5.3-codex", "gpt-5.3-codex"), ++ ("bedrock_mantle/openai.gpt-5.5", "bedrock_mantle/openai.gpt-5.5"), ++ ( ++ "bedrock_mantle/anthropic.claude-sonnet-5-5", ++ "bedrock_mantle/anthropic.claude-sonnet-5-5", ++ ), + ], +) +@pytest.mark.parametrize( @@ -3664,6 +4421,58 @@

Failed attempts cannot receive a blind refund

A separate deterministi + + +@pytest.mark.parametrize( ++ "model", ++ [ ++ "openai/o3-deep-research", ++ "openai/o4-mini-deep-research", ++ "o3-deep-research-2025-06-26", ++ "perplexity/preset/deep-research", ++ "perplexity/preset/advanced-deep-research", ++ "vertex_ai/deep-research-pro-preview-12-2025", ++ "aihubmix/gemini-3-flash-preview-search", ++ ], ++) ++@pytest.mark.parametrize( ++ "invoke", [LLM.completion, LLM.acompletion, LLM.responses, LLM.aresponses] ++) ++async def test_budget_refuses_intrinsic_search_when_catalog_omits_the_fee( ++ budget_call, model, invoke ++): ++ """A dated entry omitting the search fee must not open a search-only route. ++ ++ ``has_unbounded_search_cost`` early-returns on an absent ++ ``search_context_cost_per_query``. If that shortcut runs before the ++ search-name check, a token-only reservation admits these routes while the ++ intrinsic search charge goes unbounded. ++ """ ++ info, make_llm, messages, providers = budget_call ++ fixture = Path(__file__).with_name("fixtures") / "omitted-search-fee-info.json" ++ catalog = json.loads(fixture.read_text())["model_info"] ++ info.clear() ++ info.update(catalog[model]) ++ assert info.get("search_context_cost_per_query") is None ++ llm = make_llm( ++ model=model, ++ input_cost_per_token=0.00001, ++ output_cost_per_token=0.001, ++ ) ++ ++ with pytest.raises( ++ LLMBudgetExceededError, match="non-token audio/search request costs" ++ ): ++ result = invoke( ++ llm, ++ messages, ++ call_context=LLMCallContext(cost_budget=CostBudget(50)), ++ ) ++ if inspect.isawaitable(result): ++ await result ++ ++ for provider in providers: ++ provider.assert_not_called() ++ ++ ++@pytest.mark.parametrize( + "name,value", + [("num_retries", 2), ("model_fallbacks", ["another-model"]), ("drop_params", True)], +) @@ -3771,11 +4580,60 @@

Failed attempts cannot receive a blind refund

A separate deterministi + providers[0].assert_called_once() + assert llm.metrics.accumulated_cost == 0 + assert budget.spent == pytest.approx(0.11) ++ ++ ++@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) ++async def test_budget_settles_from_the_attempt_not_a_concurrent_call( ++ budget_call, use_async ++): ++ """Another in-flight call's cost must not settle this attempt's reservation. ++ ++ Regression: settlement read the cumulative ``metrics.accumulated_cost`` ++ delta, so a concurrent call sharing this LLM could release a reservation ++ whose own price was unknown and admit a request the cap should deny. ++ """ ++ _, make_llm, messages, providers = budget_call ++ llm = make_llm() ++ budget = CostBudget(0.15) ++ context = LLMCallContext(cost_budget=budget) ++ assert llm._telemetry is not None ++ ++ def concurrent_charge(**_kwargs): ++ # A sibling attempt on the same LLM records its own priced response ++ # while this attempt sits between reservation and settlement. ++ llm._telemetry.on_response( ++ create_mock_litellm_response( ++ "sibling", ++ response_id="sibling-resp", ++ prompt_tokens=100, ++ completion_tokens=100, ++ ) ++ ) ++ return create_mock_litellm_response( ++ "ok", response_id="unknown-cost-resp", prompt_tokens=0, completion_tokens=0 ++ ) ++ ++ providers[0].side_effect = concurrent_charge ++ providers[1].side_effect = concurrent_charge ++ ++ if use_async: ++ await llm.acompletion(messages, call_context=context) ++ else: ++ llm.completion(messages, call_context=context) ++ ++ # This attempt reported no usage, so its own price is unknown and the full ++ # reservation is retained -- not the sibling's recorded cost. ++ assert budget.spent == pytest.approx(0.11) ++ with pytest.raises(LLMBudgetExceededError): ++ if use_async: ++ await llm.acompletion(messages, call_context=context) ++ else: ++ llm.completion(messages, call_context=context)

-

06Evidence has an explicit boundary

Source manifest
-

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork ledgers, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:115 · tests/sdk/llm/test_cost_budget.py:108 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

-
ValidationObserved result and limit
Final focused run
feeb719
203 focused Python cases passed, with no failures or skips; no paid provider calls. The 203 cases comprise 127 cost cases, 70 real loopback HTTP cases, and 6 local conversation budget cases. Edited-file and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:300 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Final broader run1,806 LLM and local/remote conversation tests passed with ordinary current public LiteLLM metadata, with no failures or skips.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
+

06Evidence has an explicit boundary

Source manifest
+

The tests cover atomic reservation, tighter child and ancestor ceilings, settlement, per-run reset, independent fork trees, persisted/REST budget round-trip, ACP rejection, and TypeScript payload forwarding.

Sources: tests/sdk/llm/test_cost_budget.py:115 · tests/sdk/llm/test_cost_budget.py:108 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:88 · tests/sdk/conversation/local/test_fork.py:181 · tests/agent_server/test_conversation_service.py:151 · tests/cross/test_remote_conversation_live_server.py:887 · clients/typescript/src/__tests__/api-clients.test.ts:1927

+
ValidationObserved result and limit
Final focused run
feeb719
203 focused Python cases passed, with no failures or skips; no paid provider calls. The 203 cases comprise 127 cost cases, 70 real loopback HTTP cases, and 6 local conversation budget cases. Edited-file and commit hooks passed. tests/sdk/llm/test_cost_budget.py:1 · tests/sdk/llm/test_budget_denial_http.py:300 · tests/sdk/conversation/local/test_cost_budget_enforcement.py:1
Final broader run1,806 LLM and local/remote conversation tests passed with ordinary current public LiteLLM metadata, with no failures or skips.
Historical contract checks
091882a
126 focused Python tests and 396 TypeScript tests passed; TypeScript build, lint, and formatting passed under the CI-parity shell. These results precede the admission repair.
Execution scopeFixture transports and real loopback HTTP exercised installed SDK/LiteLLM/OpenAI paths. No provider/model calls, billed-usage proof, real-model UI flow, or Canvas budget bar is established by this evidence.
Historical forced-catalog diagnosis

An earlier pre-final run passed 1,698 of 1,709 cases; 11 Kimi image/model tests failed with forced packaged metadata. All 11 failures reproduced at 091882a under that catalog and passed on the repaired code with ordinary public metadata. These are historical environment/catalog results; no image/vision product patch was made. The final ordinary-metadata broader run above passed all 1,806 cases.

Commands and result provenance

Sanitized verification summaries and XML result counts recorded on 18 October 2026; raw local logs and profile data are absent from this portable page. Final focused command scope:

pytest tests/sdk/llm/test_cost_budget.py \
   tests/sdk/llm/test_budget_denial_http.py \
@@ -3788,5 +4646,5 @@ 

Failed attempts cannot receive a blind refund

A separate deterministi npm --script-shell=/bin/sh run test:coverage -- --maxWorkers=1 npm --script-shell=/bin/sh run format:check

That earlier broad run reported 2,437 passed, one existing skip, and two environment-path failures; both passed in an environment-only 11-test rerun. It is historical evidence, not a wholly green final-head run.

ⓘ Integration boundary: this SDK PR adds the run cap, not a Canvas budget meter. A meter needs matching current-run cost reporting and frontend integration; cumulative lifetime cost and a legacy task-budget property do not express this run's remaining allowance.
-

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

-
Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: feeb7197cc0498b72edd03af2ebc55bfb5310d49. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file +

This temporary page belongs under .pr/. The repository's PR Artifacts workflow is enabled and structurally opens or updates a cleanup PR after merge to main; cleanup still follows normal review protections. .github/workflows/pr-artifacts.yml:96

+
Review source base: 3f26a0570e96e92c6af0b75388e31c9bf4f23efc. Draft read head: 46603bb4abcd256a0a36e3e59b3c80c70dbef7ca. Final source links must be pinned to the reviewed code commit, and the PR preview link to the later commit containing this temporary HTML. No external scripts, fonts, or assets.
\ No newline at end of file From 6e1925f9dd0398b2807ad04a19645d4d2dd8d23a Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 27 Oct 2026 06:01:59 +0000 Subject: [PATCH 22/24] fix(sdk): count per-image fees in the cost budget bound The budget reservation priced only tokens, so a model that also charges a flat per-image fee (input_cost_per_image) or an image-token rate (input_cost_per_image_token) could be admitted under a ceiling the request would actually exceed. - Thread the number of images the request will send from the four call paths into _reserve_budget/worst_case_cost. - Add the highest published flat per-image fee per image, and include an input_cost_per_image_token rate in the input maximum, only when images are actually sent (vision active). - Count only images the payload carries: disabled vision drops them, and paired custom token prices suppress the catalog fee as telemetry does. Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/llm.py | 99 +++++++++++++++++++--- tests/sdk/llm/test_cost_budget.py | 112 ++++++++++++++++++++++++- 2 files changed, 198 insertions(+), 13 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index 2d24dc8f9d6..e38a22d9d40 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -141,6 +141,7 @@ # OpenHands utilities from openhands.sdk.llm.llm_response import LLMResponse from openhands.sdk.llm.message import ( + ImageContent, Message, ) from openhands.sdk.llm.mixins.non_native_fc import NonNativeToolCallingMixin @@ -1783,17 +1784,22 @@ def worst_case_cost( *, input_tokens: int | None = None, max_output_tokens: int | None = None, + image_count: int = 0, ) -> float: """Upper-bound USD using finite token bounds and published price tiers. Unknown pricing or token limits cannot establish a hard ceiling and are refused. Each direction uses its highest known context/cache tier, or - the paired custom prices also used by telemetry. + the paired custom prices also used by telemetry. A flat per-image input + fee is charged per image outside the token rates, so ``image_count`` + adds the highest published fee for every image the request carries. """ input_upper = self._budget_token_bound(input_tokens, "input") output_upper = self._budget_token_bound(max_output_tokens, "output") - in_cost, out_cost = self._cost_per_token_bounds() + in_cost, out_cost = self._cost_per_token_bounds(image_tokens=image_count > 0) total = input_upper * in_cost + output_upper * out_cost + if image_count: + total += image_count * self._per_image_input_cost() if not math.isfinite(total): raise self._budget_bound_error("nonfinite price projection") return total @@ -1808,12 +1814,16 @@ def _budget_price(self, value: object) -> float: raise self._budget_bound_error("unknown or invalid token price") return float(value) - def _cost_per_token_bounds(self) -> tuple[float, float]: + def _cost_per_token_bounds( + self, *, image_tokens: bool = False + ) -> tuple[float, float]: """Conservative directional rates, including context/cache tiers. A single custom rate is not a complete override: telemetry uses custom pricing only when both input and output prices are supplied. Explicit - zero is a valid known price; missing/nonfinite prices are not. + zero is a valid known price; missing/nonfinite prices are not. When the + request carries images, an ``input_cost_per_image_token`` rate is a + possible input rate for image tokens and joins the input maximum. """ if ( self.input_cost_per_token is not None @@ -1838,7 +1848,7 @@ def _cost_per_token_bounds(self) -> tuple[float, float]: "cache_read_input_token_cost", "cache_creation_input_token_cost", ) - ): + ) or (image_tokens and key.startswith("input_cost_per_image_token")): input_costs.append(self._budget_price(value)) elif key.startswith( ("output_cost_per_token", "output_cost_per_reasoning_token") @@ -1854,16 +1864,61 @@ def _cost_per_token_bounds(self) -> tuple[float, float]: uplift = max(uplifts) return max(input_costs) * uplift, max(output_costs) * uplift + def _per_image_input_cost(self) -> float: + """Highest published flat per-image input fee, or ``0.0`` when unpriced. + + ``input_cost_per_image`` (and its context-tier variants) is charged per + image in addition to token rates, so it must be added to the token bound + for any request that carries images. Paired custom token prices make + telemetry ignore the catalog fee, so they suppress it here too. + """ + if ( + self.input_cost_per_token is not None + and self.output_cost_per_token is not None + ): + return 0.0 + info = self._model_info or {} + fees = [ + self._budget_price(value) + for key, value in info.items() + if value is not None + and ( + key == "input_cost_per_image" + or key.startswith("input_cost_per_image_above") + ) + ] + return max(fees) if fees else 0.0 + + def _count_request_images(self, messages: Sequence[Message]) -> int: + """Count images the request will actually send. + + Images are dropped from the payload when vision is inactive, so a + disabled-vision request carries no per-image charge even if its message + objects hold image content. + """ + if not self.vision_is_active(): + return 0 + return sum( + len(item.image_urls) + for message in messages + for item in message.content + if isinstance(item, ImageContent) + ) + def _reserve_budget( self, call_context: LLMCallContext | None, request_kwargs: dict[str, Any], + *, + image_count: int = 0, ) -> BudgetReservation | None: """Atomically reserve the bound for this exact prepared attempt. Unsupported request overrides are refused only for budgeted calls: opaque provider bodies, multiple completions and route changes can invalidate the configured model's prices or the outgoing token cap. + ``image_count`` is the number of images this attempt will send, so a + flat per-image fee is included in the bound rather than escaping it. """ budget = call_context.cost_budget if call_context else None if budget is None: @@ -1940,7 +1995,9 @@ def _reserve_budget( provider_cap = metadata.max_input_tokens input_bound = self._budget_token_bound(provider_cap, "input") worst_case = self.worst_case_cost( - input_tokens=input_bound, max_output_tokens=max(output_bounds) + input_tokens=input_bound, + max_output_tokens=max(output_bounds), + image_count=image_count, ) reservation = budget.try_reserve(worst_case) if reservation is None: @@ -1951,6 +2008,8 @@ def _budget_guarded_attempt( self, call_context: LLMCallContext | None, request_kwargs: dict[str, Any], + *, + image_count: int = 0, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Reserve inside SDK retry, before each bounded transport attempt. @@ -1968,7 +2027,9 @@ def decorate(attempt: Callable[..., Any]) -> Callable[..., Any]: @wraps(attempt) async def _reserved_async(*args: Any, **kwargs: Any) -> Any: reservation = self._reserve_budget( - call_context, {**request_kwargs, **kwargs} + call_context, + {**request_kwargs, **kwargs}, + image_count=image_count, ) if reservation is None: return await attempt(*args, **kwargs) @@ -2001,7 +2062,9 @@ async def _reserved_async(*args: Any, **kwargs: Any) -> Any: @wraps(attempt) def _reserved_sync(*args: Any, **kwargs: Any) -> Any: reservation = self._reserve_budget( - call_context, {**request_kwargs, **kwargs} + call_context, + {**request_kwargs, **kwargs}, + image_count=image_count, ) if reservation is None: return attempt(*args, **kwargs) @@ -2136,7 +2199,11 @@ def completion( ) @self._make_retry_decorator() - @self._budget_guarded_attempt(call_context, call_kwargs) + @self._budget_guarded_attempt( + call_context, + call_kwargs, + image_count=self._count_request_images(messages), + ) def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) @@ -2259,7 +2326,11 @@ async def acompletion( @self._make_retry_decorator() @self._async_hard_timeout_decorator() - @self._budget_guarded_attempt(call_context, call_kwargs) + @self._budget_guarded_attempt( + call_context, + call_kwargs, + image_count=self._count_request_images(messages), + ) async def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None self._telemetry.on_request(telemetry_ctx=telemetry_ctx) @@ -2405,7 +2476,9 @@ def responses( @self._make_retry_decorator() @self._budget_guarded_attempt( - call_context, {"tools": resp_tools, **call_kwargs} + call_context, + {"tools": resp_tools, **call_kwargs}, + image_count=self._count_request_images(messages), ) def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse: assert self._telemetry is not None @@ -2576,7 +2649,9 @@ async def aresponses( @self._make_retry_decorator() @self._async_hard_timeout_decorator() @self._budget_guarded_attempt( - call_context, {"tools": resp_tools, **call_kwargs} + call_context, + {"tools": resp_tools, **call_kwargs}, + image_count=self._count_request_images(messages), ) async def _one_attempt( **retry_kwargs: Any, diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index 0c24410c49f..7e900909318 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -12,7 +12,7 @@ import openhands.sdk.llm.llm as llm_module import openhands.sdk.llm.utils.telemetry as telemetry_module -from openhands.sdk import LLM, Message, TextContent +from openhands.sdk import LLM, ImageContent, Message, TextContent from openhands.sdk.llm.call_context import LLMCallContext from openhands.sdk.llm.cost_budget import BudgetReservation, CostBudget from openhands.sdk.llm.exceptions import LLMBudgetExceededError @@ -1021,3 +1021,113 @@ def concurrent_charge(**_kwargs): await llm.acompletion(messages, call_context=context) else: llm.completion(messages, call_context=context) + + +def _with_image(messages: list[Message]) -> list[Message]: + """Attach one image to the fixture's user message.""" + out = list(messages) + user = out[-1] + out[-1] = user.model_copy( + update={ + "content": [ + *user.content, + ImageContent(image_urls=["data:image/png;base64,AAAA"]), + ] + } + ) + return out + + +@pytest.mark.parametrize( + "invoke", + [LLM.completion, LLM.acompletion, LLM.responses, LLM.aresponses], +) +async def test_budget_counts_flat_per_image_fee(budget_call, invoke): + """A flat per-image fee must join the token bound, not bypass the cap. + + Regression: the reservation priced only tokens, so a model that also + charges ``input_cost_per_image`` could be admitted under a ceiling the + request would exceed once the per-image fee was billed. + """ + info, make_llm, messages, providers = budget_call + info["supports_vision"] = True + info["input_cost_per_image"] = 0.05 + llm = make_llm() + image_messages = _with_image(messages) + + with pytest.raises(LLMBudgetExceededError): + result = invoke( + llm, + image_messages, + call_context=LLMCallContext(cost_budget=CostBudget(0.12)), + ) + if inspect.isawaitable(result): + await result + + for provider in providers: + provider.assert_not_called() + + +def test_budget_admits_image_request_when_per_image_fee_fits(budget_call): + info, make_llm, messages, providers = budget_call + info["supports_vision"] = True + info["input_cost_per_image"] = 0.05 + llm = make_llm() + + result = llm.completion( + _with_image(messages), + call_context=LLMCallContext(cost_budget=CostBudget(0.2)), + ) + + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() + + +def test_budget_counts_image_token_rate_only_when_images_are_sent(budget_call): + """``input_cost_per_image_token`` is a possible rate only with images.""" + info, make_llm, messages, providers = budget_call + info["supports_vision"] = True + info["input_cost_per_image_token"] = 0.0001 + llm = make_llm() + budget = CostBudget(0.12) + + with pytest.raises(LLMBudgetExceededError): + llm.completion( + _with_image(messages), call_context=LLMCallContext(cost_budget=budget) + ) + providers[0].assert_not_called() + + result = llm.completion(messages, call_context=LLMCallContext(cost_budget=budget)) + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() + + +def test_budget_ignores_images_dropped_by_disabled_vision(budget_call): + info, make_llm, messages, providers = budget_call + info["supports_vision"] = True + info["input_cost_per_image"] = 0.05 + llm = make_llm(disable_vision=True) + + result = llm.completion( + _with_image(messages), + call_context=LLMCallContext(cost_budget=CostBudget(0.12)), + ) + + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() + + +def test_budget_suppresses_catalog_image_fee_with_custom_prices(budget_call): + """Paired custom token prices override the catalog, as telemetry does.""" + info, make_llm, messages, providers = budget_call + info["supports_vision"] = True + info["input_cost_per_image"] = 0.05 + llm = make_llm(input_cost_per_token=0.0000005, output_cost_per_token=0.001) + + result = llm.completion( + _with_image(messages), + call_context=LLMCallContext(cost_budget=CostBudget(0.12)), + ) + + assert result.message.content == [TextContent(text="ok")] + providers[0].assert_called_once() From a24957436a1b523056046f6ef4a07c7201b83190 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 27 Oct 2026 06:45:42 +0000 Subject: [PATCH 23/24] fix(sdk): count images from the prepared payload, not source messages Counting source messages inflated the reservation for Responses calls: the serializer drops assistant and system images, so a call that sent no image could be denied a budget its real cost fit. Count image parts in the prepared payload instead. Chat completions use ``image_url`` parts; the Responses API uses ``input_image`` nested in message content or ``function_call_output``. This also keeps disabled-vision payloads at zero images without a separate vision check. Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/llm.py | 39 +++++++++++------------ tests/sdk/llm/test_cost_budget.py | 43 +++++++++++++++++++++----- 2 files changed, 55 insertions(+), 27 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index e38a22d9d40..6bb92d69431 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -141,7 +141,6 @@ # OpenHands utilities from openhands.sdk.llm.llm_response import LLMResponse from openhands.sdk.llm.message import ( - ImageContent, Message, ) from openhands.sdk.llm.mixins.non_native_fc import NonNativeToolCallingMixin @@ -1889,21 +1888,23 @@ def _per_image_input_cost(self) -> float: ] return max(fees) if fees else 0.0 - def _count_request_images(self, messages: Sequence[Message]) -> int: - """Count images the request will actually send. - - Images are dropped from the payload when vision is inactive, so a - disabled-vision request carries no per-image charge even if its message - objects hold image content. + @staticmethod + def _count_payload_images(payload: Any) -> int: + """Count image parts in a prepared chat or Responses payload. + + Chat completions carry images as ``image_url`` parts; the Responses API + uses ``input_image`` (nested inside a message's content or a + ``function_call_output``). Counting the prepared payload rather than the + source messages matches what is actually sent, so roles the serializer + drops images from (assistant/system history) and a disabled-vision + payload do not inflate the reservation. """ - if not self.vision_is_active(): - return 0 - return sum( - len(item.image_urls) - for message in messages - for item in message.content - if isinstance(item, ImageContent) - ) + if isinstance(payload, Mapping): + count = 1 if payload.get("type") in ("image_url", "input_image") else 0 + return count + sum(LLM._count_payload_images(v) for v in payload.values()) + if isinstance(payload, list | tuple): + return sum(LLM._count_payload_images(item) for item in payload) + return 0 def _reserve_budget( self, @@ -2202,7 +2203,7 @@ def completion( @self._budget_guarded_attempt( call_context, call_kwargs, - image_count=self._count_request_images(messages), + image_count=self._count_payload_images(formatted_messages), ) def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None @@ -2329,7 +2330,7 @@ async def acompletion( @self._budget_guarded_attempt( call_context, call_kwargs, - image_count=self._count_request_images(messages), + image_count=self._count_payload_images(formatted_messages), ) async def _one_attempt(**retry_kwargs: Any) -> ModelResponse: assert self._telemetry is not None @@ -2478,7 +2479,7 @@ def responses( @self._budget_guarded_attempt( call_context, {"tools": resp_tools, **call_kwargs}, - image_count=self._count_request_images(messages), + image_count=self._count_payload_images(input_items), ) def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse: assert self._telemetry is not None @@ -2651,7 +2652,7 @@ async def aresponses( @self._budget_guarded_attempt( call_context, {"tools": resp_tools, **call_kwargs}, - image_count=self._count_request_images(messages), + image_count=self._count_payload_images(input_items), ) async def _one_attempt( **retry_kwargs: Any, diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index 7e900909318..4d10cde3390 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -1023,18 +1023,15 @@ def concurrent_charge(**_kwargs): llm.completion(messages, call_context=context) +def _image_content(url: str = "data:image/png;base64,AAAA") -> ImageContent: + return ImageContent(image_urls=[url]) + + def _with_image(messages: list[Message]) -> list[Message]: """Attach one image to the fixture's user message.""" out = list(messages) user = out[-1] - out[-1] = user.model_copy( - update={ - "content": [ - *user.content, - ImageContent(image_urls=["data:image/png;base64,AAAA"]), - ] - } - ) + out[-1] = user.model_copy(update={"content": [*user.content, _image_content()]}) return out @@ -1131,3 +1128,33 @@ def test_budget_suppresses_catalog_image_fee_with_custom_prices(budget_call): assert result.message.content == [TextContent(text="ok")] providers[0].assert_called_once() + + +def test_budget_ignores_responses_images_dropped_from_history(budget_call): + """Responses sends only user/tool images, so history images must not count. + + Regression: counting source messages inflated the reservation for a + Responses call whose only image sat in assistant/system history, denying a + request that actually sends none. + """ + info, make_llm, _messages, providers = budget_call + info["supports_vision"] = True + info["input_cost_per_image"] = 0.05 + info["output_cost_per_token"] = 0.0001 + llm = make_llm() + history = [ + Message( + role="system", + content=[TextContent(text="sys"), _image_content()], + ), + Message(role="assistant", content=[TextContent(text="prev"), _image_content()]), + Message(role="user", content=[TextContent(text="hi")]), + ] + + # Token-only bound is $0.02; the two history images would add $0.10. + result = llm.responses( + history, call_context=LLMCallContext(cost_budget=CostBudget(0.05)) + ) + + assert result.message.content == [TextContent(text="ok")] + providers[2].assert_called_once() From eec526c0059c48d9421b6ace07cef08b755a8807 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 28 Oct 2026 08:12:44 +0000 Subject: [PATCH 24/24] fix(sdk): harden live budget changes and ACP budget enforcement Address three correctness gaps in the per-conversation cost budget that the upstream merge (5065712b) exposed: - A live max_budget_per_run change rebuilt the reservation ledger, dropping in-flight reservations and retained conservative charges, so a later call could exceed the new ceiling while the running context still enforced the old one. CostBudget.set_limit() now moves only the ceiling, and _install_budget_ledger() retunes an existing ledger in place. - A cap set on an ACP conversation (via the max_budget_per_run setter or a server PATCH) was accepted but unenforceable, because ACP prompts bypass the pre-call reservation. The setter and the server update path now reject it (HTTP 422); the resume path re-checks the effective persisted cap. - A fork created with reset_metrics=False copied the source's accumulated cost but had already built its ledger from empty stats, so it could reserve against a ceiling that ignored the cost it reported. The fork re-seeds its ledger after copying stats. Adds regression tests for all three, each of which fails on the pre-fix code. Co-authored-by: openhands --- .../agent_server/conversation_router.py | 9 +- .../agent_server/conversation_service.py | 5 + .../conversation/impl/local_conversation.py | 73 ++++++++--- .../openhands/sdk/llm/cost_budget.py | 14 ++ .../agent_server/test_conversation_router.py | 25 ++++ .../agent_server/test_conversation_service.py | 40 ++++++ .../local/test_cost_budget_enforcement.py | 121 ++++++++++++++++++ tests/sdk/llm/test_cost_budget.py | 56 ++++++++ 8 files changed, 324 insertions(+), 19 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py index af75689aae2..b14255fd7e0 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py @@ -750,7 +750,14 @@ async def update_conversation( This endpoint allows updating conversation details like title. """ - updated = await conversation_service.update_conversation(conversation_id, request) + try: + updated = await conversation_service.update_conversation( + conversation_id, request + ) + except CostBudgetUnsupportedError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) + ) from e if not updated: raise HTTPException(status.HTTP_404_NOT_FOUND) return Success() diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index 4a03c82b9be..ae5caa07cac 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -1926,6 +1926,11 @@ async def update_conversation( update["tags"] = request.tags # Membership, not None: an explicit null removes the budget limit. budget_updated = "max_budget_per_run" in request.model_fields_set + if budget_updated and request.max_budget_per_run is not None: + # An ACP agent's prompts bypass pre-call reservation, so a ceiling + # set on a running ACP conversation could never be enforced. Reject + # it before ``save_meta`` so a refused update writes nothing. + reject_acp_budget(state.agent, request.max_budget_per_run) # Persist first: a refused write (e.g. lost lease ownership) must leave # the in-memory metadata and state untouched. await event_service.save_meta(event_service.stored.model_copy(update=update)) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 71f069795c2..f4cbffa767b 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -461,11 +461,12 @@ def __init__( if _parent_llm_call_context is not None else None ) - effective_budget = ( - max_budget_per_run - if max_budget_per_run is not None - else (inherited_budget.limit if inherited_budget is not None else None) - ) + + def effective_budget(own: float | None) -> float | None: + if own is not None: + return own + return inherited_budget.limit if inherited_budget is not None else None + # Reject an ACP budget before ``ConversationState.create`` writes # ``base_state.json``. A later rejection would leave an orphaned state # file behind, so a retry with the same id but a different agent would @@ -473,7 +474,7 @@ def __init__( # The resumed agent is not known until after ``create``, so only a # caller-supplied agent can be checked here; the resume path re-checks # once the persisted agent is loaded. - reject_acp_budget(agent, effective_budget) + reject_acp_budget(agent, effective_budget(max_budget_per_run)) self._state = ConversationState.create( id=desired_id, agent=agent, @@ -493,10 +494,12 @@ def __init__( # ``self.agent`` and ``self._state.agent`` are the same object. if agent is None: agent = self._state.agent - # Resume path: the persisted agent is known only now. No state file - # is created here (base_state.json already exists), so re-checking - # the budget cannot orphan anything. - reject_acp_budget(agent, effective_budget) + # Resume path: the persisted agent and cap are known only now. No + # state file is created here (base_state.json already exists), so + # re-checking cannot orphan anything. Read the cap from the state + # (``max_budget_per_run`` may be None while a cap persisted by an + # earlier write survives the reload). + reject_acp_budget(agent, effective_budget(self.max_budget_per_run)) # The persisted agent carries client-tool ``Tool`` specs, but in a # fresh process the ``ClientTool`` *classes* are absent from the # global registry. Re-register them from the persisted specs so @@ -815,6 +818,11 @@ def max_budget_per_run(self) -> float | None: @max_budget_per_run.setter def max_budget_per_run(self, value: float | None) -> None: + # ACP prompts bypass the SDK's pre-call reservation, so a ceiling set + # here could never be enforced. Reject it before the persisted state is + # written; clearing (``None``) stays allowed. + if value is not None: + reject_acp_budget(self.agent, value) with self._state: self._state.max_budget_per_run = value # The ledger's ceiling follows the live cap so a change takes effect on @@ -830,18 +838,32 @@ def _combined_cost_total(self) -> float: return self.conversation_stats.get_combined_metrics().accumulated_cost def _install_budget_ledger(self) -> None: - """Build the reservation ledger for the current cap and recorded cost. + """Build or retune the reservation ledger for the current cap. The ledger's ceiling is the conversation's accumulated cost, so the - owner seeds its tree with the cost already recorded. Rebuilding on a cap - change (the ``max_budget_per_run`` setter) makes a raised or cleared - limit take effect on the next call. A conversation that only inherited a - parent's budget shares the parent's tree and must never reseed it, or a - delegate's own cap change would wipe the parent's settled totals. + owner seeds its tree with the cost already recorded. A conversation that + only inherited a parent's budget shares the parent's tree and must never + reseed it, or a delegate's own cap change would wipe the parent's + settled totals. + + A live cap change must not rebuild the tree: reservations already held, + conservative charges retained for failed or unknown-cost attempts, and + descendant totals would be lost, so a later call could exceed the new + ceiling. When a ledger already exists it is retuned in place, which also + keeps the running conversation's captured call context and inherited + child views pointing at the same, updated tree. """ cap = self.max_budget_per_run + existing = self._cost_budget if cap is not None and cap > 0: - if self._inherited_budget is not None: + if existing is not None and existing is not self._inherited_budget: + # A ledger this conversation created already exists (its own, or + # a child view of an inherited one). Retune it in place so + # in-flight reservations, retained conservative charges, and + # descendant totals survive, and inherited child views keep + # sharing the same, updated tree. + existing.set_limit(cap) + elif self._inherited_budget is not None: # A subagent shares its parent's reservation pool so parent and # children contend on one ceiling; the tightest limit applies. self._cost_budget = self._inherited_budget.child(cap) @@ -849,7 +871,7 @@ def _install_budget_ledger(self) -> None: else: self._cost_budget = CostBudget(cap) self._owns_budget = True - self._cost_budget.reseed(self._combined_cost_total()) + self._seed_budget_ledger() else: # No cap shares an inherited tree; a non-positive cap (accepted by # the persisted state) is already met, so it needs no ledger and @@ -860,6 +882,17 @@ def _install_budget_ledger(self) -> None: self._llm_call_context, cost_budget=self._cost_budget ) + def _seed_budget_ledger(self) -> None: + """Seed the owning tree with the conversation's recorded cost. + + Only a ledger this conversation owns is seeded: a conversation that + inherited a parent's budget shares the parent's tree and must never + reseed it, and a ledger holding in-flight reservations must not be reset + under them. + """ + if self._owns_budget and self._cost_budget is not None: + self._cost_budget.reseed(self._combined_cost_total()) + def _latest_acp_prompt_message_id(self) -> str | None: """Id of the most recent ACP prompt message, or None if there is none.""" acp_prompt_messages = [ @@ -1096,6 +1129,10 @@ def fork( # Reset or copy metrics if not reset_metrics: fork_conv._state.stats = self._state.stats.model_copy(deep=True) + # The fork built its ledger from its then-empty stats; re-seed + # it now that the copied spend is visible, so the fork cannot + # reserve against a ceiling that ignores its inherited cost. + fork_conv._seed_budget_ledger() event_count = len(source_events) diff --git a/openhands-sdk/openhands/sdk/llm/cost_budget.py b/openhands-sdk/openhands/sdk/llm/cost_budget.py index 1998de9bdbe..aaff913b558 100644 --- a/openhands-sdk/openhands/sdk/llm/cost_budget.py +++ b/openhands-sdk/openhands/sdk/llm/cost_budget.py @@ -94,6 +94,20 @@ def child(self, limit: float) -> CostBudget: view._subtree_reserved = 0.0 return view + def set_limit(self, limit: float) -> None: + """Move this view's own ceiling without disturbing the tree. + + Only the limit changes: reservations already held, conservative charges + retained for failed or unknown-cost attempts, and descendant totals all + stay. A live cap change therefore cannot admit a call that ignores work + already in flight, and a running call that references this same view + observes the new ceiling. + """ + if limit <= 0: + raise ValueError("Cost budget limit must be strictly positive") + with self._tree.lock: + self._limit = limit + def _sync(self) -> None: """Reset this view's totals if the tree was reseeded. Lock held.""" generation = self._tree.generation diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py index 08841fd7c28..ed3af433ccb 100644 --- a/tests/agent_server/test_conversation_router.py +++ b/tests/agent_server/test_conversation_router.py @@ -1915,6 +1915,31 @@ def test_update_conversation_not_found( client.app.dependency_overrides.clear() +def test_update_conversation_budget_on_acp_is_422( + client, mock_conversation_service, sample_conversation_id +): + """An unenforceable ACP budget is a 422, not a silent 200.""" + + mock_conversation_service.update_conversation.side_effect = ( + CostBudgetUnsupportedError("max_budget_per_run is unsupported for ACP agents") + ) + + client.app.dependency_overrides[get_conversation_service] = lambda: ( + mock_conversation_service + ) + + try: + response = client.patch( + f"/api/conversations/{sample_conversation_id}", + json={"max_budget_per_run": 1.0}, + ) + + assert response.status_code == 422 + assert "ACP" in response.json()["detail"] + finally: + client.app.dependency_overrides.clear() + + def test_update_conversation_invalid_title( client, mock_conversation_service, sample_conversation_id ): diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py index 7409ddc7398..e22657d49ec 100644 --- a/tests/agent_server/test_conversation_service.py +++ b/tests/agent_server/test_conversation_service.py @@ -362,6 +362,46 @@ async def test_rejected_acp_budget_does_not_orphan_worktree_or_branch(tmp_path): assert not (tmp_path / "conversations" / conversation_id.hex).exists() +@pytest.mark.asyncio +async def test_update_acp_conversation_rejects_budget(tmp_path): + """A cap PATCHed onto a running ACP conversation must be refused. + + ACP prompts bypass the SDK's pre-call reservation, so accepting the cap + would promise enforcement the SDK cannot deliver. The refused update must + write nothing. + """ + conversations_dir = tmp_path / "conversations" + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + request = StartConversationRequest( + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=LocalWorkspace(working_dir=str(workspace_dir)), + confirmation_policy=NeverConfirm(), + ) + + async with ConversationService(conversations_dir=conversations_dir) as service: + info, _ = await service.start_conversation(request) + with pytest.raises(CostBudgetUnsupportedError): + await service.update_conversation( + info.id, UpdateConversationRequest(max_budget_per_run=1.0) + ) + + event_service = await service.get_event_service(info.id) + assert event_service is not None + # The refused PATCH left the live state untouched... + assert event_service.get_conversation().max_budget_per_run is None + # ...and nothing was persisted. + state = json.loads( + (conversations_dir / info.id.hex / "base_state.json").read_text() + ) + assert state.get("max_budget_per_run") is None + + # Clearing (an explicit null) is still accepted. + await service.update_conversation( + info.id, UpdateConversationRequest(max_budget_per_run=None) + ) + + @pytest.mark.asyncio async def test_agent_tool_from_unimportable_module_rejects_create(tmp_path): """The create fails with a client error instead of every message failing.""" diff --git a/tests/sdk/conversation/local/test_cost_budget_enforcement.py b/tests/sdk/conversation/local/test_cost_budget_enforcement.py index a0c9486ad25..e3402c1cace 100644 --- a/tests/sdk/conversation/local/test_cost_budget_enforcement.py +++ b/tests/sdk/conversation/local/test_cost_budget_enforcement.py @@ -300,3 +300,124 @@ def test_delegate_install_does_not_weaken_the_parent_ceiling(): # The parent keeps its recorded spend; only $0.31 of headroom remains. assert parent_budget.live_total == pytest.approx(1.0) assert parent_budget.try_reserve(0.40) is None + + +def test_live_cap_change_preserves_in_flight_reservation(): + """Raising the cap mid-run must not forget an in-flight reservation. + + Regression: ``_install_budget_ledger`` replaced the ledger on a cap change, + so a reservation held by the running conversation vanished and a later call + could reserve against the new ceiling as if the in-flight work did not exist. + """ + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + conv = _conversation(tmp, max_budget_per_run=1.0) + budget = conv._cost_budget + assert budget is not None + + reservation = budget.try_reserve(0.8) + assert reservation is not None + + conv.max_budget_per_run = 1.5 + + # The running conversation's captured context still points at the same, + # retuned ledger, and the in-flight $0.80 still counts. + assert conv._cost_budget is budget + assert conv.get_llm_call_context().cost_budget is budget + assert budget.live_total == pytest.approx(0.8) + assert budget.try_reserve(0.8) is None + assert budget.try_reserve(0.5) is not None + + +def test_setting_budget_on_existing_acp_conversation_is_rejected(): + """A cap PATCHed onto an ACP conversation must be refused, not unenforced.""" + from openhands.sdk.agent.acp_agent import ACPAgent + from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + conv = Conversation( + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=str(tmp), + persistence_dir=str(tmp / "persist"), + visualizer=None, + delete_on_close=False, + ) + with pytest.raises(CostBudgetUnsupportedError, match="ACP agents"): + conv.max_budget_per_run = 1.0 + assert conv.max_budget_per_run is None + # Clearing stays allowed. + conv.max_budget_per_run = None + + +def test_resumed_acp_conversation_rejects_persisted_budget(): + """A persisted ACP cap must be rejected when the conversation is reloaded. + + Regression: the resume path only checked the constructor argument, so a cap + written into ``base_state.json`` survived reload and stayed unenforced. + """ + import json + from uuid import uuid4 + + from openhands.sdk.agent.acp_agent import ACPAgent + from openhands.sdk.conversation.exceptions import CostBudgetUnsupportedError + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + persist = tmp / "persist" + cid = uuid4() + # Write an ACP conversation whose persisted cap the SDK can no longer + # accept, as an older/other writer could have. + conv = Conversation( + agent=ACPAgent(acp_command=["echo", "test"]), + workspace=str(tmp), + persistence_dir=str(persist), + conversation_id=cid, + visualizer=None, + delete_on_close=False, + ) + conv.close() + base = persist / cid.hex / "base_state.json" + data = json.loads(base.read_text()) + data["max_budget_per_run"] = 1.0 + base.write_text(json.dumps(data)) + + with pytest.raises(CostBudgetUnsupportedError, match="ACP agents"): + LocalConversation( + agent=None, + workspace=str(tmp), + persistence_dir=str(persist), + conversation_id=cid, + visualizer=None, + delete_on_close=False, + ) + + +def test_fork_with_copied_metrics_seeds_its_ledger(): + """A fork that copies the source's cost must seed its ledger with it. + + Regression: the fork built its ledger from its then-empty stats, so a fork + with ``reset_metrics=False`` could reserve a call against a ceiling that + ignored the cost it reported as accumulated. + """ + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + src = _conversation(tmp, max_budget_per_run=2.0) + from openhands.sdk.llm.utils.metrics import Metrics + + metric = Metrics() + metric.accumulated_cost = 1.5 + src._state.stats.usage_to_metrics["main"] = metric + + fork = src.fork(reset_metrics=False) + + assert ( + fork.conversation_stats.get_combined_metrics().accumulated_cost + == pytest.approx(1.5) + ) + fork_budget = fork._cost_budget + assert fork_budget is not None + # Only $0.50 of the $2.00 cap remains after the copied $1.50. + assert fork_budget.live_total == pytest.approx(1.5) + assert fork_budget.try_reserve(0.6) is None + assert fork_budget.try_reserve(0.5) is not None diff --git a/tests/sdk/llm/test_cost_budget.py b/tests/sdk/llm/test_cost_budget.py index 9f67f990d27..f1021579131 100644 --- a/tests/sdk/llm/test_cost_budget.py +++ b/tests/sdk/llm/test_cost_budget.py @@ -191,6 +191,62 @@ def test_reseed_resets_descendant_subtree_totals(): assert child.try_reserve(0.9) is not None +def test_set_limit_preserves_in_flight_reservations(): + """A live cap change must not drop reservations already held. + + Regression: retuning a conversation's ceiling rebuilt the ledger, so an + in-flight reservation vanished and a later call could reserve against the + new ceiling as if the in-flight work did not exist. + """ + budget = CostBudget(1.0) + reservation = budget.try_reserve(0.8) + assert reservation is not None + + budget.set_limit(1.5) + + # The in-flight $0.80 still counts: only $0.70 of headroom remains. + assert budget.live_total == pytest.approx(0.8) + assert budget.try_reserve(0.8) is None + assert budget.try_reserve(0.5) is not None + + +def test_set_limit_preserves_retained_conservative_charges(): + """A cap change must keep conservative charges from failed attempts. + + Regression: rebuilding the ledger discarded the retained bound for an + unknown-cost attempt, letting a retry reuse allowance that was already + committed. + """ + budget = CostBudget(1.0) + _settle(budget, 0.9) # retained as budget usage for an unknown-cost attempt + + budget.set_limit(1.5) + + assert budget.spent == pytest.approx(0.9) + assert budget.try_reserve(0.7) is None + assert budget.try_reserve(0.6) is not None + + +def test_set_limit_keeps_descendant_views_contending_on_one_tree(): + parent = CostBudget(5.0) + child = parent.child(1.0) + _settle(child, 0.9) + + parent.set_limit(2.0) + + # The child keeps its own cap and the parent's raised cap still binds the + # whole tree. + assert child.spent == pytest.approx(0.9) + assert child.try_reserve(0.2) is None + assert parent.live_total == pytest.approx(0.9) + + +def test_set_limit_rejects_non_positive_limit(): + budget = CostBudget(1.0) + with pytest.raises(ValueError, match="strictly positive"): + budget.set_limit(0.0) + + def test_concurrent_reservations_cannot_overshoot(): """A shared budget admits at most the calls that fit, even in parallel.""" budget = CostBudget(1.0)