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.
Files changed (136) hide show
  1. misterdev/__init__.py +3 -0
  2. misterdev/agent.py +2166 -0
  3. misterdev/agent_helpers.py +194 -0
  4. misterdev/analyzers/__init__.py +0 -0
  5. misterdev/analyzers/project_analyzer/__init__.py +246 -0
  6. misterdev/analyzers/project_analyzer/detection.py +146 -0
  7. misterdev/analyzers/project_analyzer/merge.py +137 -0
  8. misterdev/analyzers/project_analyzer/overview.py +312 -0
  9. misterdev/analyzers/project_analyzer/prompts.py +83 -0
  10. misterdev/cli.py +370 -0
  11. misterdev/config.py +521 -0
  12. misterdev/core/__init__.py +0 -0
  13. misterdev/core/audit.py +88 -0
  14. misterdev/core/config.py +10 -0
  15. misterdev/core/context/__init__.py +0 -0
  16. misterdev/core/context/change_tracker.py +218 -0
  17. misterdev/core/context/contracts/__init__.py +63 -0
  18. misterdev/core/context/contracts/_log.py +9 -0
  19. misterdev/core/context/contracts/_text.py +12 -0
  20. misterdev/core/context/contracts/extraction.py +30 -0
  21. misterdev/core/context/contracts/python_generic.py +42 -0
  22. misterdev/core/context/contracts/registry.py +127 -0
  23. misterdev/core/context/contracts/rust_line.py +225 -0
  24. misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
  25. misterdev/core/context/lsp.py +174 -0
  26. misterdev/core/context/scratchpad.py +111 -0
  27. misterdev/core/context/topography/__init__.py +43 -0
  28. misterdev/core/context/topography/_log.py +10 -0
  29. misterdev/core/context/topography/cache.py +91 -0
  30. misterdev/core/context/topography/engine.py +172 -0
  31. misterdev/core/context/topography/graph.py +675 -0
  32. misterdev/core/context/topography/nodes.py +55 -0
  33. misterdev/core/context/topography/parsers.py +95 -0
  34. misterdev/core/context/topography/syntax.py +54 -0
  35. misterdev/core/economics/__init__.py +0 -0
  36. misterdev/core/economics/context_budget.py +175 -0
  37. misterdev/core/economics/embeddings.py +232 -0
  38. misterdev/core/economics/free_models.py +108 -0
  39. misterdev/core/economics/llm_cache.py +105 -0
  40. misterdev/core/economics/model_catalog.py +79 -0
  41. misterdev/core/economics/model_ledger.py +331 -0
  42. misterdev/core/economics/model_selector.py +281 -0
  43. misterdev/core/execution/__init__.py +0 -0
  44. misterdev/core/execution/bounded.py +50 -0
  45. misterdev/core/execution/container.py +221 -0
  46. misterdev/core/execution/error_classifier.py +366 -0
  47. misterdev/core/execution/error_resolver.py +201 -0
  48. misterdev/core/execution/governance.py +283 -0
  49. misterdev/core/execution/outcomes.py +50 -0
  50. misterdev/core/execution/progress.py +120 -0
  51. misterdev/core/execution/project.py +231 -0
  52. misterdev/core/execution/registry.py +97 -0
  53. misterdev/core/execution/runtime.py +279 -0
  54. misterdev/core/gitcmd.py +39 -0
  55. misterdev/core/integration/__init__.py +0 -0
  56. misterdev/core/integration/mcp.py +368 -0
  57. misterdev/core/integration/mcp_gather.py +186 -0
  58. misterdev/core/models.py +35 -0
  59. misterdev/core/modes.py +184 -0
  60. misterdev/core/planning/__init__.py +0 -0
  61. misterdev/core/planning/advisor.py +89 -0
  62. misterdev/core/planning/assessment.py +135 -0
  63. misterdev/core/planning/decomposer.py +387 -0
  64. misterdev/core/planning/metacognition.py +103 -0
  65. misterdev/core/planning/sovereign.py +308 -0
  66. misterdev/core/planning/targets.py +201 -0
  67. misterdev/core/reporting/__init__.py +0 -0
  68. misterdev/core/reporting/report.py +377 -0
  69. misterdev/core/reporting/report_view.py +151 -0
  70. misterdev/core/task.py +163 -0
  71. misterdev/core/verification/__init__.py +0 -0
  72. misterdev/core/verification/claim_verifier.py +210 -0
  73. misterdev/core/verification/critic.py +324 -0
  74. misterdev/core/verification/gatekeeper/__init__.py +631 -0
  75. misterdev/core/verification/gatekeeper/constants.py +138 -0
  76. misterdev/core/verification/gatekeeper/helpers.py +28 -0
  77. misterdev/core/verification/goal_check.py +219 -0
  78. misterdev/core/verification/independent.py +68 -0
  79. misterdev/core/verification/mutation_gate.py +221 -0
  80. misterdev/core/verification/preflight.py +95 -0
  81. misterdev/core/verification/spec_tests.py +175 -0
  82. misterdev/core/verification/validator.py +495 -0
  83. misterdev/core/verification/vision_verify.py +185 -0
  84. misterdev/core/verification/web_verify.py +408 -0
  85. misterdev/environments/__init__.py +0 -0
  86. misterdev/environments/base_env.py +18 -0
  87. misterdev/environments/container_env.py +87 -0
  88. misterdev/environments/venv_env.py +42 -0
  89. misterdev/llm/__init__.py +0 -0
  90. misterdev/llm/client/__init__.py +152 -0
  91. misterdev/llm/client/base.py +382 -0
  92. misterdev/llm/client/edits.py +70 -0
  93. misterdev/llm/client/embeddings.py +121 -0
  94. misterdev/llm/client/errors.py +134 -0
  95. misterdev/llm/client/providers.py +535 -0
  96. misterdev/llm/client/response.py +24 -0
  97. misterdev/llm/prompt_manager.py +82 -0
  98. misterdev/llm/responses/__init__.py +34 -0
  99. misterdev/llm/responses/apply.py +131 -0
  100. misterdev/llm/responses/json_extract.py +80 -0
  101. misterdev/llm/responses/models.py +43 -0
  102. misterdev/llm/responses/parsing.py +494 -0
  103. misterdev/logging_setup.py +20 -0
  104. misterdev/mcp_server.py +208 -0
  105. misterdev/nl_cli.py +149 -0
  106. misterdev/plugins.py +115 -0
  107. misterdev/py.typed +0 -0
  108. misterdev/task_executors/__init__.py +0 -0
  109. misterdev/task_executors/base_executor.py +10 -0
  110. misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
  111. misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
  112. misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
  113. misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
  114. misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
  115. misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
  116. misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
  117. misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
  118. misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
  119. misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
  120. misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
  121. misterdev/tools/__init__.py +19 -0
  122. misterdev/tools/base_tool.py +14 -0
  123. misterdev/tools/command.py +75 -0
  124. misterdev/tools/file_io.py +69 -0
  125. misterdev/tools/formatter.py +26 -0
  126. misterdev/tools/git_tool.py +90 -0
  127. misterdev/utils/__init__.py +0 -0
  128. misterdev/utils/file_utils.py +169 -0
  129. misterdev/utils/process.py +23 -0
  130. misterdev-0.2.0.dist-info/METADATA +326 -0
  131. misterdev-0.2.0.dist-info/RECORD +136 -0
  132. misterdev-0.2.0.dist-info/WHEEL +5 -0
  133. misterdev-0.2.0.dist-info/entry_points.txt +3 -0
  134. misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
  135. misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
  136. 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
+ ]