band-aider-pub/tests/band_aider/test_token_jacker.py
2026-10-17 11:57:37 -07:00

181 lines
5.9 KiB
Python

# SPDX-FileCopyrightText: 2026 Art Sea LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
from __future__ import annotations
import logging
from band_aider.ai_client.completion_client import CompletionClient, OpenAIConfig
from band_aider.ai_client.token_jacker import TokenJacker
from band_aider.ai_client.cached_completion_client import CachedCompletionClient
from band_aider.context.token_budget_state import TokenBudgetSettings
from band_aider.ui.dto.model_escalation import ModelEscalationDTO
class _RecordingClient(CompletionClient):
"""_RecordingClient records prompts and returns configured responses."""
model = "default"
api_base = "https://example.invalid"
api_key = "key"
temperature = 0
max_tokens = None
max_attempts = 8
retry_temperature_step = 0.2
max_retry_temperature = 0.8
def __init__(self, response: str, openai_configs: tuple[OpenAIConfig, ...] = ()) -> None:
self.response = response
self._openai_configs = openai_configs
self.prompts: list[str] = []
def complete(self, prompt: str, logger: logging.Logger | None = None) -> str:
"""Record the prompt and return the stationary configured response."""
del logger
self.prompts.append(prompt)
return self.response
@property
def openai_configs(self) -> tuple[OpenAIConfig, ...]:
return self._openai_configs
@property
def inner(self) -> CompletionClient | None:
return None
class _Publisher:
def __init__(self) -> None:
self.events: list[object] = []
def publish(self, event: object) -> None:
self.events.append(event)
def test_token_jacker_forwards_under_budget_prompt_to_inner() -> None:
"""Verify under-budget prompts keep using the primary client."""
inner = _RecordingClient("inner")
client = TokenJacker(
inner=inner,
settings=TokenBudgetSettings(max_prompt_tokens=10_000),
)
assert client.complete("small prompt") == "inner"
assert inner.prompts == ["small prompt"]
def test_token_jacker_routes_oversized_prompt_to_overflow() -> None:
"""Verify oversized prompts use the overflow client instead of reduction."""
inner = _RecordingClient(
"inner",
openai_configs=(
OpenAIConfig(model="local", api_base="http://local"),
OpenAIConfig(model="cloud", api_base="http://cloud"),
),
)
overflow = _RecordingClient("overflow")
publisher = _Publisher()
client = TokenJacker(
inner=inner,
settings=TokenBudgetSettings(max_prompt_tokens=1, tokens_per_word=1.0),
event_publisher=publisher,
escalation_clients=(overflow,),
)
assert client.complete("too long") == "overflow"
assert inner.prompts == []
assert overflow.prompts == ["too long"]
escalations = [event for event in publisher.events if isinstance(event, ModelEscalationDTO)]
assert len(escalations) == 1
assert escalations[0].trigger_name == "TokenJacker"
assert escalations[0].active_model == "cloud"
assert escalations[0].state == "routed"
assert escalations[0].prompt_preview == "too long"
def test_token_jacker_escalates_past_cached_primary_response() -> None:
inner = _RecordingClient("primary")
overflow = _RecordingClient("overflow")
primary_cached = CachedCompletionClient(inner)
overflow_cached = CachedCompletionClient(overflow)
primary_cached._cache.put("too long", "cached-primary")
client = TokenJacker(
inner=primary_cached,
settings=TokenBudgetSettings(max_prompt_tokens=1, tokens_per_word=1.0),
escalation_clients=(overflow_cached,),
)
assert client.complete("too long") == "overflow"
assert inner.prompts == []
assert overflow.prompts == ["too long"]
def test_token_jacker_walks_multiple_overflow_tiers() -> None:
"""Verify overflow routing escalates past one failed tier to the next."""
inner = _RecordingClient(
"inner",
openai_configs=(
OpenAIConfig(model="local", api_base="http://local"),
OpenAIConfig(model="cloud-1", api_base="http://cloud-1"),
OpenAIConfig(model="cloud-2", api_base="http://cloud-2"),
),
)
seen_models: list[str] = []
class _FailingClient(CompletionClient):
def complete(self, prompt: str, logger: logging.Logger | None = None) -> str:
del prompt, logger
seen_models.append("cloud-1")
raise RuntimeError("overflow tier failed")
@property
def openai_configs(self) -> tuple[OpenAIConfig, ...]:
return ()
@property
def inner(self) -> CompletionClient | None:
return None
class _SuccessClient(CompletionClient):
def complete(self, prompt: str, logger: logging.Logger | None = None) -> str:
del prompt, logger
seen_models.append("cloud-2")
return "overflow-2"
@property
def openai_configs(self) -> tuple[OpenAIConfig, ...]:
return ()
@property
def inner(self) -> CompletionClient | None:
return None
client = TokenJacker(
inner=inner,
settings=TokenBudgetSettings(max_prompt_tokens=1, tokens_per_word=1.0),
escalation_clients=(_FailingClient(), _SuccessClient()),
)
assert client.complete("too long") == "overflow-2"
assert seen_models == ["cloud-1", "cloud-2"]
def test_token_jacker_oversized_prompt_falls_back_to_inner_without_overflow() -> None:
"""Verify oversized prompts log and fall back to the primary client when no overflow tier exists."""
inner = _RecordingClient(
"inner",
openai_configs=(OpenAIConfig(model="local", api_base="http://local"),),
)
client = TokenJacker(
inner=inner,
settings=TokenBudgetSettings(max_prompt_tokens=1, tokens_per_word=1.0),
)
assert client.complete("too long") == "inner"
assert inner.prompts == ["too long"]