fixfleet 0.3.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.
bugfixer/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """FixFleet — Fleet of AI agents fixing GitLab bugs."""
2
+
3
+ __version__ = "0.3.0"
4
+ __name_pretty__ = "FixFleet"
5
+ __tagline__ = "Fleet of AI agents fixing GitLab bugs"
6
+ __author__ = "Yash Koladiya"
@@ -0,0 +1 @@
1
+ """Pluggable backends — CLI tools and API clients that fix bugs."""
@@ -0,0 +1,82 @@
1
+ """Shared subprocess runner with stdout tee (live print + capture)."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import threading
7
+ import time
8
+ from typing import List
9
+
10
+ from .base import RunResult
11
+
12
+
13
+ def run_with_tee(cmd: List[str], cwd: str, timeout: int = 600,
14
+ stdin_data: str = None, env: dict = None) -> RunResult:
15
+ """Run subprocess, stream stdout/stderr to terminal, also capture for parsing."""
16
+
17
+ captured_out: list = []
18
+ captured_err: list = []
19
+
20
+ try:
21
+ proc = subprocess.Popen(
22
+ cmd,
23
+ cwd=cwd,
24
+ stdin=subprocess.PIPE if stdin_data is not None else None,
25
+ stdout=subprocess.PIPE,
26
+ stderr=subprocess.PIPE,
27
+ text=True,
28
+ bufsize=1,
29
+ env={**os.environ, **(env or {})},
30
+ )
31
+ except FileNotFoundError as e:
32
+ return RunResult(returncode=127, stderr=str(e))
33
+
34
+ if stdin_data is not None:
35
+ try:
36
+ proc.stdin.write(stdin_data)
37
+ proc.stdin.close()
38
+ except (BrokenPipeError, OSError):
39
+ pass
40
+
41
+ def reader(stream, sink_list, dest):
42
+ try:
43
+ for line in iter(stream.readline, ""):
44
+ sink_list.append(line)
45
+ dest.write(line)
46
+ dest.flush()
47
+ finally:
48
+ try:
49
+ stream.close()
50
+ except Exception:
51
+ pass
52
+
53
+ t_out = threading.Thread(target=reader, args=(proc.stdout, captured_out, sys.stdout), daemon=True)
54
+ t_err = threading.Thread(target=reader, args=(proc.stderr, captured_err, sys.stderr), daemon=True)
55
+ t_out.start()
56
+ t_err.start()
57
+
58
+ deadline = time.time() + timeout
59
+ while True:
60
+ rc = proc.poll()
61
+ if rc is not None:
62
+ break
63
+ if time.time() > deadline:
64
+ proc.kill()
65
+ t_out.join(timeout=2)
66
+ t_err.join(timeout=2)
67
+ return RunResult(
68
+ returncode=-1,
69
+ stdout="".join(captured_out),
70
+ stderr="".join(captured_err),
71
+ timed_out=True,
72
+ )
73
+ time.sleep(0.1)
74
+
75
+ t_out.join(timeout=2)
76
+ t_err.join(timeout=2)
77
+
78
+ return RunResult(
79
+ returncode=proc.returncode,
80
+ stdout="".join(captured_out),
81
+ stderr="".join(captured_err),
82
+ )
@@ -0,0 +1 @@
1
+ """API-based backends (HTTP clients to LLM providers)."""
@@ -0,0 +1,183 @@
1
+ """OpenAI-compatible API backend.
2
+
3
+ Works with: Groq, OpenRouter, Gemini (compat endpoint), Cerebras, Mistral,
4
+ Together, DeepSeek, Ollama (localhost), LM Studio, vLLM, etc.
5
+
6
+ Strategy: send prompt + a hint about repo structure, expect a unified diff
7
+ back. Apply the diff with `git apply` (or `patch`) inside project_dir.
8
+ No agentic tool-use loop — keeps token usage minimal.
9
+ """
10
+
11
+ import json
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ import urllib.error
16
+ import urllib.request
17
+ from pathlib import Path
18
+
19
+ from ..base import Backend, RunResult
20
+
21
+
22
+ SYSTEM_PROMPT = """You are an expert software engineer fixing bugs in a codebase.
23
+
24
+ You CANNOT execute tools. You will be given a bug description and a list of likely-relevant files (with their contents inlined where possible). Output a fix as a unified diff.
25
+
26
+ OUTPUT FORMAT (strict):
27
+ 1. Brief explanation (2-4 lines) of the root cause.
28
+ 2. A unified diff inside a single fenced block tagged ```diff
29
+ 3. End with the FIX REPORT block exactly as instructed in the user prompt.
30
+
31
+ The diff MUST:
32
+ - Use standard unified-diff format (--- a/path, +++ b/path, @@ hunks).
33
+ - Use forward-slash paths relative to the repo root.
34
+ - Include enough context (3 lines) for `git apply` to work.
35
+ - Not contain any prose between the fence markers.
36
+ """
37
+
38
+
39
+ class OpenAICompatBackend(Backend):
40
+ name = "openai_compat"
41
+ display_name = "OpenAI-Compatible API"
42
+ requires_binary = "" # network-only
43
+
44
+ def __init__(self, base_url: str, api_key: str, model: str,
45
+ max_output_tokens: int = 4096, temperature: float = 0.1,
46
+ apply_diff: bool = True):
47
+ self.base_url = base_url.rstrip("/")
48
+ self.api_key = api_key
49
+ self.model = model
50
+ self.max_output_tokens = max_output_tokens
51
+ self.temperature = temperature
52
+ self.apply_diff = apply_diff
53
+
54
+ def available(self) -> bool:
55
+ return bool(self.base_url) and bool(self.model)
56
+
57
+ def version(self) -> str:
58
+ return f"{self.model} @ {self.base_url}"
59
+
60
+ def _chat(self, messages: list, timeout: int) -> dict:
61
+ url = f"{self.base_url}/chat/completions"
62
+ body = {
63
+ "model": self.model,
64
+ "messages": messages,
65
+ "temperature": self.temperature,
66
+ "max_tokens": self.max_output_tokens,
67
+ }
68
+ data = json.dumps(body).encode()
69
+ req = urllib.request.Request(
70
+ url,
71
+ data=data,
72
+ headers={
73
+ "Content-Type": "application/json",
74
+ "Authorization": f"Bearer {self.api_key}" if self.api_key else "",
75
+ },
76
+ method="POST",
77
+ )
78
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
79
+ return json.loads(resp.read().decode())
80
+
81
+ def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
82
+ messages = [
83
+ {"role": "system", "content": SYSTEM_PROMPT},
84
+ {"role": "user", "content": prompt},
85
+ ]
86
+
87
+ try:
88
+ response = self._chat(messages, timeout=timeout)
89
+ except urllib.error.HTTPError as e:
90
+ err_body = e.read().decode(errors="ignore")
91
+ return RunResult(returncode=e.code, stderr=f"HTTP {e.code}: {err_body[:500]}")
92
+ except urllib.error.URLError as e:
93
+ return RunResult(returncode=2, stderr=f"Network error: {e.reason}")
94
+ except Exception as e:
95
+ return RunResult(returncode=3, stderr=f"Unexpected error: {e}")
96
+
97
+ try:
98
+ content = response["choices"][0]["message"]["content"] or ""
99
+ except (KeyError, IndexError):
100
+ return RunResult(returncode=4, stderr=f"Bad response shape: {json.dumps(response)[:500]}")
101
+
102
+ # Echo to user so they can read the model's reasoning live.
103
+ print(content, flush=True)
104
+
105
+ usage = response.get("usage", {}) or {}
106
+ token_summary = (
107
+ f"\n[tokens] prompt={usage.get('prompt_tokens', '?')} "
108
+ f"completion={usage.get('completion_tokens', '?')} "
109
+ f"total={usage.get('total_tokens', '?')}\n"
110
+ )
111
+ print(token_summary, file=sys.stderr, flush=True)
112
+
113
+ if not self.apply_diff:
114
+ return RunResult(returncode=0, stdout=content)
115
+
116
+ diff = _extract_diff(content)
117
+ if not diff:
118
+ # Model didn't produce a diff — surface to user but treat as no-op fail.
119
+ return RunResult(
120
+ returncode=5,
121
+ stdout=content,
122
+ stderr="Model did not return a unified diff. No changes applied.",
123
+ )
124
+
125
+ rc, msg = _apply_diff(diff, project_dir)
126
+ if rc != 0:
127
+ return RunResult(returncode=rc, stdout=content, stderr=f"Diff apply failed: {msg}")
128
+
129
+ return RunResult(returncode=0, stdout=content + "\n[diff applied successfully]")
130
+
131
+
132
+ # ── Diff extraction + apply ────────────────────────────────────
133
+
134
+ DIFF_FENCE_RE = re.compile(r"```(?:diff|patch)?\s*\n(.*?)\n```", re.DOTALL)
135
+
136
+
137
+ def _extract_diff(text: str) -> str:
138
+ """Pull the first fenced diff block out of the model output."""
139
+ matches = DIFF_FENCE_RE.findall(text)
140
+ for m in matches:
141
+ if "---" in m and "+++" in m and "@@" in m:
142
+ return m.strip() + "\n"
143
+ # Fallback: maybe model emitted raw diff without fences
144
+ if text.lstrip().startswith("---") and "+++" in text:
145
+ return text.strip() + "\n"
146
+ return ""
147
+
148
+
149
+ def _apply_diff(diff_text: str, project_dir: str) -> tuple:
150
+ """Try `git apply`, fall back to `patch -p1`. Return (rc, message)."""
151
+ project = Path(project_dir)
152
+
153
+ # Use stdin to feed the diff
154
+ try:
155
+ result = subprocess.run(
156
+ ["git", "apply", "--whitespace=fix", "-"],
157
+ input=diff_text,
158
+ cwd=project_dir,
159
+ capture_output=True,
160
+ text=True,
161
+ timeout=30,
162
+ )
163
+ if result.returncode == 0:
164
+ return 0, "git apply succeeded"
165
+ git_err = result.stderr
166
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
167
+ git_err = str(e)
168
+
169
+ # Try patch as fallback
170
+ try:
171
+ result = subprocess.run(
172
+ ["patch", "-p1", "--forward"],
173
+ input=diff_text,
174
+ cwd=project_dir,
175
+ capture_output=True,
176
+ text=True,
177
+ timeout=30,
178
+ )
179
+ if result.returncode == 0:
180
+ return 0, "patch succeeded"
181
+ return result.returncode, f"git apply: {git_err}; patch: {result.stderr}"
182
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
183
+ return 1, f"git apply: {git_err}; patch unavailable: {e}"
@@ -0,0 +1,52 @@
1
+ """Backend interface contract."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class RunResult:
11
+ returncode: int
12
+ stdout: str = ""
13
+ stderr: str = ""
14
+ timed_out: bool = False
15
+ skipped: bool = False
16
+ skip_reason: str = ""
17
+
18
+ @property
19
+ def ok(self) -> bool:
20
+ return self.returncode == 0 and not self.timed_out and not self.skipped
21
+
22
+
23
+ class Backend(ABC):
24
+ """Abstract backend. Implementations wrap a CLI or API."""
25
+
26
+ name: str = "base"
27
+ display_name: str = "Base"
28
+ requires_binary: str = "" # name of binary on PATH, empty = no requirement
29
+
30
+ def available(self) -> bool:
31
+ """Return True if backend can be used right now (binary present, key set, etc)."""
32
+ if self.requires_binary:
33
+ return shutil.which(self.requires_binary) is not None
34
+ return True
35
+
36
+ def version(self) -> str:
37
+ """Best-effort version string. Empty if unknown."""
38
+ if not self.requires_binary:
39
+ return ""
40
+ try:
41
+ out = subprocess.run(
42
+ [self.requires_binary, "--version"],
43
+ capture_output=True, text=True, timeout=5,
44
+ )
45
+ return (out.stdout or out.stderr).strip().splitlines()[0] if (out.stdout or out.stderr) else ""
46
+ except Exception:
47
+ return ""
48
+
49
+ @abstractmethod
50
+ def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
51
+ """Execute the backend against `prompt` inside `project_dir`. Return RunResult."""
52
+ raise NotImplementedError
@@ -0,0 +1 @@
1
+ """CLI-based backends (subprocess wrappers around installed CLI tools)."""
@@ -0,0 +1,25 @@
1
+ """Aider CLI backend."""
2
+
3
+ from ..base import Backend, RunResult
4
+ from .._subprocess import run_with_tee
5
+
6
+
7
+ class AiderCLIBackend(Backend):
8
+ name = "aider"
9
+ display_name = "Aider"
10
+ requires_binary = "aider"
11
+
12
+ def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
13
+ # --yes-always: skip all confirmations.
14
+ # --no-auto-commits: bug fixer manages commits manually (user reviews).
15
+ # --map-tokens 1024: tighter repo map to save tokens.
16
+ # --no-stream: simpler stdout to capture.
17
+ cmd = [
18
+ "aider",
19
+ "--message", prompt,
20
+ "--yes-always",
21
+ "--no-auto-commits",
22
+ "--map-tokens", "1024",
23
+ "--no-stream",
24
+ ]
25
+ return run_with_tee(cmd, cwd=project_dir, timeout=timeout)
@@ -0,0 +1,22 @@
1
+ """Claude Code CLI backend."""
2
+
3
+ from ..base import Backend, RunResult
4
+ from .._subprocess import run_with_tee
5
+
6
+
7
+ class ClaudeCLIBackend(Backend):
8
+ name = "claude"
9
+ display_name = "Claude Code (Anthropic)"
10
+ requires_binary = "claude"
11
+
12
+ DEFAULT_ALLOWED_TOOLS = "Read,Edit,Write,Grep,Glob,Bash"
13
+
14
+ def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
15
+ cmd = [
16
+ "claude",
17
+ "--print",
18
+ prompt,
19
+ "--allowedTools",
20
+ self.DEFAULT_ALLOWED_TOOLS,
21
+ ]
22
+ return run_with_tee(cmd, cwd=project_dir, timeout=timeout)
@@ -0,0 +1,20 @@
1
+ """OpenAI Codex CLI backend."""
2
+
3
+ from ..base import Backend, RunResult
4
+ from .._subprocess import run_with_tee
5
+
6
+
7
+ class CodexCLIBackend(Backend):
8
+ name = "codex"
9
+ display_name = "Codex (OpenAI)"
10
+ requires_binary = "codex"
11
+
12
+ def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
13
+ # `codex exec` is the non-interactive mode; --full-auto auto-approves edits within sandbox.
14
+ cmd = [
15
+ "codex",
16
+ "exec",
17
+ "--full-auto",
18
+ prompt,
19
+ ]
20
+ return run_with_tee(cmd, cwd=project_dir, timeout=timeout)
@@ -0,0 +1,19 @@
1
+ """Cursor Agent CLI backend."""
2
+
3
+ from ..base import Backend, RunResult
4
+ from .._subprocess import run_with_tee
5
+
6
+
7
+ class CursorAgentBackend(Backend):
8
+ name = "cursor"
9
+ display_name = "Cursor Agent"
10
+ requires_binary = "cursor-agent"
11
+
12
+ def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
13
+ # -p / --print prints output non-interactively.
14
+ cmd = [
15
+ "cursor-agent",
16
+ "-p",
17
+ prompt,
18
+ ]
19
+ return run_with_tee(cmd, cwd=project_dir, timeout=timeout)
@@ -0,0 +1,20 @@
1
+ """Google Gemini CLI backend."""
2
+
3
+ from ..base import Backend, RunResult
4
+ from .._subprocess import run_with_tee
5
+
6
+
7
+ class GeminiCLIBackend(Backend):
8
+ name = "gemini"
9
+ display_name = "Gemini CLI (Google)"
10
+ requires_binary = "gemini"
11
+
12
+ def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
13
+ # --yolo auto-approves all tool calls.
14
+ cmd = [
15
+ "gemini",
16
+ "--yolo",
17
+ "-p",
18
+ prompt,
19
+ ]
20
+ return run_with_tee(cmd, cwd=project_dir, timeout=timeout)
@@ -0,0 +1,19 @@
1
+ """Qwen Code CLI backend (forked from Gemini CLI)."""
2
+
3
+ from ..base import Backend, RunResult
4
+ from .._subprocess import run_with_tee
5
+
6
+
7
+ class QwenCLIBackend(Backend):
8
+ name = "qwen"
9
+ display_name = "Qwen Code (Alibaba)"
10
+ requires_binary = "qwen"
11
+
12
+ def run(self, prompt: str, project_dir: str, timeout: int = 600) -> RunResult:
13
+ cmd = [
14
+ "qwen",
15
+ "--yolo",
16
+ "-p",
17
+ prompt,
18
+ ]
19
+ return run_with_tee(cmd, cwd=project_dir, timeout=timeout)
@@ -0,0 +1,87 @@
1
+ """Backend registry — list, detect, instantiate."""
2
+
3
+ from .api.openai_compat import OpenAICompatBackend
4
+ from .base import Backend
5
+ from .cli.aider import AiderCLIBackend
6
+ from .cli.claude import ClaudeCLIBackend
7
+ from .cli.codex import CodexCLIBackend
8
+ from .cli.cursor import CursorAgentBackend
9
+ from .cli.gemini import GeminiCLIBackend
10
+ from .cli.qwen import QwenCLIBackend
11
+
12
+
13
+ CLI_BACKENDS = [
14
+ ClaudeCLIBackend,
15
+ CodexCLIBackend,
16
+ GeminiCLIBackend,
17
+ CursorAgentBackend,
18
+ AiderCLIBackend,
19
+ QwenCLIBackend,
20
+ ]
21
+
22
+
23
+ def list_cli_backends() -> list:
24
+ """Instantiate all CLI backend classes (regardless of availability)."""
25
+ return [cls() for cls in CLI_BACKENDS]
26
+
27
+
28
+ def detect_available_clis() -> list:
29
+ """Return only CLI backends whose binary is on PATH."""
30
+ return [b for b in list_cli_backends() if b.available()]
31
+
32
+
33
+ def detect_unavailable_clis() -> list:
34
+ """Return CLI backends whose binary is missing (for display)."""
35
+ return [b for b in list_cli_backends() if not b.available()]
36
+
37
+
38
+ # ── Free-tier API presets ──────────────────────────────────────
39
+
40
+ API_PRESETS = {
41
+ "groq": {
42
+ "label": "Groq (free, fast)",
43
+ "base_url": "https://api.groq.com/openai/v1",
44
+ "default_model": "llama-3.3-70b-versatile",
45
+ "key_url": "https://console.groq.com/keys",
46
+ },
47
+ "gemini": {
48
+ "label": "Google Gemini (free tier, big quota)",
49
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
50
+ "default_model": "gemini-2.0-flash",
51
+ "key_url": "https://aistudio.google.com/apikey",
52
+ },
53
+ "openrouter": {
54
+ "label": "OpenRouter (free models available)",
55
+ "base_url": "https://openrouter.ai/api/v1",
56
+ "default_model": "deepseek/deepseek-chat-v3.1:free",
57
+ "key_url": "https://openrouter.ai/keys",
58
+ },
59
+ "cerebras": {
60
+ "label": "Cerebras (free tier, very fast)",
61
+ "base_url": "https://api.cerebras.ai/v1",
62
+ "default_model": "llama-3.3-70b",
63
+ "key_url": "https://cloud.cerebras.ai",
64
+ },
65
+ "ollama": {
66
+ "label": "Ollama (local, no key, free forever)",
67
+ "base_url": "http://localhost:11434/v1",
68
+ "default_model": "qwen2.5-coder:7b",
69
+ "key_url": "https://ollama.com",
70
+ },
71
+ "lmstudio": {
72
+ "label": "LM Studio (local, no key)",
73
+ "base_url": "http://localhost:1234/v1",
74
+ "default_model": "local-model",
75
+ "key_url": "https://lmstudio.ai",
76
+ },
77
+ "custom": {
78
+ "label": "Custom OpenAI-compatible endpoint",
79
+ "base_url": "",
80
+ "default_model": "",
81
+ "key_url": "",
82
+ },
83
+ }
84
+
85
+
86
+ def build_api_backend(base_url: str, api_key: str, model: str, **kwargs) -> Backend:
87
+ return OpenAICompatBackend(base_url=base_url, api_key=api_key, model=model, **kwargs)
bugfixer/budget.py ADDED
@@ -0,0 +1,119 @@
1
+ """Token budget — slim long sections, estimate cost, enforce caps.
2
+
3
+ Estimation is heuristic (1 token ≈ 4 chars for English text). Good enough for
4
+ warning users before they blow through a paid plan; not for billing accuracy.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ # ── Section caps (chars) ───────────────────────────────────────
11
+
12
+ DEFAULT_CAPS = {
13
+ "title": 300,
14
+ "description": 2500,
15
+ "steps": 1500,
16
+ "expected": 500,
17
+ "actual": 800,
18
+ "environment": 600,
19
+ "logs": 2000,
20
+ "notes": 800,
21
+ "raw_description": 4000,
22
+ "inline_file": 8000, # inlined candidate file content
23
+ }
24
+
25
+
26
+ # ── Token cost rates (per backend, output:input ratio) ─────────
27
+
28
+ # Multiplier estimates how many tokens get consumed per input token across the
29
+ # whole agentic loop (reads, edits, retries). Pure-API backend = ~1.5; agentic
30
+ # CLI = 3-4 because of tool-use round-trips.
31
+ BACKEND_LOOP_MULTIPLIER = {
32
+ "claude": 4.0,
33
+ "codex": 4.0,
34
+ "gemini": 3.5,
35
+ "qwen": 3.5,
36
+ "cursor": 3.5,
37
+ "aider": 2.5,
38
+ "openai_compat": 1.5, # diff-only, no loop
39
+ }
40
+
41
+
42
+ # ── Default budgets ────────────────────────────────────────────
43
+
44
+ DEFAULT_BUDGETS = {
45
+ "session_max_tokens": 200_000,
46
+ "per_issue_max_tokens": 30_000,
47
+ "daily_max_tokens": 500_000,
48
+ }
49
+
50
+
51
+ # ── Public helpers ─────────────────────────────────────────────
52
+
53
+ def slim(text: str, max_chars: int) -> str:
54
+ if not text:
55
+ return ""
56
+ if len(text) <= max_chars:
57
+ return text
58
+ cut = max_chars
59
+ return text[:cut].rstrip() + f"\n\n[...truncated {len(text) - cut} chars to save tokens...]"
60
+
61
+
62
+ def estimate_tokens(text: str) -> int:
63
+ """Rough token count: 4 chars per token."""
64
+ if not text:
65
+ return 0
66
+ return max(1, len(text) // 4)
67
+
68
+
69
+ def estimate_total_cost(prompt: str, backend_name: str) -> int:
70
+ """Estimate total tokens consumed for one issue on the given backend."""
71
+ base = estimate_tokens(prompt)
72
+ mult = BACKEND_LOOP_MULTIPLIER.get(backend_name, 3.0)
73
+ return int(base * mult)
74
+
75
+
76
+ @dataclass
77
+ class BudgetCheck:
78
+ allowed: bool
79
+ estimated: int
80
+ session_used: int
81
+ session_max: int
82
+ per_issue_max: int
83
+ daily_used: int
84
+ daily_max: int
85
+ reason: str = ""
86
+
87
+
88
+ def check_budget(prompt: str, backend_name: str,
89
+ session_used: int, daily_used: int,
90
+ budgets: dict = None) -> BudgetCheck:
91
+ b = {**DEFAULT_BUDGETS, **(budgets or {})}
92
+ estimated = estimate_total_cost(prompt, backend_name)
93
+
94
+ reasons: list = []
95
+ if estimated > b["per_issue_max_tokens"]:
96
+ reasons.append(
97
+ f"per-issue cap exceeded ({estimated:,} > {b['per_issue_max_tokens']:,})"
98
+ )
99
+ if session_used + estimated > b["session_max_tokens"]:
100
+ reasons.append(
101
+ f"session cap would be exceeded "
102
+ f"({session_used + estimated:,} > {b['session_max_tokens']:,})"
103
+ )
104
+ if daily_used + estimated > b["daily_max_tokens"]:
105
+ reasons.append(
106
+ f"daily cap would be exceeded "
107
+ f"({daily_used + estimated:,} > {b['daily_max_tokens']:,})"
108
+ )
109
+
110
+ return BudgetCheck(
111
+ allowed=not reasons,
112
+ estimated=estimated,
113
+ session_used=session_used,
114
+ session_max=b["session_max_tokens"],
115
+ per_issue_max=b["per_issue_max_tokens"],
116
+ daily_used=daily_used,
117
+ daily_max=b["daily_max_tokens"],
118
+ reason="; ".join(reasons),
119
+ )