verd 0.1.0__tar.gz
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.
- verd-0.1.0/PKG-INFO +12 -0
- verd-0.1.0/README.md +210 -0
- verd-0.1.0/pyproject.toml +28 -0
- verd-0.1.0/setup.cfg +4 -0
- verd-0.1.0/verd/__init__.py +0 -0
- verd-0.1.0/verd/__main__.py +141 -0
- verd-0.1.0/verd/config.py +68 -0
- verd-0.1.0/verd/context.py +153 -0
- verd-0.1.0/verd/engine.py +300 -0
- verd-0.1.0/verd/judge.py +158 -0
- verd-0.1.0/verd/mcp_server.py +116 -0
- verd-0.1.0/verd/models.py +40 -0
- verd-0.1.0/verd/output.py +131 -0
- verd-0.1.0/verd/security.py +191 -0
- verd-0.1.0/verd/selector.py +300 -0
- verd-0.1.0/verd/slack_bot.py +445 -0
- verd-0.1.0/verd.egg-info/PKG-INFO +12 -0
- verd-0.1.0/verd.egg-info/SOURCES.txt +20 -0
- verd-0.1.0/verd.egg-info/dependency_links.txt +1 -0
- verd-0.1.0/verd.egg-info/entry_points.txt +6 -0
- verd-0.1.0/verd.egg-info/requires.txt +8 -0
- verd-0.1.0/verd.egg-info/top_level.txt +1 -0
verd-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: verd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi-LLM debate CLI for confident answers
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: openai>=1.0.0
|
|
7
|
+
Requires-Dist: rich>=13.0.0
|
|
8
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
9
|
+
Requires-Dist: mcp>=1.0.0
|
|
10
|
+
Provides-Extra: slack
|
|
11
|
+
Requires-Dist: slack-bolt>=1.18.0; extra == "slack"
|
|
12
|
+
Requires-Dist: httpx>=0.27.0; extra == "slack"
|
verd-0.1.0/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# verd
|
|
2
|
+
|
|
3
|
+
Multi-LLM debate for confident answers. Takes any content + a question, runs it through multiple AI models in a structured multi-round debate, and returns a confidence-weighted verdict with strengths, issues, and fixes.
|
|
4
|
+
|
|
5
|
+
Instead of asking one AI "are you sure?", verd spawns multiple models from different families, has them challenge each other across rounds, then a stronger judge synthesizes the final verdict.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install verd
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
verd works with any OpenAI-compatible API. Pick one:
|
|
16
|
+
|
|
17
|
+
### Option 1: OpenRouter (easiest, all models, one key)
|
|
18
|
+
|
|
19
|
+
Sign up at [openrouter.ai](https://openrouter.ai), get an API key, then:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export OPENAI_API_KEY=sk-or-...
|
|
23
|
+
export OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Option 2: Direct OpenAI
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export OPENAI_API_KEY=sk-...
|
|
30
|
+
export OPENAI_BASE_URL=https://api.openai.com/v1
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Note: only OpenAI models will work. Edit `verd/models.py` to use only OpenAI models.
|
|
34
|
+
|
|
35
|
+
### Option 3: LiteLLM proxy (use native keys from any provider)
|
|
36
|
+
|
|
37
|
+
If you have API keys from multiple providers (Anthropic, Google, OpenAI, etc.):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install litellm
|
|
41
|
+
litellm --config litellm_config.yaml # starts local proxy on port 4000
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Example `litellm_config.yaml`:
|
|
45
|
+
```yaml
|
|
46
|
+
model_list:
|
|
47
|
+
- model_name: claude-sonnet-4-6
|
|
48
|
+
litellm_params:
|
|
49
|
+
model: anthropic/claude-sonnet-4-20250514
|
|
50
|
+
api_key: sk-ant-...
|
|
51
|
+
- model_name: gpt-5-mini
|
|
52
|
+
litellm_params:
|
|
53
|
+
model: openai/gpt-5-mini
|
|
54
|
+
api_key: sk-...
|
|
55
|
+
- model_name: gemini-2.5-flash
|
|
56
|
+
litellm_params:
|
|
57
|
+
model: gemini/gemini-2.5-flash
|
|
58
|
+
api_key: AIza...
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then point verd at it:
|
|
62
|
+
```bash
|
|
63
|
+
export OPENAI_API_KEY=sk-anything
|
|
64
|
+
export OPENAI_BASE_URL=http://localhost:4000/v1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Option 4: Any OpenAI-compatible provider
|
|
68
|
+
|
|
69
|
+
Azure OpenAI, Together, Groq, Fireworks, etc. — just set the base URL and API key.
|
|
70
|
+
|
|
71
|
+
### Save to .env
|
|
72
|
+
|
|
73
|
+
Or create a `.env` file in your working directory:
|
|
74
|
+
```bash
|
|
75
|
+
cp .env.example .env
|
|
76
|
+
# edit with your keys
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Custom models
|
|
80
|
+
|
|
81
|
+
Edit `verd/models.py` to match whatever models your provider supports. The default config uses models available through LiteLLM proxies and OpenRouter.
|
|
82
|
+
|
|
83
|
+
## Usage
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Auto-scan current directory
|
|
87
|
+
cd backend && verd "is this production-ready?"
|
|
88
|
+
|
|
89
|
+
# Single file
|
|
90
|
+
verd "is this JWT implementation secure?" -f auth.py
|
|
91
|
+
|
|
92
|
+
# Multiple files
|
|
93
|
+
verd "any issues?" -f auth.py middleware.py routes.py
|
|
94
|
+
|
|
95
|
+
# Directory
|
|
96
|
+
verd "is this codebase sound?" -d src/ --ext .py
|
|
97
|
+
|
|
98
|
+
# Inline question
|
|
99
|
+
verdl "is O(n^2) acceptable for n=1000?"
|
|
100
|
+
|
|
101
|
+
# Git diffs
|
|
102
|
+
verd "are these changes safe?" -g # unstaged
|
|
103
|
+
verd "ready to commit?" -gs # staged
|
|
104
|
+
verdh "should we merge this?" -gb main # branch diff
|
|
105
|
+
|
|
106
|
+
# Pipe
|
|
107
|
+
cat auth.py | verd "is this secure?"
|
|
108
|
+
|
|
109
|
+
# Quiet mode (verdict only, no transcript)
|
|
110
|
+
verd "any bugs?" -f app.py -q
|
|
111
|
+
|
|
112
|
+
# JSON output
|
|
113
|
+
verd "any bugs?" -f app.py --json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Modes
|
|
117
|
+
|
|
118
|
+
| Command | Models | Rounds | Speed | Cost |
|
|
119
|
+
|---------|--------|--------|-------|------|
|
|
120
|
+
| `verdl` | 2 | 1 | ~10s | ~$0.01 |
|
|
121
|
+
| `verd` | 4 | 2 | ~30s | ~$0.05 |
|
|
122
|
+
| `verdh` | 5 + web search | 3 | ~70s | ~$0.30 |
|
|
123
|
+
|
|
124
|
+
## Flags
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
claim the question to evaluate (required)
|
|
128
|
+
|
|
129
|
+
Content input (pick one, or auto-scans current dir):
|
|
130
|
+
-c, --context TEXT inline content string
|
|
131
|
+
-f FILE [FILE ...] one or more files
|
|
132
|
+
-d [DIR] directory (default: current dir)
|
|
133
|
+
-g, --git unstaged git diff
|
|
134
|
+
-gs, --git-staged staged git diff
|
|
135
|
+
-gb, --git-branch REF git diff REF...HEAD
|
|
136
|
+
|
|
137
|
+
Directory filters:
|
|
138
|
+
--ext EXT [EXT ...] filter by extension (.py .ts)
|
|
139
|
+
--exclude PATTERN glob pattern to exclude (test_*)
|
|
140
|
+
|
|
141
|
+
Output:
|
|
142
|
+
-q, --quiet hide debate transcript, show only verdict
|
|
143
|
+
--json raw JSON output
|
|
144
|
+
--timeout SECONDS override timeout per model call
|
|
145
|
+
--version show version
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Exit Codes
|
|
149
|
+
|
|
150
|
+
- `0` — PASS
|
|
151
|
+
- `1` — FAIL
|
|
152
|
+
- `2` — UNCERTAIN
|
|
153
|
+
|
|
154
|
+
Useful for scripting: `verd "are tests passing?" -f test.py && deploy`
|
|
155
|
+
|
|
156
|
+
## MCP — Claude Code / Cursor
|
|
157
|
+
|
|
158
|
+
Add to `~/.claude.json` or `~/.cursor/mcp.json`:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"mcpServers": {
|
|
163
|
+
"verd": {
|
|
164
|
+
"command": "verd-mcp",
|
|
165
|
+
"env": {
|
|
166
|
+
"OPENAI_API_KEY": "your-key",
|
|
167
|
+
"OPENAI_BASE_URL": "https://openrouter.ai/api/v1"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Then use `verd`, `verdl`, or `verdh` as tools directly in chat.
|
|
175
|
+
|
|
176
|
+
## Slack
|
|
177
|
+
|
|
178
|
+
Install with Slack dependencies:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pip install verd[slack]
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Create a Slack app with Socket Mode, add bot scopes (`app_mentions:read`, `channels:history`, `chat:write`, `reactions:write`, `im:history`, `im:write`, `users:read`), then:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
export SLACK_BOT_TOKEN=xoxb-...
|
|
188
|
+
export SLACK_APP_TOKEN=xapp-...
|
|
189
|
+
export SLACK_SIGNING_SECRET=...
|
|
190
|
+
verd-slack
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Usage in Slack:
|
|
194
|
+
- `@verd what do you think?` — reads thread/channel context, debates, replies
|
|
195
|
+
- `@verd deep is this secure?` — uses verdh (5 models + web search)
|
|
196
|
+
- `@verd quick is this right?` — uses verdl (fast)
|
|
197
|
+
- `@verd last 50 what's the consensus?` — reads last 50 messages
|
|
198
|
+
- `/verd should we use Kafka?` — slash command with progress updates
|
|
199
|
+
- `/verdl is this correct?` — quick slash command
|
|
200
|
+
- `/verdh any security issues?` — deep slash command
|
|
201
|
+
|
|
202
|
+
## How it works
|
|
203
|
+
|
|
204
|
+
1. Your question + content gets sent to multiple AI models in parallel
|
|
205
|
+
2. Each model gives its independent assessment (PASS/FAIL/UNCERTAIN)
|
|
206
|
+
3. Models see each other's responses and cross-examine for 1-3 rounds
|
|
207
|
+
4. A stronger judge model synthesizes the debate into a final verdict
|
|
208
|
+
5. You get: verdict, confidence %, strengths, issues, and actionable fixes
|
|
209
|
+
|
|
210
|
+
The key insight: different models catch different things. Claude spots security issues GPT misses. Gemini catches logic errors DeepSeek overlooks. The debate format forces them to challenge each other rather than just agreeing.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "verd"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Multi-LLM debate CLI for confident answers"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"openai>=1.0.0",
|
|
12
|
+
"rich>=13.0.0",
|
|
13
|
+
"python-dotenv>=1.0.0",
|
|
14
|
+
"mcp>=1.0.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
slack = [
|
|
19
|
+
"slack-bolt>=1.18.0",
|
|
20
|
+
"httpx>=0.27.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
verd = "verd.__main__:main_default"
|
|
25
|
+
verdl = "verd.__main__:main_light"
|
|
26
|
+
verdh = "verd.__main__:main_heavy"
|
|
27
|
+
verd-mcp = "verd.mcp_server:main"
|
|
28
|
+
verd-slack = "verd.slack_bot:main"
|
verd-0.1.0/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from verd.config import VERSION
|
|
7
|
+
from verd.context import build_context
|
|
8
|
+
from verd.engine import run_debate
|
|
9
|
+
from verd.output import print_result, StatusDisplay
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_parser():
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="Multi-LLM debate for confident answers"
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("--version", action="version", version=f"verd {VERSION}")
|
|
17
|
+
parser.add_argument("claim", help="The claim or question to evaluate")
|
|
18
|
+
|
|
19
|
+
# Content input — pick one
|
|
20
|
+
content = parser.add_argument_group("content input")
|
|
21
|
+
content.add_argument("-c", "--context", help="Inline content string")
|
|
22
|
+
content.add_argument(
|
|
23
|
+
"-f", "--file", nargs="+", metavar="FILE",
|
|
24
|
+
help="One or more files to evaluate",
|
|
25
|
+
)
|
|
26
|
+
content.add_argument(
|
|
27
|
+
"-d", "--dir", nargs="?", const="", default=None, metavar="DIR",
|
|
28
|
+
help="Read all files in a directory (default: current dir)",
|
|
29
|
+
)
|
|
30
|
+
content.add_argument(
|
|
31
|
+
"-g", "--git", action="store_true",
|
|
32
|
+
help="Use unstaged git diff as content",
|
|
33
|
+
)
|
|
34
|
+
content.add_argument(
|
|
35
|
+
"-gs", "--git-staged", action="store_true",
|
|
36
|
+
help="Use staged git diff as content",
|
|
37
|
+
)
|
|
38
|
+
content.add_argument(
|
|
39
|
+
"-gb", "--git-branch", metavar="REF",
|
|
40
|
+
help="Use git diff REF...HEAD as content (e.g. main)",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Filters for -d
|
|
44
|
+
filters = parser.add_argument_group("directory filters (use with -d)")
|
|
45
|
+
filters.add_argument(
|
|
46
|
+
"-a", "--all", action="store_true",
|
|
47
|
+
help="Scan all files, skip smart selection (for full codebase reviews)",
|
|
48
|
+
)
|
|
49
|
+
filters.add_argument(
|
|
50
|
+
"--ext", nargs="+", metavar="EXT",
|
|
51
|
+
help="Filter by extension (e.g. --ext .py .ts)",
|
|
52
|
+
)
|
|
53
|
+
filters.add_argument(
|
|
54
|
+
"--exclude", nargs="+", metavar="PATTERN",
|
|
55
|
+
help="Glob patterns to exclude (e.g. --exclude 'test_*' '*.spec.*')",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Output
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"-q", "--quiet", action="store_true",
|
|
61
|
+
help="Hide debate transcript, show only verdict",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--json", dest="json_output", action="store_true",
|
|
65
|
+
help="Output raw JSON",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--timeout", type=int, default=None,
|
|
69
|
+
help="Override timeout per model call (seconds)",
|
|
70
|
+
)
|
|
71
|
+
return parser
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run(mode: str):
|
|
75
|
+
parser = make_parser()
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
content, claim, files = build_context(args)
|
|
78
|
+
|
|
79
|
+
if not content and not claim:
|
|
80
|
+
parser.print_help()
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
# --all flag: skip smart file selection, send everything
|
|
84
|
+
if getattr(args, 'all', False) and files is not None:
|
|
85
|
+
from verd.context import files_to_content, MAX_CONTENT_CHARS
|
|
86
|
+
total_chars = sum(len(text) for _, text, _ in files)
|
|
87
|
+
content = files_to_content(files)
|
|
88
|
+
if total_chars > MAX_CONTENT_CHARS:
|
|
89
|
+
skipped = total_chars - MAX_CONTENT_CHARS
|
|
90
|
+
print(
|
|
91
|
+
f"\n⚠ {len(files)} files totaling {total_chars // 1000}K chars — "
|
|
92
|
+
f"truncated to {MAX_CONTENT_CHARS // 1000}K ({skipped // 1000}K chars lost).\n"
|
|
93
|
+
f" Some files were cut off. For better results, drop -a and let\n"
|
|
94
|
+
f" the smart selector pick relevant files, or narrow with --ext / -f.\n",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
print(f"scanning all {len(files)} files ({total_chars // 1000}K chars)", file=sys.stderr)
|
|
99
|
+
files = None # don't run selector
|
|
100
|
+
|
|
101
|
+
status = StatusDisplay()
|
|
102
|
+
try:
|
|
103
|
+
result = asyncio.run(
|
|
104
|
+
run_debate(
|
|
105
|
+
content,
|
|
106
|
+
claim,
|
|
107
|
+
mode,
|
|
108
|
+
timeout_override=args.timeout,
|
|
109
|
+
status_display=status,
|
|
110
|
+
files=files,
|
|
111
|
+
verbose=not args.quiet,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
finally:
|
|
115
|
+
status.stop()
|
|
116
|
+
|
|
117
|
+
if args.json_output:
|
|
118
|
+
print(json.dumps(result, indent=2, default=str))
|
|
119
|
+
else:
|
|
120
|
+
print_result(result)
|
|
121
|
+
|
|
122
|
+
# Exit codes: 0=PASS, 1=FAIL, 2=UNCERTAIN
|
|
123
|
+
verdict = result.get("verdict", "UNCERTAIN")
|
|
124
|
+
exit_codes = {"PASS": 0, "FAIL": 1, "UNCERTAIN": 2}
|
|
125
|
+
sys.exit(exit_codes.get(verdict, 2))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main_light():
|
|
129
|
+
run("verdl")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main_default():
|
|
133
|
+
run("verd")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main_heavy():
|
|
137
|
+
run("verdh")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
main_default()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from openai import AsyncOpenAI
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
load_dotenv()
|
|
6
|
+
|
|
7
|
+
VERSION = "0.1.0"
|
|
8
|
+
|
|
9
|
+
# Token budgets — reasoning models use these for thinking + output combined
|
|
10
|
+
# Keep high enough that reasoning models don't run out of thinking space
|
|
11
|
+
DEBATER_MAX_TOKENS = {
|
|
12
|
+
"verdl": 2048, # fast, concise
|
|
13
|
+
"verd": 4096, # balanced
|
|
14
|
+
"verdh": 4096, # deep analysis
|
|
15
|
+
}
|
|
16
|
+
JUDGE_MAX_TOKENS = 8192
|
|
17
|
+
|
|
18
|
+
TIMEOUTS = {
|
|
19
|
+
"verdl": 30,
|
|
20
|
+
"verd": 45,
|
|
21
|
+
"verdh": 90,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
JSON_MODE_MODELS = {
|
|
25
|
+
"o3", "o3-mini", "o4-mini", "gpt-4.1", "gpt-5-mini", "gpt-5", "gpt-5.4"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Pricing per 1M tokens (input, output) in USD
|
|
29
|
+
MODEL_PRICING = {
|
|
30
|
+
"gpt-4.1": (2.00, 8.00),
|
|
31
|
+
"gpt-5-mini": (0.25, 2.00),
|
|
32
|
+
"gpt-5.4": (2.50, 15.00),
|
|
33
|
+
"claude-sonnet-4-6": (3.00, 15.00),
|
|
34
|
+
"claude-opus-4-6": (5.00, 25.00),
|
|
35
|
+
"gemini-2.5-flash": (0.30, 2.50),
|
|
36
|
+
"gemini-3.1-pro-preview": (2.00, 12.00),
|
|
37
|
+
"deepseek-r1": (1.35, 5.40),
|
|
38
|
+
"sonar-pro": (3.00, 15.00),
|
|
39
|
+
"o3": (2.00, 8.00),
|
|
40
|
+
"o4-mini": (1.10, 4.40),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
|
45
|
+
"""Estimate cost in USD for a model call."""
|
|
46
|
+
input_price, output_price = MODEL_PRICING.get(model, (5.00, 15.00))
|
|
47
|
+
return (prompt_tokens * input_price + completion_tokens * output_price) / 1_000_000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_client = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_client() -> AsyncOpenAI:
|
|
54
|
+
"""Lazy client init — only validates env vars on first actual API call."""
|
|
55
|
+
global _client
|
|
56
|
+
if _client is None:
|
|
57
|
+
api_key = os.getenv("OPENAI_API_KEY")
|
|
58
|
+
base_url = os.getenv("OPENAI_BASE_URL")
|
|
59
|
+
if not api_key:
|
|
60
|
+
raise EnvironmentError(
|
|
61
|
+
"OPENAI_API_KEY is not set. Add it to your .env file or export it."
|
|
62
|
+
)
|
|
63
|
+
if not base_url:
|
|
64
|
+
raise EnvironmentError(
|
|
65
|
+
"OPENAI_BASE_URL is not set. Add it to your .env file or export it."
|
|
66
|
+
)
|
|
67
|
+
_client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
68
|
+
return _client
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Dirs/files to always skip
|
|
7
|
+
_SKIP_DIRS = {
|
|
8
|
+
"__pycache__", ".git", ".venv", "venv", "node_modules",
|
|
9
|
+
".tox", ".mypy_cache", ".pytest_cache", "dist", "build",
|
|
10
|
+
".egg-info", ".eggs",
|
|
11
|
+
}
|
|
12
|
+
_SKIP_FILES = {
|
|
13
|
+
".DS_Store", "Thumbs.db", ".env", ".env.local",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
MAX_CONTENT_CHARS = 200_000 # ~50k tokens, safe for most models
|
|
17
|
+
|
|
18
|
+
# Code extensions we care about
|
|
19
|
+
_CODE_EXTENSIONS = {
|
|
20
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".rb", ".java",
|
|
21
|
+
".kt", ".swift", ".c", ".cpp", ".h", ".hpp", ".cs", ".php",
|
|
22
|
+
".scala", ".ex", ".exs", ".clj", ".lua", ".r", ".sql", ".sh",
|
|
23
|
+
".yaml", ".yml", ".toml", ".json", ".tf", ".hcl",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_valid_path(p: Path) -> bool:
|
|
28
|
+
if any(part in _SKIP_DIRS for part in p.parts):
|
|
29
|
+
return False
|
|
30
|
+
if p.name in _SKIP_FILES or p.name.startswith("."):
|
|
31
|
+
return False
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_file(path: Path) -> str | None:
|
|
36
|
+
"""Read file content, return None on failure."""
|
|
37
|
+
try:
|
|
38
|
+
return path.read_text()
|
|
39
|
+
except (UnicodeDecodeError, PermissionError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _collect_files(
|
|
44
|
+
dir_path: Path,
|
|
45
|
+
extensions: list[str] | None,
|
|
46
|
+
excludes: list[str] | None,
|
|
47
|
+
) -> list[tuple[Path, str, str]]:
|
|
48
|
+
"""Collect files from directory. Returns list of (relative_path, content, extension).
|
|
49
|
+
|
|
50
|
+
Auto-detects extensions if None.
|
|
51
|
+
"""
|
|
52
|
+
if extensions is None:
|
|
53
|
+
counts: dict[str, int] = {}
|
|
54
|
+
for p in dir_path.rglob("*"):
|
|
55
|
+
if not p.is_file() or not _is_valid_path(p):
|
|
56
|
+
continue
|
|
57
|
+
if p.suffix in _CODE_EXTENSIONS:
|
|
58
|
+
counts[p.suffix] = counts.get(p.suffix, 0) + 1
|
|
59
|
+
if not counts:
|
|
60
|
+
return []
|
|
61
|
+
extensions = sorted(counts, key=counts.get, reverse=True)
|
|
62
|
+
pass # auto-detected extensions
|
|
63
|
+
|
|
64
|
+
files = []
|
|
65
|
+
for p in sorted(dir_path.rglob("*")):
|
|
66
|
+
if not p.is_file() or not _is_valid_path(p):
|
|
67
|
+
continue
|
|
68
|
+
if extensions and p.suffix not in extensions:
|
|
69
|
+
continue
|
|
70
|
+
if excludes and any(fnmatch.fnmatch(p.name, pat) for pat in excludes):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
text = _read_file(p)
|
|
74
|
+
if text is None:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
rel = p.relative_to(dir_path)
|
|
78
|
+
files.append((rel, text, p.suffix))
|
|
79
|
+
|
|
80
|
+
return files
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def files_to_content(files: list[tuple[Path, str, str]]) -> str:
|
|
84
|
+
"""Convert file list to concatenated content string with headers."""
|
|
85
|
+
parts = []
|
|
86
|
+
total = 0
|
|
87
|
+
for path, text, _ext in files:
|
|
88
|
+
chunk = f"--- {path} ---\n{text}\n"
|
|
89
|
+
total += len(chunk)
|
|
90
|
+
if total > MAX_CONTENT_CHARS:
|
|
91
|
+
parts.append(f"\n[truncated at {MAX_CONTENT_CHARS // 1000}k chars]\n")
|
|
92
|
+
break
|
|
93
|
+
parts.append(chunk)
|
|
94
|
+
return "\n".join(parts)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _run_git(cmd: list[str]) -> str:
|
|
98
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
raise RuntimeError(f"git failed: {result.stderr.strip()}")
|
|
101
|
+
return result.stdout
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_context(args) -> tuple[str, str, list[tuple[Path, str, str]] | None]:
|
|
105
|
+
"""Returns (content, claim, files_or_none).
|
|
106
|
+
|
|
107
|
+
When files is not None, content selection can be applied before debate.
|
|
108
|
+
When files is None, content is already final (user picked it explicitly).
|
|
109
|
+
"""
|
|
110
|
+
content = ""
|
|
111
|
+
files = None
|
|
112
|
+
|
|
113
|
+
if args.dir is not None:
|
|
114
|
+
dir_path = Path(args.dir) if args.dir else Path(".")
|
|
115
|
+
exts = args.ext or None
|
|
116
|
+
excludes = args.exclude or None
|
|
117
|
+
files = _collect_files(dir_path, exts, excludes)
|
|
118
|
+
content = files_to_content(files)
|
|
119
|
+
elif args.file:
|
|
120
|
+
parts = []
|
|
121
|
+
for f in args.file:
|
|
122
|
+
p = Path(f)
|
|
123
|
+
if not p.exists():
|
|
124
|
+
print(f"warning: {f} not found, skipping", file=sys.stderr)
|
|
125
|
+
continue
|
|
126
|
+
text = _read_file(p)
|
|
127
|
+
if text is not None:
|
|
128
|
+
parts.append(f"--- {p.name} ---\n{text}\n")
|
|
129
|
+
content = "\n".join(parts)
|
|
130
|
+
elif args.git:
|
|
131
|
+
content = _run_git(["git", "diff"])
|
|
132
|
+
elif args.git_staged:
|
|
133
|
+
content = _run_git(["git", "diff", "--staged"])
|
|
134
|
+
elif args.git_branch:
|
|
135
|
+
content = _run_git(["git", "diff", f"{args.git_branch}...HEAD"])
|
|
136
|
+
elif args.context:
|
|
137
|
+
content = args.context
|
|
138
|
+
elif not sys.stdin.isatty():
|
|
139
|
+
content = sys.stdin.read()
|
|
140
|
+
else:
|
|
141
|
+
# No content flag — auto-scan current directory
|
|
142
|
+
cwd = Path(".")
|
|
143
|
+
files = _collect_files(cwd, None, None)
|
|
144
|
+
if files:
|
|
145
|
+
content = files_to_content(files)
|
|
146
|
+
|
|
147
|
+
content = content.strip()
|
|
148
|
+
|
|
149
|
+
if len(content) > MAX_CONTENT_CHARS:
|
|
150
|
+
content = content[:MAX_CONTENT_CHARS] + f"\n[truncated at {MAX_CONTENT_CHARS // 1000}k chars]"
|
|
151
|
+
print(f"warning: content truncated to {MAX_CONTENT_CHARS // 1000}k chars", file=sys.stderr)
|
|
152
|
+
|
|
153
|
+
return content, args.claim.strip(), files
|