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,535 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from misterdev.config import get_section_setting
|
|
6
|
+
from misterdev.logging_setup import setup_logger
|
|
7
|
+
|
|
8
|
+
from .base import BaseLLMClient
|
|
9
|
+
from .edits import APPLY_EDITS_TOOL, _edits_to_markdown
|
|
10
|
+
from .errors import _api_error
|
|
11
|
+
from .response import LLMResponse, LLMUsage
|
|
12
|
+
|
|
13
|
+
logger = setup_logger(__name__)
|
|
14
|
+
|
|
15
|
+
# Per-request network ceiling. The SDKs default to ~600s, so a hung/stalled
|
|
16
|
+
# connection can block a single attempt for ten minutes before retry/failover
|
|
17
|
+
# even kicks in. 300s bounds that while still allowing a large code generation
|
|
18
|
+
# to finish.
|
|
19
|
+
_REQUEST_TIMEOUT_SECONDS = 300.0
|
|
20
|
+
|
|
21
|
+
# Marker the executor inserts between the stable, reusable context (project
|
|
22
|
+
# structure, symbol graph, target-file code) and the volatile per-attempt tail
|
|
23
|
+
# (the specific ask, error logs). The client splits the prompt here and marks
|
|
24
|
+
# everything before it cacheable, so retries and same-wave tasks re-read that
|
|
25
|
+
# prefix at ~10% of input price instead of paying full price every call. Input
|
|
26
|
+
# is ~83% of a run's tokens, so this is the largest single cost lever.
|
|
27
|
+
CACHE_BREAKPOINT = "\x00\x00MISTERDEV_CACHE_BREAKPOINT\x00\x00"
|
|
28
|
+
|
|
29
|
+
# Anthropic prices a cache read at ~10% of the base input token rate.
|
|
30
|
+
_CACHE_READ_PRICE_FRACTION = 0.1
|
|
31
|
+
|
|
32
|
+
# The default 5-minute ephemeral cache — proven and universally supported. It
|
|
33
|
+
# covers the high-value case: a task's retries are seconds apart, so attempt 2/3
|
|
34
|
+
# re-read the stable prefix from cache. (Extended 1h TTL would also cover the
|
|
35
|
+
# cross-task gap, but it needs the extended-cache beta and can 400 where
|
|
36
|
+
# unsupported, so it is not enabled by default.)
|
|
37
|
+
_CACHE_CONTROL = {"type": "ephemeral"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _supports_prompt_cache(model: str) -> bool:
|
|
41
|
+
"""True for models with Anthropic-style ``cache_control`` breakpoints.
|
|
42
|
+
|
|
43
|
+
Gated to Claude so caching never reshapes requests for other providers
|
|
44
|
+
(where a content-parts array or an unknown field could 400).
|
|
45
|
+
"""
|
|
46
|
+
m = (model or "").lower()
|
|
47
|
+
return "claude" in m or "anthropic" in m
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cache_user_content(prompt: str, model: str):
|
|
51
|
+
"""User-message content, split at CACHE_BREAKPOINT into a cached prefix and a
|
|
52
|
+
fresh suffix. Returns the plain string (byte-identical to before) when the
|
|
53
|
+
model can't cache or no breakpoint is present, so the uncached path is
|
|
54
|
+
unchanged. A stray breakpoint is always stripped."""
|
|
55
|
+
if not prompt or CACHE_BREAKPOINT not in prompt:
|
|
56
|
+
return prompt
|
|
57
|
+
prefix, suffix = prompt.split(CACHE_BREAKPOINT, 1)
|
|
58
|
+
if not _supports_prompt_cache(model):
|
|
59
|
+
return prefix + suffix
|
|
60
|
+
parts = []
|
|
61
|
+
if prefix:
|
|
62
|
+
parts.append({"type": "text", "text": prefix, "cache_control": _CACHE_CONTROL})
|
|
63
|
+
if suffix:
|
|
64
|
+
parts.append({"type": "text", "text": suffix})
|
|
65
|
+
return parts
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cache_system_content(system_prompt: str, model: str):
|
|
69
|
+
"""System content marked cacheable (Claude only), else the plain string."""
|
|
70
|
+
if not _supports_prompt_cache(model):
|
|
71
|
+
return system_prompt
|
|
72
|
+
return [{"type": "text", "text": system_prompt, "cache_control": _CACHE_CONTROL}]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _close_stream(stream) -> None:
|
|
76
|
+
"""Close an SDK stream's underlying HTTP connection (best effort), so an
|
|
77
|
+
aborted generation doesn't leak the socket back into the connection pool."""
|
|
78
|
+
close = getattr(stream, "close", None)
|
|
79
|
+
if callable(close):
|
|
80
|
+
try:
|
|
81
|
+
close()
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _openrouter_sdk(llm_config: dict):
|
|
87
|
+
"""Build an OpenAI SDK client pointed at OpenRouter. Returns (client, api_key).
|
|
88
|
+
|
|
89
|
+
Shared by the chat and embedding clients so the base URL, env-var lookup,
|
|
90
|
+
and missing-key error live in one place.
|
|
91
|
+
"""
|
|
92
|
+
env_var = get_section_setting("llm", llm_config, "api_key_env_var")
|
|
93
|
+
api_key = os.environ.get(env_var)
|
|
94
|
+
if not api_key:
|
|
95
|
+
raise ValueError(f"API key environment variable '{env_var}' not set.")
|
|
96
|
+
from openai import OpenAI
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
OpenAI(
|
|
100
|
+
base_url="https://openrouter.ai/api/v1",
|
|
101
|
+
api_key=api_key,
|
|
102
|
+
timeout=_REQUEST_TIMEOUT_SECONDS,
|
|
103
|
+
),
|
|
104
|
+
api_key,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _deny_unless_training_allowed(llm_config: dict) -> str:
|
|
109
|
+
"""OpenRouter data_collection policy: deny unless training is opted in."""
|
|
110
|
+
if get_section_setting("llm", llm_config, "allow_training_models"):
|
|
111
|
+
return "allow"
|
|
112
|
+
return "deny"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Per-1M-token price assumed for a model absent from a provider's COST_PER_1M
|
|
116
|
+
# table (a conservative mid-tier estimate so an unknown model is never costed
|
|
117
|
+
# as free). Shared by both providers' _estimate_cost.
|
|
118
|
+
_DEFAULT_COST_PER_1M = {"input": 3.0, "output": 15.0}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class OpenRouterLLMClient(BaseLLMClient):
|
|
122
|
+
"""LLM client using OpenRouter API (OpenAI-compatible)."""
|
|
123
|
+
|
|
124
|
+
# Approximate costs per 1M tokens for common models
|
|
125
|
+
COST_PER_1M = {
|
|
126
|
+
"anthropic/claude-opus-4-8": {"input": 15.0, "output": 75.0},
|
|
127
|
+
"anthropic/claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
|
|
128
|
+
"anthropic/claude-sonnet-4.6": {"input": 3.0, "output": 15.0},
|
|
129
|
+
"anthropic/claude-haiku-4-5": {"input": 0.80, "output": 4.0},
|
|
130
|
+
"anthropic/claude-sonnet-4": {"input": 3.0, "output": 15.0},
|
|
131
|
+
"anthropic/claude-3.5-sonnet": {"input": 3.0, "output": 15.0},
|
|
132
|
+
"anthropic/claude-haiku-4": {"input": 0.80, "output": 4.0},
|
|
133
|
+
"openai/gpt-4o": {"input": 2.5, "output": 10.0},
|
|
134
|
+
"openai/gpt-4o-mini": {"input": 0.15, "output": 0.60},
|
|
135
|
+
"google/gemini-2.5-pro": {"input": 1.25, "output": 10.0},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def __init__(self, config: dict):
|
|
139
|
+
super().__init__(config)
|
|
140
|
+
llm_config = config.get("llm", {})
|
|
141
|
+
self.client, self.api_key = _openrouter_sdk(llm_config)
|
|
142
|
+
self.model = get_section_setting("llm", llm_config, "model")
|
|
143
|
+
self.temperature = get_section_setting("llm", llm_config, "temperature")
|
|
144
|
+
self.sampling = dict(get_section_setting("llm", llm_config, "sampling") or {})
|
|
145
|
+
self.data_collection = _deny_unless_training_allowed(llm_config)
|
|
146
|
+
|
|
147
|
+
from misterdev.core.economics.model_catalog import ModelCatalog
|
|
148
|
+
|
|
149
|
+
self._catalog = ModelCatalog()
|
|
150
|
+
|
|
151
|
+
def _sampling_kwargs(self) -> dict:
|
|
152
|
+
"""Sampling params for the active model, filtered by what it supports.
|
|
153
|
+
|
|
154
|
+
temperature plus any configured ``llm.sampling`` knobs (top_p, top_k,
|
|
155
|
+
min_p, repetition_penalty, ...) are emitted only when the model's
|
|
156
|
+
OpenRouter ``supported_parameters`` include them, so an unsupported knob
|
|
157
|
+
never 400s the request. Unknown model (catalog miss/offline) falls back
|
|
158
|
+
to the prior behavior: temperature only.
|
|
159
|
+
"""
|
|
160
|
+
candidates = {"temperature": self.temperature, **self.sampling}
|
|
161
|
+
profile = self._catalog.profile(self.model)
|
|
162
|
+
if profile is None:
|
|
163
|
+
return {"temperature": self.temperature}
|
|
164
|
+
return {k: v for k, v in candidates.items() if profile.supports(k)}
|
|
165
|
+
|
|
166
|
+
def _supports_tools(self) -> bool:
|
|
167
|
+
profile = self._catalog.profile(self.model)
|
|
168
|
+
return profile is not None and profile.supports("tools")
|
|
169
|
+
|
|
170
|
+
def _supports_reasoning(self) -> bool:
|
|
171
|
+
profile = self._catalog.profile(self.model)
|
|
172
|
+
return profile is not None and profile.supports_reasoning
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _extract_tool_edits(choice) -> list:
|
|
176
|
+
"""Pull the apply_edits arguments out of a tool-call response.
|
|
177
|
+
|
|
178
|
+
Tolerates malformed/empty arguments by returning [] (the executor then
|
|
179
|
+
sees no edits and retries, exactly as with an empty markdown response).
|
|
180
|
+
"""
|
|
181
|
+
edits = []
|
|
182
|
+
for call in getattr(choice.message, "tool_calls", None) or []:
|
|
183
|
+
fn = getattr(call, "function", None)
|
|
184
|
+
if fn is None or fn.name != "apply_edits":
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
args = json.loads(fn.arguments or "{}")
|
|
188
|
+
except (json.JSONDecodeError, TypeError):
|
|
189
|
+
continue
|
|
190
|
+
if isinstance(args, dict) and isinstance(args.get("edits"), list):
|
|
191
|
+
edits.extend(args["edits"])
|
|
192
|
+
return edits
|
|
193
|
+
|
|
194
|
+
def generate_edits(self, prompt: str, system_prompt: str = "") -> LLMResponse:
|
|
195
|
+
"""Force the apply_edits tool when the active model supports it.
|
|
196
|
+
|
|
197
|
+
Reuses generate()'s budget/retry/usage machinery via an internal flag;
|
|
198
|
+
falls back to plain markdown generation for models without tool support.
|
|
199
|
+
"""
|
|
200
|
+
if not self._supports_tools():
|
|
201
|
+
return self.generate(prompt, system_prompt)
|
|
202
|
+
self._edit_tool_mode = True
|
|
203
|
+
try:
|
|
204
|
+
return self.generate(prompt, system_prompt)
|
|
205
|
+
finally:
|
|
206
|
+
self._edit_tool_mode = False
|
|
207
|
+
|
|
208
|
+
def chat_multimodal(
|
|
209
|
+
self, prompt: str, image_b64: str, model: Optional[str] = None
|
|
210
|
+
) -> str:
|
|
211
|
+
"""Send a text part plus a base64 PNG image and return the reply text.
|
|
212
|
+
|
|
213
|
+
Builds an OpenAI-compatible multimodal message and selects ``model`` (a
|
|
214
|
+
vision model id) when given, else the client's configured model. Provider
|
|
215
|
+
routing prefs are reused so a multimodal call honors the same
|
|
216
|
+
data_collection policy as every other request.
|
|
217
|
+
"""
|
|
218
|
+
# Honor the budget gate and record spend just like generate()/_call() —
|
|
219
|
+
# otherwise every vision call is invisible to the budget accounting and
|
|
220
|
+
# cost_by_task, letting the cap be silently overrun.
|
|
221
|
+
self._enforce_budget()
|
|
222
|
+
try:
|
|
223
|
+
resp = self.client.chat.completions.create(
|
|
224
|
+
model=model or self.model,
|
|
225
|
+
messages=[
|
|
226
|
+
{
|
|
227
|
+
"role": "user",
|
|
228
|
+
"content": [
|
|
229
|
+
{"type": "text", "text": prompt},
|
|
230
|
+
{
|
|
231
|
+
"type": "image_url",
|
|
232
|
+
"image_url": {
|
|
233
|
+
"url": f"data:image/png;base64,{image_b64}"
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
}
|
|
238
|
+
],
|
|
239
|
+
extra_body=self._extra_body(),
|
|
240
|
+
)
|
|
241
|
+
usage_data = getattr(resp, "usage", None)
|
|
242
|
+
if usage_data:
|
|
243
|
+
self._track_usage(self._usage_from(usage_data))
|
|
244
|
+
return resp.choices[0].message.content or ""
|
|
245
|
+
except Exception as e:
|
|
246
|
+
raise _api_error("OpenRouter", e) from e
|
|
247
|
+
|
|
248
|
+
def _extra_body(self) -> dict:
|
|
249
|
+
"""OpenRouter provider routing prefs.
|
|
250
|
+
|
|
251
|
+
``data_collection="deny"`` confines routing to providers that do not
|
|
252
|
+
store or train on inputs — enforced server-side for every call, which is
|
|
253
|
+
what makes harvesting free models safe. A free model with no compliant
|
|
254
|
+
provider simply errors and the executor escalates to the next tier.
|
|
255
|
+
|
|
256
|
+
Also carries a reasoning-effort budget when one is requested for the
|
|
257
|
+
call and the active model supports it.
|
|
258
|
+
"""
|
|
259
|
+
body = {"provider": {"data_collection": self.data_collection}}
|
|
260
|
+
effort = getattr(self, "_reasoning_effort", None)
|
|
261
|
+
if effort and self._supports_reasoning():
|
|
262
|
+
body["reasoning"] = {"effort": effort}
|
|
263
|
+
return body
|
|
264
|
+
|
|
265
|
+
def _build_messages(self, prompt: str, system_prompt: str) -> list:
|
|
266
|
+
messages = []
|
|
267
|
+
if system_prompt:
|
|
268
|
+
messages.append(
|
|
269
|
+
{
|
|
270
|
+
"role": "system",
|
|
271
|
+
"content": _cache_system_content(system_prompt, self.model),
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
messages.append(
|
|
275
|
+
{"role": "user", "content": _cache_user_content(prompt, self.model)}
|
|
276
|
+
)
|
|
277
|
+
return messages
|
|
278
|
+
|
|
279
|
+
def _usage_from(self, usage_data) -> LLMUsage:
|
|
280
|
+
"""Build an LLMUsage from an OpenAI-compatible usage object (empty when
|
|
281
|
+
the provider omitted usage).
|
|
282
|
+
|
|
283
|
+
When the provider reports cached prompt tokens (prompt caching hit),
|
|
284
|
+
price them at 10% of the input rate so the budget/ledger reflect the real
|
|
285
|
+
discount instead of charging cache reads at full price.
|
|
286
|
+
"""
|
|
287
|
+
usage = LLMUsage()
|
|
288
|
+
if usage_data:
|
|
289
|
+
usage.prompt_tokens = usage_data.prompt_tokens or 0
|
|
290
|
+
usage.completion_tokens = usage_data.completion_tokens or 0
|
|
291
|
+
usage.total_tokens = usage.prompt_tokens + usage.completion_tokens
|
|
292
|
+
details = getattr(usage_data, "prompt_tokens_details", None)
|
|
293
|
+
cached = getattr(details, "cached_tokens", 0) or 0 if details else 0
|
|
294
|
+
usage.cache_read_tokens = cached
|
|
295
|
+
fresh_input = max(0, usage.prompt_tokens - cached)
|
|
296
|
+
usage.estimated_cost = (
|
|
297
|
+
self._estimate_cost(fresh_input, usage.completion_tokens)
|
|
298
|
+
+ self._estimate_cost(cached, 0) * _CACHE_READ_PRICE_FRACTION
|
|
299
|
+
)
|
|
300
|
+
return usage
|
|
301
|
+
|
|
302
|
+
def _call(self, prompt: str, system_prompt: str) -> LLMResponse:
|
|
303
|
+
logger.info(f"Calling OpenRouter model: {self.model}")
|
|
304
|
+
try:
|
|
305
|
+
messages = self._build_messages(prompt, system_prompt)
|
|
306
|
+
|
|
307
|
+
tool_kwargs = {}
|
|
308
|
+
if getattr(self, "_edit_tool_mode", False):
|
|
309
|
+
tool_kwargs = {
|
|
310
|
+
"tools": [APPLY_EDITS_TOOL],
|
|
311
|
+
"tool_choice": {
|
|
312
|
+
"type": "function",
|
|
313
|
+
"function": {"name": "apply_edits"},
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
response = self.client.chat.completions.create(
|
|
318
|
+
model=self.model,
|
|
319
|
+
messages=messages,
|
|
320
|
+
extra_body=self._extra_body(),
|
|
321
|
+
**self._sampling_kwargs(),
|
|
322
|
+
**tool_kwargs,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
choice = response.choices[0]
|
|
326
|
+
usage = self._usage_from(response.usage)
|
|
327
|
+
|
|
328
|
+
content = choice.message.content or ""
|
|
329
|
+
if tool_kwargs:
|
|
330
|
+
# Render the structured tool call back into the canonical fence
|
|
331
|
+
# format the executor's parser consumes.
|
|
332
|
+
content = _edits_to_markdown(self._extract_tool_edits(choice))
|
|
333
|
+
|
|
334
|
+
return LLMResponse(
|
|
335
|
+
content=content,
|
|
336
|
+
usage=usage,
|
|
337
|
+
model=self.model,
|
|
338
|
+
finish_reason=choice.finish_reason or "",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
except Exception as e:
|
|
342
|
+
raise _api_error("OpenRouter", e) from e
|
|
343
|
+
|
|
344
|
+
def _call_stream(self, prompt: str, system_prompt: str):
|
|
345
|
+
messages = self._build_messages(prompt, system_prompt)
|
|
346
|
+
try:
|
|
347
|
+
stream = self.client.chat.completions.create(
|
|
348
|
+
model=self.model,
|
|
349
|
+
messages=messages,
|
|
350
|
+
stream=True,
|
|
351
|
+
stream_options={"include_usage": True},
|
|
352
|
+
extra_body=self._extra_body(),
|
|
353
|
+
**self._sampling_kwargs(),
|
|
354
|
+
)
|
|
355
|
+
except Exception as e:
|
|
356
|
+
raise _api_error("OpenRouter", e) from e
|
|
357
|
+
|
|
358
|
+
usage = LLMUsage()
|
|
359
|
+
try:
|
|
360
|
+
for chunk in stream:
|
|
361
|
+
if chunk.choices:
|
|
362
|
+
delta = chunk.choices[0].delta.content
|
|
363
|
+
if delta:
|
|
364
|
+
yield delta
|
|
365
|
+
usage_data = getattr(chunk, "usage", None)
|
|
366
|
+
if usage_data:
|
|
367
|
+
usage = self._usage_from(usage_data)
|
|
368
|
+
except Exception as e:
|
|
369
|
+
# A drop DURING streaming was previously raised raw, bypassing the
|
|
370
|
+
# LLMCallError classification that retry/failover keys on.
|
|
371
|
+
raise _api_error("OpenRouter", e) from e
|
|
372
|
+
finally:
|
|
373
|
+
# Runs on normal completion, on a mid-stream error, AND on the
|
|
374
|
+
# GeneratorExit raised when the caller aborts (code_gen_abort_check),
|
|
375
|
+
# so the HTTP connection is always released.
|
|
376
|
+
_close_stream(stream)
|
|
377
|
+
return usage
|
|
378
|
+
|
|
379
|
+
def _estimate_cost(self, prompt_tokens: int, completion_tokens: int) -> float:
|
|
380
|
+
# OpenRouter ":free" model ids are zero-cost. Without this, an unknown
|
|
381
|
+
# (uncatalogued) free model falls through to the paid default below,
|
|
382
|
+
# inflating budget accounting and making the cost-aware selector rank the
|
|
383
|
+
# free models it exists to favor as the most expensive.
|
|
384
|
+
if ":free" in self.model:
|
|
385
|
+
return 0.0
|
|
386
|
+
costs = self.COST_PER_1M.get(self.model, _DEFAULT_COST_PER_1M)
|
|
387
|
+
return (prompt_tokens / 1_000_000) * costs["input"] + (
|
|
388
|
+
completion_tokens / 1_000_000
|
|
389
|
+
) * costs["output"]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class AnthropicLLMClient(BaseLLMClient):
|
|
393
|
+
"""LLM client using the Anthropic API directly."""
|
|
394
|
+
|
|
395
|
+
COST_PER_1M = {
|
|
396
|
+
"claude-opus-4-8": {"input": 15.0, "output": 75.0},
|
|
397
|
+
"claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
|
|
398
|
+
"claude-haiku-4-5": {"input": 0.80, "output": 4.0},
|
|
399
|
+
"claude-sonnet-4-20250514": {"input": 3.0, "output": 15.0},
|
|
400
|
+
"claude-haiku-4-20250414": {"input": 0.80, "output": 4.0},
|
|
401
|
+
"claude-opus-4-20250514": {"input": 15.0, "output": 75.0},
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
def __init__(self, config: dict):
|
|
405
|
+
super().__init__(config)
|
|
406
|
+
llm_config = config.get("llm", {})
|
|
407
|
+
|
|
408
|
+
env_var_name = get_section_setting("llm", llm_config, "api_key_env_var")
|
|
409
|
+
self.api_key = os.environ.get(env_var_name)
|
|
410
|
+
if not self.api_key:
|
|
411
|
+
raise ValueError(f"API key environment variable '{env_var_name}' not set.")
|
|
412
|
+
|
|
413
|
+
self.model = get_section_setting("llm", llm_config, "model")
|
|
414
|
+
self.temperature = get_section_setting("llm", llm_config, "temperature")
|
|
415
|
+
self.max_tokens = llm_config.get("max_tokens", 8192)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
import anthropic
|
|
419
|
+
|
|
420
|
+
self.client = anthropic.Anthropic(
|
|
421
|
+
api_key=self.api_key, timeout=_REQUEST_TIMEOUT_SECONDS
|
|
422
|
+
)
|
|
423
|
+
except ImportError:
|
|
424
|
+
raise ImportError(
|
|
425
|
+
"anthropic package required for Anthropic provider. "
|
|
426
|
+
"Install with: pip install anthropic"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def _usage_from(self, usage_data) -> LLMUsage:
|
|
430
|
+
"""Build an LLMUsage from an Anthropic usage object, including cache
|
|
431
|
+
creation/read token accounting."""
|
|
432
|
+
cache_creation = getattr(usage_data, "cache_creation_input_tokens", 0) or 0
|
|
433
|
+
cache_read = getattr(usage_data, "cache_read_input_tokens", 0) or 0
|
|
434
|
+
# Coalesce None to 0 like the OpenRouter sibling: a partial/aborted final
|
|
435
|
+
# message can report null token counts, and the arithmetic below would
|
|
436
|
+
# otherwise raise TypeError.
|
|
437
|
+
input_tokens = usage_data.input_tokens or 0
|
|
438
|
+
output_tokens = usage_data.output_tokens or 0
|
|
439
|
+
return LLMUsage(
|
|
440
|
+
prompt_tokens=input_tokens,
|
|
441
|
+
completion_tokens=output_tokens,
|
|
442
|
+
total_tokens=input_tokens + output_tokens,
|
|
443
|
+
cache_creation_tokens=cache_creation,
|
|
444
|
+
cache_read_tokens=cache_read,
|
|
445
|
+
estimated_cost=self._estimate_cost(
|
|
446
|
+
input_tokens,
|
|
447
|
+
output_tokens,
|
|
448
|
+
cache_creation,
|
|
449
|
+
cache_read,
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
def _call(self, prompt: str, system_prompt: str) -> LLMResponse:
|
|
454
|
+
logger.info(f"Calling Anthropic model: {self.model}")
|
|
455
|
+
try:
|
|
456
|
+
kwargs = {
|
|
457
|
+
"model": self.model,
|
|
458
|
+
"max_tokens": self.max_tokens,
|
|
459
|
+
"messages": [
|
|
460
|
+
{"role": "user", "content": _cache_user_content(prompt, self.model)}
|
|
461
|
+
],
|
|
462
|
+
"temperature": self.temperature,
|
|
463
|
+
}
|
|
464
|
+
if system_prompt:
|
|
465
|
+
# Mark the system prompt cacheable: tasks in a wave share it,
|
|
466
|
+
# so subsequent calls read it from cache at ~10% of input cost.
|
|
467
|
+
kwargs["system"] = _cache_system_content(system_prompt, self.model)
|
|
468
|
+
|
|
469
|
+
response = self.client.messages.create(**kwargs)
|
|
470
|
+
|
|
471
|
+
content = ""
|
|
472
|
+
for block in response.content:
|
|
473
|
+
if block.type == "text":
|
|
474
|
+
content += block.text
|
|
475
|
+
|
|
476
|
+
usage = self._usage_from(response.usage)
|
|
477
|
+
|
|
478
|
+
return LLMResponse(
|
|
479
|
+
content=content,
|
|
480
|
+
usage=usage,
|
|
481
|
+
model=self.model,
|
|
482
|
+
finish_reason=response.stop_reason or "",
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
except Exception as e:
|
|
486
|
+
raise _api_error("Anthropic", e) from e
|
|
487
|
+
|
|
488
|
+
def _call_stream(self, prompt: str, system_prompt: str):
|
|
489
|
+
kwargs = {
|
|
490
|
+
"model": self.model,
|
|
491
|
+
"max_tokens": self.max_tokens,
|
|
492
|
+
"messages": [
|
|
493
|
+
{"role": "user", "content": _cache_user_content(prompt, self.model)}
|
|
494
|
+
],
|
|
495
|
+
"temperature": self.temperature,
|
|
496
|
+
}
|
|
497
|
+
if system_prompt:
|
|
498
|
+
kwargs["system"] = _cache_system_content(system_prompt, self.model)
|
|
499
|
+
try:
|
|
500
|
+
stream_cm = self.client.messages.stream(**kwargs)
|
|
501
|
+
except Exception as e:
|
|
502
|
+
raise _api_error("Anthropic", e) from e
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
with stream_cm as stream:
|
|
506
|
+
for text in stream.text_stream:
|
|
507
|
+
if text:
|
|
508
|
+
yield text
|
|
509
|
+
final = stream.get_final_message()
|
|
510
|
+
except Exception as e:
|
|
511
|
+
# A drop DURING streaming (or in get_final_message) was previously
|
|
512
|
+
# raised raw, bypassing the LLMCallError classification that
|
|
513
|
+
# retry/failover keys on — only the stream() call above was wrapped.
|
|
514
|
+
# GeneratorExit (caller abort) is a BaseException, so it is not caught
|
|
515
|
+
# here and still propagates while the with-block closes the stream.
|
|
516
|
+
raise _api_error("Anthropic", e) from e
|
|
517
|
+
|
|
518
|
+
return self._usage_from(final.usage)
|
|
519
|
+
|
|
520
|
+
def _estimate_cost(
|
|
521
|
+
self,
|
|
522
|
+
input_tokens: int,
|
|
523
|
+
output_tokens: int,
|
|
524
|
+
cache_creation: int = 0,
|
|
525
|
+
cache_read: int = 0,
|
|
526
|
+
) -> float:
|
|
527
|
+
costs = self.COST_PER_1M.get(self.model, _DEFAULT_COST_PER_1M)
|
|
528
|
+
inp = costs["input"]
|
|
529
|
+
# Anthropic pricing: cache reads ~10% of input, cache writes ~25% more.
|
|
530
|
+
return (
|
|
531
|
+
(input_tokens / 1_000_000) * inp
|
|
532
|
+
+ (cache_read / 1_000_000) * inp * 0.1
|
|
533
|
+
+ (cache_creation / 1_000_000) * inp * 1.25
|
|
534
|
+
+ (output_tokens / 1_000_000) * costs["output"]
|
|
535
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class LLMUsage:
|
|
6
|
+
"""Token usage tracking for budget enforcement."""
|
|
7
|
+
|
|
8
|
+
prompt_tokens: int = 0
|
|
9
|
+
completion_tokens: int = 0
|
|
10
|
+
total_tokens: int = 0
|
|
11
|
+
estimated_cost: float = 0.0
|
|
12
|
+
call_count: int = 0
|
|
13
|
+
cache_creation_tokens: int = 0
|
|
14
|
+
cache_read_tokens: int = 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class LLMResponse:
|
|
19
|
+
"""Structured response from an LLM call."""
|
|
20
|
+
|
|
21
|
+
content: str
|
|
22
|
+
usage: LLMUsage = field(default_factory=LLMUsage)
|
|
23
|
+
model: str = ""
|
|
24
|
+
finish_reason: str = ""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
|
|
3
|
+
from misterdev.logging_setup import setup_logger
|
|
4
|
+
|
|
5
|
+
logger = setup_logger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PromptManager:
|
|
9
|
+
"""Manages prompt template formatting with safe variable substitution.
|
|
10
|
+
|
|
11
|
+
Uses {{variable}} syntax instead of Python's {variable} to avoid
|
|
12
|
+
conflicts with code content, JSON, and LLM output that contain braces.
|
|
13
|
+
Falls back to {variable} syntax for backward compatibility with
|
|
14
|
+
existing project.yaml templates.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, config: dict):
|
|
18
|
+
self.config = config
|
|
19
|
+
self.templates = config.get("prompt_templates", {})
|
|
20
|
+
|
|
21
|
+
def format_prompt(self, template_key: str, context_dict: Dict[str, Any]) -> str:
|
|
22
|
+
template = self.templates.get(template_key)
|
|
23
|
+
if not template:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"Prompt template '{template_key}' not found in configuration."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Inject inherited system prompt if not already in context
|
|
29
|
+
if "inherited_system_prompt" not in context_dict and template_key != "system":
|
|
30
|
+
context_dict["inherited_system_prompt"] = self.templates.get("system", "")
|
|
31
|
+
|
|
32
|
+
# Convert all context values to strings
|
|
33
|
+
str_context = {
|
|
34
|
+
k: str(v) if v is not None else "" for k, v in context_dict.items()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# First pass: replace {{var}} syntax (preferred, safe)
|
|
38
|
+
result = template
|
|
39
|
+
for key, value in str_context.items():
|
|
40
|
+
result = result.replace("{{" + key + "}}", value)
|
|
41
|
+
|
|
42
|
+
# Second pass: replace {var} syntax for backward compatibility.
|
|
43
|
+
# Only replace known variables to avoid breaking code content.
|
|
44
|
+
result = _safe_format(result, str_context)
|
|
45
|
+
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _safe_format(template: str, context: Dict[str, str]) -> str:
|
|
50
|
+
"""Replace {var} placeholders without breaking unrelated braces.
|
|
51
|
+
|
|
52
|
+
Scans character-by-character for {identifier} patterns where identifier
|
|
53
|
+
is a known context key. Leaves all other brace patterns untouched.
|
|
54
|
+
"""
|
|
55
|
+
result = []
|
|
56
|
+
i = 0
|
|
57
|
+
length = len(template)
|
|
58
|
+
|
|
59
|
+
while i < length:
|
|
60
|
+
if template[i] != "{":
|
|
61
|
+
result.append(template[i])
|
|
62
|
+
i += 1
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# Found '{', try to read an identifier (word chars and dots)
|
|
66
|
+
j = i + 1
|
|
67
|
+
while j < length and (template[j].isalnum() or template[j] in ("_", ".")):
|
|
68
|
+
j += 1
|
|
69
|
+
|
|
70
|
+
# Check if it closes with '}'
|
|
71
|
+
if j < length and template[j] == "}" and j > i + 1:
|
|
72
|
+
key = template[i + 1 : j]
|
|
73
|
+
if key in context:
|
|
74
|
+
result.append(context[key])
|
|
75
|
+
i = j + 1
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Not a known variable; emit the '{' literally
|
|
79
|
+
result.append(template[i])
|
|
80
|
+
i += 1
|
|
81
|
+
|
|
82
|
+
return "".join(result)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Parse LLM output into file edits.
|
|
2
|
+
|
|
3
|
+
Two edit shapes are supported: anchored SEARCH/REPLACE hunks (the surgical
|
|
4
|
+
default — the model emits only changed regions, applied against the on-disk file
|
|
5
|
+
so large files are never truncated) and whole-file fenced blocks (fallback for
|
|
6
|
+
new/short files). apply_search_replace() matches exactly, then tolerates
|
|
7
|
+
trailing-whitespace/CRLF drift, then wrong indentation (re-indenting the
|
|
8
|
+
replacement), always requiring a single unique match so a partial file is never
|
|
9
|
+
written.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .json_extract import (
|
|
13
|
+
extract_json_array,
|
|
14
|
+
extract_balanced_span,
|
|
15
|
+
extract_json_object,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
FileEdit,
|
|
19
|
+
SearchReplaceEdit,
|
|
20
|
+
EditConflictError,
|
|
21
|
+
)
|
|
22
|
+
from .parsing import LLMResponseParser
|
|
23
|
+
from .apply import apply_search_replace
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"extract_json_array",
|
|
27
|
+
"extract_balanced_span",
|
|
28
|
+
"extract_json_object",
|
|
29
|
+
"FileEdit",
|
|
30
|
+
"SearchReplaceEdit",
|
|
31
|
+
"EditConflictError",
|
|
32
|
+
"LLMResponseParser",
|
|
33
|
+
"apply_search_replace",
|
|
34
|
+
]
|