generated from coulomb/repo-seed
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:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user