feat: WP-0001 foundation + WP-0002 core extensions

WP-0001 — Foundation & GAAF Baseline
- SCOPE.md, ARCHITECTURE-LAYERS.md, contracts/ tree
- .claude/rules/ stubs filled (architecture, stack, boundary)
- 57 tests (pytest), pyproject.toml with ruff+mypy, CI workflow

WP-0002 — Core Extensions (FR-4 + FR-3)
- FR-4: BudgetTracker (thread-safe) + LLMBudgetExceededError +
  optional RunConfig.budget_tracker + enforcement in all adapters
- FR-3: async_execute_prompt on LLMAdapter ABC (asyncio.to_thread
  fallback) + native asyncio.create_subprocess_exec in ClaudeCodeAdapter

81 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 22:24:14 +00:00
parent 57b346bb8b
commit d71f4114d1
28 changed files with 1601 additions and 26 deletions

View File

@@ -5,10 +5,11 @@ Implements abstraction layer for LLM integration, supporting
multiple providers (OpenAI, Anthropic, local models, etc.).
"""
import asyncio
from abc import ABC, abstractmethod
from typing import Dict, Any
from llm_connect.models import RunConfig, LLMResponse
from llm_connect.models import RunConfig, LLMResponse, BudgetTracker
class LLMAdapter(ABC):
@@ -40,6 +41,26 @@ class LLMAdapter(ABC):
"""
pass
async def async_execute_prompt(
self,
prompt: str,
config: RunConfig,
) -> LLMResponse:
"""Execute a prompt asynchronously.
Default implementation runs :meth:`execute_prompt` in a thread
executor so that the event loop is not blocked. Subclasses may
override with a native ``asyncio``-based implementation.
Args:
prompt: Compiled prompt text
config: Execution configuration
Returns:
LLMResponse with generated content
"""
return await asyncio.to_thread(self.execute_prompt, prompt, config)
@abstractmethod
def validate_config(self, config: RunConfig) -> bool:
"""
@@ -53,6 +74,27 @@ class LLMAdapter(ABC):
"""
pass
# ── Budget helpers (call in execute_prompt implementations) ─────
def _preflight_budget(self, config: RunConfig) -> None:
"""Raise ``LLMBudgetExceededError`` if the budget is already exhausted."""
if config.budget_tracker is not None and config.budget_tracker.remaining() == 0:
from llm_connect.exceptions import LLMBudgetExceededError
tracker = config.budget_tracker
raise LLMBudgetExceededError(
"Token budget exhausted before making request",
total=tracker.total,
spent=tracker.spent,
requested=0,
context={"total": tracker.total, "spent": tracker.spent},
)
def _consume_budget(self, config: RunConfig, response: LLMResponse) -> None:
"""Consume tokens from the budget tracker after a successful call."""
if config.budget_tracker is not None:
tokens = response.usage.get("total_tokens", 0)
config.budget_tracker.consume(tokens)
class MockLLMAdapter(LLMAdapter):
"""
@@ -88,11 +130,12 @@ class MockLLMAdapter(LLMAdapter):
Returns:
Mock LLMResponse
"""
self._preflight_budget(config)
self.call_count += 1
self.last_prompt = prompt
self.last_config = config
return LLMResponse(
response = LLMResponse(
content=self.mock_response,
model=config.model_name,
usage={
@@ -103,6 +146,8 @@ class MockLLMAdapter(LLMAdapter):
finish_reason="stop",
metadata={"mock": True},
)
self._consume_budget(config, response)
return response
def validate_config(self, config: RunConfig) -> bool:
"""