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

@@ -12,7 +12,7 @@ Quick start::
response = adapter.execute_prompt(prompt, run_config)
"""
from llm_connect.models import RunConfig, LLMResponse
from llm_connect.models import RunConfig, LLMResponse, BudgetTracker
from llm_connect.adapter import LLMAdapter, MockLLMAdapter, ErrorLLMAdapter
from llm_connect.factory import create_adapter
from llm_connect.openrouter import OpenRouterAdapter
@@ -27,6 +27,7 @@ from llm_connect.exceptions import (
LLMRateLimitError,
LLMTimeoutError,
LLMSubprocessError,
LLMBudgetExceededError,
)
from llm_connect.embedding_adapter import EmbeddingAdapter
from llm_connect.embedding_openai import OpenAICompatibleEmbeddingAdapter
@@ -41,6 +42,7 @@ from llm_connect.similarity import (
__all__ = [
"RunConfig",
"LLMResponse",
"BudgetTracker",
"LLMAdapter",
"MockLLMAdapter",
"ErrorLLMAdapter",
@@ -57,6 +59,7 @@ __all__ = [
"LLMRateLimitError",
"LLMTimeoutError",
"LLMSubprocessError",
"LLMBudgetExceededError",
"EmbeddingAdapter",
"OpenAICompatibleEmbeddingAdapter",
"EmbeddingCache",

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:
"""

View File

@@ -2,6 +2,7 @@
Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess.
"""
import asyncio
import subprocess
from typing import Optional
@@ -35,6 +36,7 @@ class ClaudeCodeAdapter(LLMAdapter):
# ── LLMAdapter interface ────────────────────────────────────────
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self._preflight_budget(config)
cmd = [self._cli_path, "--print"]
if self._model:
cmd.extend(["--model", self._model])
@@ -66,7 +68,7 @@ class ClaudeCodeAdapter(LLMAdapter):
prompt_tokens = estimate_tokens(prompt)
completion_tokens = estimate_tokens(content)
return LLMResponse(
response = LLMResponse(
content=content,
model=self._model or "claude-code-cli",
usage={
@@ -80,6 +82,63 @@ class ClaudeCodeAdapter(LLMAdapter):
"cli_path": self._cli_path,
},
)
self._consume_budget(config, response)
return response
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
"""Native async implementation using asyncio.create_subprocess_exec."""
self._preflight_budget(config)
cmd = [self._cli_path, "--print"]
if self._model:
cmd.extend(["--model", self._model])
timeout = config.timeout_seconds or self._config.timeout_seconds
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(input=prompt.encode()),
timeout=timeout,
)
except asyncio.TimeoutError as exc:
raise LLMTimeoutError(
f"claude CLI timed out after {timeout}s",
cause=exc,
) from exc
if proc.returncode != 0:
raise LLMSubprocessError(
f"claude CLI exited with code {proc.returncode}",
return_code=proc.returncode,
stderr=stderr_bytes.decode(),
)
content = stdout_bytes.decode()
prompt_tokens = estimate_tokens(prompt)
completion_tokens = estimate_tokens(content)
response = LLMResponse(
content=content,
model=self._model or "claude-code-cli",
usage={
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
finish_reason="stop",
metadata={
"provider": "claude-code",
"cli_path": self._cli_path,
"async": True,
},
)
self._consume_budget(config, response)
return response
def validate_config(self, config: RunConfig) -> bool:
try:

View File

@@ -64,6 +64,30 @@ class LLMTimeoutError(LLMError):
pass
class LLMBudgetExceededError(LLMError):
"""Token budget cap exceeded during a call or delegation chain.
Attributes:
total: The configured token cap.
spent: Tokens already consumed before this call.
requested: Tokens this call would have consumed.
"""
def __init__(
self,
message: str,
total: int = 0,
spent: int = 0,
requested: int = 0,
cause: Optional[Exception] = None,
context: Optional[Dict[str, Any]] = None,
):
super().__init__(message, cause=cause, context=context)
self.total = total
self.spent = spent
self.requested = requested
class LLMSubprocessError(LLMError):
"""Claude Code CLI subprocess failed.

View File

@@ -2,6 +2,7 @@
Google Gemini adapter — calls the Generative Language REST API directly.
"""
import asyncio
import time
from typing import Optional, Dict, Any
@@ -48,6 +49,7 @@ class GeminiAdapter(LLMAdapter):
# ── LLMAdapter interface ────────────────────────────────────────
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self._preflight_budget(config)
model = self._model
# Build Gemini request
@@ -92,7 +94,7 @@ class GeminiAdapter(LLMAdapter):
usage_meta = data.get("usageMetadata", {})
return LLMResponse(
response = LLMResponse(
content=content,
model=model,
usage={
@@ -106,6 +108,12 @@ class GeminiAdapter(LLMAdapter):
"latency_seconds": round(latency, 3),
},
)
self._consume_budget(config, response)
return response
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
"""Async wrapper — runs execute_prompt in a thread executor."""
return await asyncio.to_thread(self.execute_prompt, prompt, config)
def validate_config(self, config: RunConfig) -> bool:
if not self._api_key:

View File

@@ -5,8 +5,53 @@ These classes are the canonical definitions; they are re-exported by
markitect.prompts.execution.models for backward compatibility.
"""
import threading
from dataclasses import dataclass, field
from typing import Dict, Any
from typing import Dict, Any, Optional
class BudgetTracker:
"""Shared token budget for a call or delegation chain.
Thread-safe. Tracks cumulative token spend across multiple adapter
calls. Raises ``LLMBudgetExceededError`` when the cap is exceeded.
Example::
tracker = BudgetTracker(total=4000)
config = RunConfig(budget_tracker=tracker)
# All adapter calls sharing this config will consume from the same cap.
"""
def __init__(self, total: int) -> None:
if total <= 0:
raise ValueError(f"BudgetTracker total must be positive, got {total}")
self.total = total
self.spent = 0
self._lock = threading.Lock()
def remaining(self) -> int:
"""Return tokens remaining in the budget."""
return max(0, self.total - self.spent)
def consume(self, tokens: int) -> None:
"""Record *tokens* as spent. Raises ``LLMBudgetExceededError`` if cap exceeded."""
from llm_connect.exceptions import LLMBudgetExceededError # avoid circular at module load
with self._lock:
new_spent = self.spent + tokens
if new_spent > self.total:
raise LLMBudgetExceededError(
f"Token budget exceeded: {new_spent} tokens used, cap is {self.total}",
total=self.total,
spent=self.spent,
requested=tokens,
context={"total": self.total, "spent": self.spent, "requested": tokens},
)
self.spent = new_spent
def __repr__(self) -> str:
return f"BudgetTracker(total={self.total}, spent={self.spent}, remaining={self.remaining()})"
@dataclass
@@ -30,9 +75,10 @@ class RunConfig:
max_depth: int = 3
skip_if_exists: bool = True
timeout_seconds: int = 300
budget_tracker: Optional["BudgetTracker"] = field(default=None, repr=False)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
"""Convert to dictionary. ``budget_tracker`` is excluded (runtime object)."""
return {
"model_name": self.model_name,
"temperature": self.temperature,

View File

@@ -2,6 +2,7 @@
OpenAI (ChatGPT) adapter — calls the OpenAI chat completions API.
"""
import asyncio
import time
from typing import Optional, Dict, Any
@@ -51,6 +52,7 @@ class OpenAIAdapter(LLMAdapter):
# ── LLMAdapter interface ────────────────────────────────────────
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self._preflight_budget(config)
model = self._model
messages: list[Dict[str, str]] = []
@@ -80,7 +82,7 @@ class OpenAIAdapter(LLMAdapter):
finish_reason = choice.get("finish_reason", "stop")
usage = data.get("usage", {})
return LLMResponse(
response = LLMResponse(
content=content,
model=data.get("model", model),
usage={
@@ -95,6 +97,12 @@ class OpenAIAdapter(LLMAdapter):
"response_id": data.get("id", ""),
},
)
self._consume_budget(config, response)
return response
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
"""Async wrapper — runs execute_prompt in a thread executor."""
return await asyncio.to_thread(self.execute_prompt, prompt, config)
def validate_config(self, config: RunConfig) -> bool:
if not self._api_key:

View File

@@ -2,6 +2,7 @@
OpenRouter adapter — calls the OpenAI-compatible chat completions API.
"""
import asyncio
import time
from typing import Optional, Dict, Any
@@ -55,6 +56,7 @@ class OpenRouterAdapter(LLMAdapter):
# ── LLMAdapter interface ────────────────────────────────────────
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self._preflight_budget(config)
model = self._model if self._model != _DEFAULT_MODEL else (config.model_name or self._model)
messages: list[Dict[str, str]] = []
@@ -88,7 +90,7 @@ class OpenRouterAdapter(LLMAdapter):
finish_reason = choice.get("finish_reason", "stop")
usage = data.get("usage", {})
return LLMResponse(
response = LLMResponse(
content=content,
model=data.get("model", model),
usage={
@@ -103,6 +105,12 @@ class OpenRouterAdapter(LLMAdapter):
"response_id": data.get("id", ""),
},
)
self._consume_budget(config, response)
return response
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
"""Async wrapper — runs execute_prompt in a thread executor."""
return await asyncio.to_thread(self.execute_prompt, prompt, config)
def validate_config(self, config: RunConfig) -> bool:
if not self._api_key: