misterdev 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- misterdev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import time # noqa: F401 — kept so tests can patch `client.time.sleep` on this package
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from misterdev.config import get_setting
|
|
5
|
+
from misterdev.logging_setup import setup_logger
|
|
6
|
+
|
|
7
|
+
from .base import BaseLLMClient
|
|
8
|
+
from .edits import APPLY_EDITS_TOOL, _edits_to_markdown, code_gen_abort_check
|
|
9
|
+
from .embeddings import (
|
|
10
|
+
LocalEmbeddingClient,
|
|
11
|
+
OpenRouterEmbeddingClient,
|
|
12
|
+
_create_local_embedding_client,
|
|
13
|
+
_create_openrouter_embedding_client,
|
|
14
|
+
create_embedding_client,
|
|
15
|
+
)
|
|
16
|
+
from .errors import (
|
|
17
|
+
RETRYABLE_ERROR_MARKERS,
|
|
18
|
+
RETRYABLE_EXCEPTION_NAMES,
|
|
19
|
+
RETRYABLE_STATUS_CODES,
|
|
20
|
+
BudgetExceededError,
|
|
21
|
+
LLMCallError,
|
|
22
|
+
_api_error,
|
|
23
|
+
_error_status_code,
|
|
24
|
+
_is_retryable_error,
|
|
25
|
+
)
|
|
26
|
+
from .providers import (
|
|
27
|
+
CACHE_BREAKPOINT,
|
|
28
|
+
AnthropicLLMClient,
|
|
29
|
+
OpenRouterLLMClient,
|
|
30
|
+
_deny_unless_training_allowed,
|
|
31
|
+
_openrouter_sdk,
|
|
32
|
+
)
|
|
33
|
+
from .response import LLMResponse, LLMUsage
|
|
34
|
+
|
|
35
|
+
logger = setup_logger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FailoverLLMClient(BaseLLMClient):
|
|
39
|
+
"""Wraps a primary client plus ordered fallbacks for provider outages.
|
|
40
|
+
|
|
41
|
+
On a retryable error from one provider it advances to the next; a
|
|
42
|
+
non-retryable error (bad request) propagates immediately. Usage from
|
|
43
|
+
whichever provider served the request is tracked on this wrapper.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: dict):
|
|
47
|
+
super().__init__(config)
|
|
48
|
+
self.primary = _create_single_client(config)
|
|
49
|
+
self.failover_clients: list[BaseLLMClient] = []
|
|
50
|
+
for fc in get_setting(config, "llm", "failover"):
|
|
51
|
+
try:
|
|
52
|
+
merged = {**config, "llm": {**config["llm"], **fc, "failover": []}}
|
|
53
|
+
self.failover_clients.append(_create_single_client(merged))
|
|
54
|
+
except (ValueError, ImportError) as e:
|
|
55
|
+
logger.warning(f"Failover provider unavailable, skipping: {e}")
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def model(self) -> str:
|
|
59
|
+
return self.primary.model
|
|
60
|
+
|
|
61
|
+
def with_model(self, model: str):
|
|
62
|
+
# Route the primary path; fallbacks keep their configured models.
|
|
63
|
+
return self.primary.with_model(model)
|
|
64
|
+
|
|
65
|
+
def _call(self, prompt: str, system_prompt: str) -> LLMResponse:
|
|
66
|
+
clients = [self.primary] + self.failover_clients
|
|
67
|
+
last_error: Optional[LLMCallError] = None
|
|
68
|
+
for i, client in enumerate(clients):
|
|
69
|
+
try:
|
|
70
|
+
response = client._call(prompt, system_prompt)
|
|
71
|
+
if i > 0:
|
|
72
|
+
logger.info(
|
|
73
|
+
f"Failover successful via {client.__class__.__name__} ({client.model})."
|
|
74
|
+
)
|
|
75
|
+
return response
|
|
76
|
+
except LLMCallError as e:
|
|
77
|
+
last_error = e
|
|
78
|
+
if not e.retryable:
|
|
79
|
+
raise
|
|
80
|
+
logger.warning(
|
|
81
|
+
f"Provider {client.__class__.__name__} failed ({e}); trying next provider..."
|
|
82
|
+
)
|
|
83
|
+
raise last_error or LLMCallError("All LLM providers failed", retryable=False)
|
|
84
|
+
|
|
85
|
+
def _call_stream(self, prompt: str, system_prompt: str):
|
|
86
|
+
clients = [self.primary] + self.failover_clients
|
|
87
|
+
last_error: Optional[LLMCallError] = None
|
|
88
|
+
for i, client in enumerate(clients):
|
|
89
|
+
try:
|
|
90
|
+
usage = yield from client._call_stream(prompt, system_prompt)
|
|
91
|
+
if i > 0:
|
|
92
|
+
logger.info(
|
|
93
|
+
f"Failover stream via {client.__class__.__name__} ({client.model})."
|
|
94
|
+
)
|
|
95
|
+
return usage
|
|
96
|
+
except LLMCallError as e:
|
|
97
|
+
last_error = e
|
|
98
|
+
if not e.retryable:
|
|
99
|
+
raise
|
|
100
|
+
logger.warning(
|
|
101
|
+
f"Provider {client.__class__.__name__} stream failed ({e}); "
|
|
102
|
+
"trying next provider..."
|
|
103
|
+
)
|
|
104
|
+
raise last_error or LLMCallError("All LLM providers failed", retryable=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _create_single_client(config: dict) -> BaseLLMClient:
|
|
108
|
+
provider = get_setting(config, "llm", "provider")
|
|
109
|
+
if provider == "openrouter":
|
|
110
|
+
return OpenRouterLLMClient(config)
|
|
111
|
+
elif provider == "anthropic":
|
|
112
|
+
return AnthropicLLMClient(config)
|
|
113
|
+
else:
|
|
114
|
+
raise ValueError(f"Unsupported LLM provider: {provider}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_llm_client(config: dict) -> BaseLLMClient:
|
|
118
|
+
"""Factory: returns a failover-wrapped client when failover config exists."""
|
|
119
|
+
if get_setting(config, "llm", "failover"):
|
|
120
|
+
return FailoverLLMClient(config)
|
|
121
|
+
return _create_single_client(config)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"APPLY_EDITS_TOOL",
|
|
126
|
+
"CACHE_BREAKPOINT",
|
|
127
|
+
"AnthropicLLMClient",
|
|
128
|
+
"BaseLLMClient",
|
|
129
|
+
"BudgetExceededError",
|
|
130
|
+
"FailoverLLMClient",
|
|
131
|
+
"LLMCallError",
|
|
132
|
+
"LLMResponse",
|
|
133
|
+
"LLMUsage",
|
|
134
|
+
"LocalEmbeddingClient",
|
|
135
|
+
"OpenRouterEmbeddingClient",
|
|
136
|
+
"OpenRouterLLMClient",
|
|
137
|
+
"RETRYABLE_ERROR_MARKERS",
|
|
138
|
+
"RETRYABLE_EXCEPTION_NAMES",
|
|
139
|
+
"RETRYABLE_STATUS_CODES",
|
|
140
|
+
"_api_error",
|
|
141
|
+
"_create_local_embedding_client",
|
|
142
|
+
"_create_openrouter_embedding_client",
|
|
143
|
+
"_create_single_client",
|
|
144
|
+
"_deny_unless_training_allowed",
|
|
145
|
+
"_edits_to_markdown",
|
|
146
|
+
"_error_status_code",
|
|
147
|
+
"_is_retryable_error",
|
|
148
|
+
"_openrouter_sdk",
|
|
149
|
+
"code_gen_abort_check",
|
|
150
|
+
"create_embedding_client",
|
|
151
|
+
"create_llm_client",
|
|
152
|
+
]
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from misterdev.config import get_setting
|
|
8
|
+
from misterdev.logging_setup import setup_logger
|
|
9
|
+
|
|
10
|
+
from .errors import BudgetExceededError, LLMCallError
|
|
11
|
+
from .response import LLMResponse, LLMUsage
|
|
12
|
+
|
|
13
|
+
logger = setup_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseLLMClient(ABC):
|
|
17
|
+
"""Abstract LLM client with token tracking and budget enforcement."""
|
|
18
|
+
|
|
19
|
+
# Fraction of budget-remaining a single task may spend when its per-task
|
|
20
|
+
# cap is set to "auto".
|
|
21
|
+
AUTO_TASK_CAP_FRACTION = 0.5
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: dict):
|
|
24
|
+
self.config = config
|
|
25
|
+
self.cumulative_usage = LLMUsage()
|
|
26
|
+
# Guards the usage accumulators: one client instance is hit by several
|
|
27
|
+
# worker threads at once (parallel analyzers, executor waves), so the
|
|
28
|
+
# read-modify-write of cost/token counters must be serialized or
|
|
29
|
+
# concurrent calls lose updates and under-count spend.
|
|
30
|
+
self._usage_lock = threading.Lock()
|
|
31
|
+
# Per-task ROUTING/ATTRIBUTION state (active model, current task, reasoning
|
|
32
|
+
# effort, edit-tool mode) is thread-local: parallel executor workers share
|
|
33
|
+
# ONE client instance (see agent._WorktreeProjectView), so a plain instance
|
|
34
|
+
# attribute would let one worker's with_model()/track_task() clobber
|
|
35
|
+
# another's mid-call — issuing a request to the wrong model or attributing
|
|
36
|
+
# its cost to the wrong task. The shared accounting below stays shared.
|
|
37
|
+
self._tls = threading.local()
|
|
38
|
+
self._default_model: Optional[str] = None
|
|
39
|
+
self.cost_by_task: Dict[str, float] = {}
|
|
40
|
+
self._budget = get_setting(config, "build", "budget")
|
|
41
|
+
# Optional per-task cost ceiling. None disables it; a number is an
|
|
42
|
+
# absolute cap; "auto" makes it a fraction of the budget remaining when
|
|
43
|
+
# the task starts (snapshotted in track_task), so it shrinks as the run
|
|
44
|
+
# spends and no single task can drain the global budget.
|
|
45
|
+
self._max_cost_per_task = get_setting(
|
|
46
|
+
config, "orchestrator", "max_cost_per_task"
|
|
47
|
+
)
|
|
48
|
+
self._task_caps: Dict[str, Optional[float]] = {}
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def model(self) -> Optional[str]:
|
|
52
|
+
"""Active model: a thread-local ``with_model`` override, else the
|
|
53
|
+
configured default (set once at construction, shared across threads).
|
|
54
|
+
|
|
55
|
+
Tolerates a partially-constructed instance (``_tls``/``_default_model``
|
|
56
|
+
absent) so unit tests that build a client via ``__new__`` to exercise a
|
|
57
|
+
pure method still read ``model``."""
|
|
58
|
+
override = getattr(getattr(self, "_tls", None), "model", None)
|
|
59
|
+
return override or getattr(self, "_default_model", None)
|
|
60
|
+
|
|
61
|
+
@model.setter
|
|
62
|
+
def model(self, value: Optional[str]) -> None:
|
|
63
|
+
# Construction sets the shared default; per-call overrides go through
|
|
64
|
+
# with_model() (thread-local), never this setter.
|
|
65
|
+
self._default_model = value
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def _current_task(self) -> Optional[str]:
|
|
69
|
+
return getattr(self._tls, "current_task", None)
|
|
70
|
+
|
|
71
|
+
@_current_task.setter
|
|
72
|
+
def _current_task(self, value: Optional[str]) -> None:
|
|
73
|
+
self._tls.current_task = value
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def _reasoning_effort(self) -> Optional[str]:
|
|
77
|
+
return getattr(self._tls, "reasoning_effort", None)
|
|
78
|
+
|
|
79
|
+
@_reasoning_effort.setter
|
|
80
|
+
def _reasoning_effort(self, value: Optional[str]) -> None:
|
|
81
|
+
self._tls.reasoning_effort = value
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def _edit_tool_mode(self) -> bool:
|
|
85
|
+
return getattr(self._tls, "edit_tool_mode", False)
|
|
86
|
+
|
|
87
|
+
@_edit_tool_mode.setter
|
|
88
|
+
def _edit_tool_mode(self, value: bool) -> None:
|
|
89
|
+
self._tls.edit_tool_mode = value
|
|
90
|
+
|
|
91
|
+
def generate(self, prompt: str, system_prompt: str = "") -> LLMResponse:
|
|
92
|
+
"""Generate a response with retry and budget enforcement.
|
|
93
|
+
|
|
94
|
+
This is the primary public interface. Subclasses implement _call().
|
|
95
|
+
"""
|
|
96
|
+
self._enforce_budget()
|
|
97
|
+
|
|
98
|
+
# Free models (`:free`) are frequently rate-limited upstream and slow to
|
|
99
|
+
# even return the 429. Since routed calls fall back to the reliable paid
|
|
100
|
+
# model on failure, a free model should FAIL FAST (one shot) rather than
|
|
101
|
+
# burn ~minutes on slow retries before that fallback kicks in. Paid models
|
|
102
|
+
# keep full retry resilience for genuine transient errors.
|
|
103
|
+
is_free = ":free" in (getattr(self, "model", "") or "")
|
|
104
|
+
max_retries = 1 if is_free else 3
|
|
105
|
+
base_delay = 1.0
|
|
106
|
+
|
|
107
|
+
last_error = None
|
|
108
|
+
for attempt in range(max_retries):
|
|
109
|
+
try:
|
|
110
|
+
response = self._call(prompt, system_prompt)
|
|
111
|
+
self._track_usage(response.usage)
|
|
112
|
+
return response
|
|
113
|
+
except LLMCallError as e:
|
|
114
|
+
last_error = e
|
|
115
|
+
if not e.retryable or attempt == max_retries - 1:
|
|
116
|
+
raise
|
|
117
|
+
delay = base_delay * (2**attempt)
|
|
118
|
+
logger.warning(
|
|
119
|
+
f"LLM call failed (attempt {attempt + 1}/{max_retries}), "
|
|
120
|
+
f"retrying in {delay:.1f}s: {e}"
|
|
121
|
+
)
|
|
122
|
+
time.sleep(delay)
|
|
123
|
+
|
|
124
|
+
raise last_error
|
|
125
|
+
|
|
126
|
+
def generate_code(self, prompt: str, system_prompt: str = "") -> str:
|
|
127
|
+
"""Convenience wrapper returning just the text content.
|
|
128
|
+
|
|
129
|
+
Maintains backward compatibility with existing callers.
|
|
130
|
+
"""
|
|
131
|
+
return self.generate(prompt, system_prompt).content
|
|
132
|
+
|
|
133
|
+
def generate_edits(self, prompt: str, system_prompt: str = "") -> LLMResponse:
|
|
134
|
+
"""Generate file edits, preferring a structured tool call when supported.
|
|
135
|
+
|
|
136
|
+
The base implementation just calls :meth:`generate` (markdown edits);
|
|
137
|
+
clients that support function-calling override to force the apply_edits
|
|
138
|
+
tool and return content rendered into the canonical fence format.
|
|
139
|
+
"""
|
|
140
|
+
return self.generate(prompt, system_prompt)
|
|
141
|
+
|
|
142
|
+
def chat_multimodal(
|
|
143
|
+
self, prompt: str, image_b64: str, model: Optional[str] = None
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Send a text+image message and return the model's text reply.
|
|
146
|
+
|
|
147
|
+
First-class multimodal entry point used by the vision gate. The base
|
|
148
|
+
raises so providers without a multimodal path degrade cleanly (the
|
|
149
|
+
vision gate then falls back / SKIPs rather than crashing).
|
|
150
|
+
"""
|
|
151
|
+
raise NotImplementedError("multimodal chat not supported by this client")
|
|
152
|
+
|
|
153
|
+
def health_check(self) -> Tuple[bool, str]:
|
|
154
|
+
"""Verify the configured model actually resolves before a real run.
|
|
155
|
+
|
|
156
|
+
A retired/misrouted model id (e.g. an OpenRouter 404) otherwise only
|
|
157
|
+
surfaces on the first analysis call, after preflight has already
|
|
158
|
+
spent setup time. This makes one minimal request and reports failure
|
|
159
|
+
with the model id so the build can abort early with a clear message.
|
|
160
|
+
"""
|
|
161
|
+
model = getattr(self, "model", "<unknown>")
|
|
162
|
+
prior = self.cumulative_usage.estimated_cost
|
|
163
|
+
try:
|
|
164
|
+
self.generate("ping", "Reply with the single word OK.")
|
|
165
|
+
return True, model
|
|
166
|
+
except Exception as e:
|
|
167
|
+
return False, f"model {model!r} unavailable: {e}"
|
|
168
|
+
finally:
|
|
169
|
+
self.cumulative_usage.estimated_cost = prior
|
|
170
|
+
|
|
171
|
+
@contextmanager
|
|
172
|
+
def with_model(self, model: str):
|
|
173
|
+
"""Temporarily override the active model for per-task routing.
|
|
174
|
+
|
|
175
|
+
The override is thread-local (a shared client is used by parallel
|
|
176
|
+
workers), so it must set/restore ``_tls.model`` directly rather than the
|
|
177
|
+
``model`` setter, which writes the shared default.
|
|
178
|
+
"""
|
|
179
|
+
had_override = hasattr(self._tls, "model")
|
|
180
|
+
previous = getattr(self._tls, "model", None)
|
|
181
|
+
self._tls.model = model
|
|
182
|
+
try:
|
|
183
|
+
yield
|
|
184
|
+
finally:
|
|
185
|
+
if had_override:
|
|
186
|
+
self._tls.model = previous
|
|
187
|
+
else:
|
|
188
|
+
try:
|
|
189
|
+
del self._tls.model
|
|
190
|
+
except AttributeError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
@contextmanager
|
|
194
|
+
def with_reasoning_effort(self, effort: Optional[str]):
|
|
195
|
+
"""Temporarily request a reasoning effort for the enclosed call(s).
|
|
196
|
+
|
|
197
|
+
Honored only by clients/models that support a reasoning budget; ignored
|
|
198
|
+
elsewhere. ``None`` is a no-op.
|
|
199
|
+
"""
|
|
200
|
+
original = getattr(self, "_reasoning_effort", None)
|
|
201
|
+
self._reasoning_effort = effort
|
|
202
|
+
try:
|
|
203
|
+
yield
|
|
204
|
+
finally:
|
|
205
|
+
self._reasoning_effort = original
|
|
206
|
+
|
|
207
|
+
def _enforce_budget(self) -> None:
|
|
208
|
+
"""Raise BudgetExceededError if the global or per-task cap is spent.
|
|
209
|
+
|
|
210
|
+
Shared by generate() and generate_stream() so both paths honor the
|
|
211
|
+
same budget gates before issuing a call.
|
|
212
|
+
"""
|
|
213
|
+
# Read the cumulative cost under the same lock that _track_usage writes
|
|
214
|
+
# it, so a parallel worker mid-update can't be observed as a torn/stale
|
|
215
|
+
# value that lets a call slip past the ceiling.
|
|
216
|
+
with self._usage_lock:
|
|
217
|
+
spent = self.cumulative_usage.estimated_cost
|
|
218
|
+
if spent >= self._budget:
|
|
219
|
+
raise BudgetExceededError(
|
|
220
|
+
f"Budget of ${self._budget:.2f} exceeded (spent ${spent:.2f})"
|
|
221
|
+
)
|
|
222
|
+
if self.task_cost_exceeded(getattr(self, "_current_task", None)):
|
|
223
|
+
task_id = getattr(self, "_current_task", None)
|
|
224
|
+
cap = self.effective_task_cap(task_id)
|
|
225
|
+
raise BudgetExceededError(
|
|
226
|
+
f"Per-task budget of ${cap:.2f} exceeded "
|
|
227
|
+
f"for task {task_id!r} "
|
|
228
|
+
f"(spent ${self.task_cost(task_id):.2f})"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@abstractmethod
|
|
232
|
+
def _call(self, prompt: str, system_prompt: str) -> LLMResponse:
|
|
233
|
+
"""Execute a single LLM API call. Subclasses implement this."""
|
|
234
|
+
|
|
235
|
+
def generate_stream(
|
|
236
|
+
self, prompt: str, system_prompt: str = "", abort_check=None
|
|
237
|
+
) -> LLMResponse:
|
|
238
|
+
"""Stream a response, aborting early if abort_check flags the output.
|
|
239
|
+
|
|
240
|
+
Returns finish_reason="aborted" with the partial content when the check
|
|
241
|
+
trips, so a caller can retry with a stricter prompt instead of waiting
|
|
242
|
+
for a full bad response. Usage is captured from the provider stream when
|
|
243
|
+
available (and estimated otherwise) so streaming honors the same budget
|
|
244
|
+
accounting as generate().
|
|
245
|
+
"""
|
|
246
|
+
self._enforce_budget()
|
|
247
|
+
|
|
248
|
+
chunks: list[str] = []
|
|
249
|
+
usage: Optional[LLMUsage] = None
|
|
250
|
+
finish_reason = "stop"
|
|
251
|
+
stream = self._call_stream(prompt, system_prompt)
|
|
252
|
+
try:
|
|
253
|
+
while True:
|
|
254
|
+
chunks.append(next(stream))
|
|
255
|
+
if abort_check is not None and abort_check("".join(chunks)):
|
|
256
|
+
logger.warning("Aborting LLM stream: bad output pattern detected")
|
|
257
|
+
stream.close()
|
|
258
|
+
finish_reason = "aborted"
|
|
259
|
+
break
|
|
260
|
+
except StopIteration as stop:
|
|
261
|
+
usage = stop.value
|
|
262
|
+
|
|
263
|
+
content = "".join(chunks)
|
|
264
|
+
if usage is None:
|
|
265
|
+
usage = self._estimate_usage(prompt, system_prompt, content)
|
|
266
|
+
self._track_usage(usage)
|
|
267
|
+
return LLMResponse(
|
|
268
|
+
content=content,
|
|
269
|
+
usage=usage,
|
|
270
|
+
model=getattr(self, "model", ""),
|
|
271
|
+
finish_reason=finish_reason,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _call_stream(self, prompt: str, system_prompt: str):
|
|
275
|
+
raise NotImplementedError("streaming not supported by this client")
|
|
276
|
+
yield # pragma: no cover - marks this a generator for callers
|
|
277
|
+
|
|
278
|
+
def _estimate_usage(
|
|
279
|
+
self, prompt: str, system_prompt: str, content: str
|
|
280
|
+
) -> LLMUsage:
|
|
281
|
+
"""Approximate usage when a stream yields no API usage data.
|
|
282
|
+
|
|
283
|
+
Used on early abort (the stream is abandoned before its usage chunk)
|
|
284
|
+
or with providers that omit usage. Tokens are estimated at ~4 chars
|
|
285
|
+
each; cost defers to the provider's _estimate_cost.
|
|
286
|
+
"""
|
|
287
|
+
prompt_tokens = (len(prompt) + len(system_prompt)) // 4
|
|
288
|
+
completion_tokens = len(content) // 4
|
|
289
|
+
return LLMUsage(
|
|
290
|
+
prompt_tokens=prompt_tokens,
|
|
291
|
+
completion_tokens=completion_tokens,
|
|
292
|
+
total_tokens=prompt_tokens + completion_tokens,
|
|
293
|
+
estimated_cost=self._estimate_cost(prompt_tokens, completion_tokens),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _estimate_cost(
|
|
297
|
+
self, prompt_tokens: int, completion_tokens: int, *args
|
|
298
|
+
) -> float:
|
|
299
|
+
"""Dollar cost for a token count. Overridden per provider."""
|
|
300
|
+
return 0.0
|
|
301
|
+
|
|
302
|
+
@contextmanager
|
|
303
|
+
def track_task(self, task_id: str):
|
|
304
|
+
"""Attribute LLM cost/calls made in this block to a task id."""
|
|
305
|
+
previous = getattr(self, "_current_task", None)
|
|
306
|
+
self._current_task = task_id
|
|
307
|
+
# Snapshot the per-task cost cap on first entry so an "auto" cap is
|
|
308
|
+
# fixed to the budget available when the task started, not recomputed
|
|
309
|
+
# (and shrinking) on every call as the task spends.
|
|
310
|
+
if task_id is not None and task_id not in self._task_caps:
|
|
311
|
+
self._task_caps[task_id] = self._resolve_task_cap()
|
|
312
|
+
try:
|
|
313
|
+
yield
|
|
314
|
+
finally:
|
|
315
|
+
self._current_task = previous
|
|
316
|
+
|
|
317
|
+
def _resolve_task_cap(self) -> Optional[float]:
|
|
318
|
+
"""Resolve the configured per-task cap to a dollar figure (or None)."""
|
|
319
|
+
raw = self._max_cost_per_task
|
|
320
|
+
if isinstance(raw, bool) or raw is None:
|
|
321
|
+
return None
|
|
322
|
+
if isinstance(raw, (int, float)):
|
|
323
|
+
return float(raw)
|
|
324
|
+
if isinstance(raw, str) and raw.strip().lower() == "auto":
|
|
325
|
+
return self.budget_remaining * self.AUTO_TASK_CAP_FRACTION
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
def effective_task_cap(self, task_id: Optional[str]) -> Optional[float]:
|
|
329
|
+
"""The dollar cap for a task, or None when uncapped.
|
|
330
|
+
|
|
331
|
+
Prefers the value snapshotted by ``track_task``; falls back to a live
|
|
332
|
+
resolve so the cap is meaningful even when queried before the task
|
|
333
|
+
block is entered.
|
|
334
|
+
"""
|
|
335
|
+
if task_id is None:
|
|
336
|
+
return None
|
|
337
|
+
if task_id in self._task_caps:
|
|
338
|
+
return self._task_caps[task_id]
|
|
339
|
+
return self._resolve_task_cap()
|
|
340
|
+
|
|
341
|
+
def _track_usage(self, usage: LLMUsage) -> None:
|
|
342
|
+
bucket = getattr(self, "_current_task", None) or "overhead"
|
|
343
|
+
with self._usage_lock:
|
|
344
|
+
self.cumulative_usage.prompt_tokens += usage.prompt_tokens
|
|
345
|
+
self.cumulative_usage.completion_tokens += usage.completion_tokens
|
|
346
|
+
self.cumulative_usage.total_tokens += usage.total_tokens
|
|
347
|
+
self.cumulative_usage.estimated_cost += usage.estimated_cost
|
|
348
|
+
self.cumulative_usage.call_count += 1
|
|
349
|
+
self.cumulative_usage.cache_creation_tokens += usage.cache_creation_tokens
|
|
350
|
+
self.cumulative_usage.cache_read_tokens += usage.cache_read_tokens
|
|
351
|
+
self.cost_by_task[bucket] = (
|
|
352
|
+
self.cost_by_task.get(bucket, 0.0) + usage.estimated_cost
|
|
353
|
+
)
|
|
354
|
+
running = self.cumulative_usage.estimated_cost
|
|
355
|
+
logger.debug(
|
|
356
|
+
"llm call model=%s bucket=%s tokens=%d/%d cost=$%.4f cumulative=$%.4f",
|
|
357
|
+
getattr(self, "model", ""),
|
|
358
|
+
bucket,
|
|
359
|
+
usage.prompt_tokens,
|
|
360
|
+
usage.completion_tokens,
|
|
361
|
+
usage.estimated_cost,
|
|
362
|
+
running,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def budget_remaining(self) -> float:
|
|
367
|
+
return max(0.0, self._budget - self.cumulative_usage.estimated_cost)
|
|
368
|
+
|
|
369
|
+
def task_cost(self, task_id: Optional[str]) -> float:
|
|
370
|
+
"""Accumulated cost attributed to a task id (0.0 if untracked)."""
|
|
371
|
+
if task_id is None:
|
|
372
|
+
return 0.0
|
|
373
|
+
return getattr(self, "cost_by_task", {}).get(task_id, 0.0)
|
|
374
|
+
|
|
375
|
+
def task_cost_exceeded(self, task_id: Optional[str]) -> bool:
|
|
376
|
+
"""True when task_id has crossed its snapshotted per-task cost cap."""
|
|
377
|
+
if task_id is None:
|
|
378
|
+
return False
|
|
379
|
+
cap = self.effective_task_cap(task_id)
|
|
380
|
+
if cap is None:
|
|
381
|
+
return False
|
|
382
|
+
return self.task_cost(task_id) >= cap
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Structured tool for edit extraction. Forcing this (when a model supports
|
|
2
|
+
# `tools`) replaces brittle markdown-fence parsing: the model returns
|
|
3
|
+
# well-formed JSON we render back into the canonical fence format the executor
|
|
4
|
+
# already consumes, so nothing downstream changes.
|
|
5
|
+
APPLY_EDITS_TOOL = {
|
|
6
|
+
"type": "function",
|
|
7
|
+
"function": {
|
|
8
|
+
"name": "apply_edits",
|
|
9
|
+
"description": (
|
|
10
|
+
"Write the complete final content of each file to create or modify "
|
|
11
|
+
"to satisfy the task."
|
|
12
|
+
),
|
|
13
|
+
"parameters": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"edits": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"items": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"path": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Project-relative file path.",
|
|
24
|
+
},
|
|
25
|
+
"content": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Full final content of the file.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
"required": ["path", "content"],
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"required": ["edits"],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _edits_to_markdown(edits: list) -> str:
|
|
41
|
+
"""Render structured edits into the canonical ```lang:path fence format.
|
|
42
|
+
|
|
43
|
+
The executor's parser keys on the ``path`` after the colon; the language
|
|
44
|
+
token is re-derived from the path downstream, so a placeholder is fine.
|
|
45
|
+
"""
|
|
46
|
+
blocks = []
|
|
47
|
+
for edit in edits:
|
|
48
|
+
if not isinstance(edit, dict):
|
|
49
|
+
continue
|
|
50
|
+
path = edit.get("path")
|
|
51
|
+
content = edit.get("content", "")
|
|
52
|
+
if path:
|
|
53
|
+
blocks.append(f"```text:{path}\n{content}\n```")
|
|
54
|
+
return "\n\n".join(blocks)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def code_gen_abort_check(accumulated: str) -> bool:
|
|
58
|
+
"""Heuristic: True when a code-gen stream is clearly going wrong.
|
|
59
|
+
|
|
60
|
+
Trips when a lot of text arrives with no code fence or file marker, or when
|
|
61
|
+
the model opens with conversational filler instead of code.
|
|
62
|
+
"""
|
|
63
|
+
if (
|
|
64
|
+
len(accumulated) > 2000
|
|
65
|
+
and "```" not in accumulated
|
|
66
|
+
and "# File:" not in accumulated
|
|
67
|
+
):
|
|
68
|
+
return True
|
|
69
|
+
head = accumulated[:200]
|
|
70
|
+
return ("I'll help you" in head) or ("Sure, here" in head)
|