codeforerunner 0.3.1__py3-none-any.whl → 0.3.2__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.
- codeforerunner/check.py +116 -23
- codeforerunner/cli.py +25 -0
- codeforerunner/doctor.py +20 -0
- codeforerunner/providers/anthropic.py +57 -0
- codeforerunner/providers/base.py +9 -1
- codeforerunner/providers/google.py +50 -0
- codeforerunner/providers/ollama.py +45 -0
- codeforerunner/providers/openai.py +54 -0
- {codeforerunner-0.3.1.dist-info → codeforerunner-0.3.2.dist-info}/METADATA +1 -1
- {codeforerunner-0.3.1.dist-info → codeforerunner-0.3.2.dist-info}/RECORD +14 -14
- {codeforerunner-0.3.1.dist-info → codeforerunner-0.3.2.dist-info}/WHEEL +0 -0
- {codeforerunner-0.3.1.dist-info → codeforerunner-0.3.2.dist-info}/entry_points.txt +0 -0
- {codeforerunner-0.3.1.dist-info → codeforerunner-0.3.2.dist-info}/licenses/LICENSE.md +0 -0
- {codeforerunner-0.3.1.dist-info → codeforerunner-0.3.2.dist-info}/top_level.txt +0 -0
codeforerunner/check.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
"""Drift detection for docs
|
|
1
|
+
"""Drift detection for docs vs repo state."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import fnmatch
|
|
5
5
|
import re
|
|
6
|
-
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from codeforerunner.config import CheckConfig
|
|
@@ -17,8 +17,18 @@ class Violation:
|
|
|
17
17
|
message: str
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class _Rule:
|
|
22
|
+
id: str
|
|
23
|
+
pattern: re.Pattern
|
|
24
|
+
triggers: tuple[str, ...]
|
|
25
|
+
message: str
|
|
26
|
+
invert: bool = False # True = fire when triggers ABSENT (doc claims feature exists but file gone)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_RULES: list[_Rule] = [
|
|
30
|
+
# Normal rules: fire when trigger EXISTS and phrase matches (doc denies a thing that's present)
|
|
31
|
+
_Rule(
|
|
22
32
|
"R1-no-cli",
|
|
23
33
|
re.compile(
|
|
24
34
|
r"(?i)no\s+CLI\s+exists"
|
|
@@ -29,56 +39,87 @@ _RULES = [
|
|
|
29
39
|
("src/codeforerunner/cli.py",),
|
|
30
40
|
"doc claims no CLI exists, but src/codeforerunner/cli.py is present",
|
|
31
41
|
),
|
|
32
|
-
(
|
|
42
|
+
_Rule(
|
|
33
43
|
"R2-no-pre-commit",
|
|
34
44
|
re.compile(r"(?i)no\s+pre[- ]commit(\s+hook)?"),
|
|
35
45
|
(".pre-commit-hooks.yaml",),
|
|
36
46
|
"doc claims no pre-commit hook, but .pre-commit-hooks.yaml is present",
|
|
37
47
|
),
|
|
38
|
-
(
|
|
48
|
+
_Rule(
|
|
39
49
|
"R3-no-ci",
|
|
40
50
|
re.compile(r"(?i)no\s+CI(\s+workflow)?"),
|
|
41
51
|
(".github/workflows/*.yml",),
|
|
42
52
|
"doc claims no CI workflow, but .github/workflows/*.yml is present",
|
|
43
53
|
),
|
|
44
|
-
(
|
|
54
|
+
_Rule(
|
|
45
55
|
"R4-no-installer",
|
|
46
56
|
re.compile(r"(?i)no\s+installer"),
|
|
47
57
|
("src/codeforerunner/installer.py",),
|
|
48
58
|
"doc claims no installer, but src/codeforerunner/installer.py is present",
|
|
49
59
|
),
|
|
50
|
-
(
|
|
60
|
+
_Rule(
|
|
51
61
|
"R5-no-python-package",
|
|
52
62
|
re.compile(r"(?i)no\s+Python\s+package"),
|
|
53
63
|
("pyproject.toml",),
|
|
54
64
|
"doc claims no Python package, but pyproject.toml is present",
|
|
55
65
|
),
|
|
56
|
-
(
|
|
66
|
+
_Rule(
|
|
57
67
|
"R6-no-docker",
|
|
58
68
|
re.compile(r"(?i)no\s+Docker(\s+image)?|no\s+Dockerfile"),
|
|
59
69
|
("Dockerfile", "compose.yml", "docker-compose.yml"),
|
|
60
70
|
"doc claims no Docker, but Dockerfile/compose file is present",
|
|
61
71
|
),
|
|
62
|
-
(
|
|
72
|
+
_Rule(
|
|
63
73
|
"R6b-no-makefile",
|
|
64
74
|
re.compile(r"(?i)no\s+Makefile"),
|
|
65
75
|
("Makefile",),
|
|
66
76
|
"doc claims no Makefile, but Makefile is present",
|
|
67
77
|
),
|
|
68
|
-
(
|
|
78
|
+
_Rule(
|
|
69
79
|
"R7-no-mcp",
|
|
70
80
|
re.compile(r"(?i)no\s+MCP(\s+server)?"),
|
|
71
81
|
("src/codeforerunner/mcp_server.py",),
|
|
72
82
|
"doc claims no MCP server, but src/codeforerunner/mcp_server.py is present",
|
|
73
83
|
),
|
|
74
|
-
(
|
|
84
|
+
_Rule(
|
|
75
85
|
"R8-no-marketplace",
|
|
76
86
|
re.compile(r"(?i)no\s+marketplace(\s+manifest)?"),
|
|
77
87
|
("plugins/codex/marketplace.json",),
|
|
78
88
|
"doc claims no marketplace, but plugins/codex/marketplace.json is present",
|
|
79
89
|
),
|
|
90
|
+
# Inverse rules: fire when trigger ABSENT and phrase matches (doc claims thing exists but file gone)
|
|
91
|
+
_Rule(
|
|
92
|
+
"RI1-missing-cli",
|
|
93
|
+
re.compile(
|
|
94
|
+
r"(?i)\bforerunner\s+(?:init|scan|doc|check|generate|doctor)\b"
|
|
95
|
+
),
|
|
96
|
+
("src/codeforerunner/cli.py",),
|
|
97
|
+
"doc references forerunner CLI commands, but src/codeforerunner/cli.py is absent",
|
|
98
|
+
invert=True,
|
|
99
|
+
),
|
|
100
|
+
_Rule(
|
|
101
|
+
"RI5-missing-python-package",
|
|
102
|
+
re.compile(r"(?i)\bpipx?\s+install\s+codeforerunner\b"),
|
|
103
|
+
("pyproject.toml",),
|
|
104
|
+
"doc claims package is installable via pip/pipx, but pyproject.toml is absent",
|
|
105
|
+
invert=True,
|
|
106
|
+
),
|
|
107
|
+
_Rule(
|
|
108
|
+
"RI7-missing-mcp",
|
|
109
|
+
re.compile(r"(?i)\bforerunner\s+mcp-server\b"),
|
|
110
|
+
("src/codeforerunner/mcp_server.py",),
|
|
111
|
+
"doc references forerunner mcp-server, but src/codeforerunner/mcp_server.py is absent",
|
|
112
|
+
invert=True,
|
|
113
|
+
),
|
|
80
114
|
]
|
|
81
115
|
|
|
116
|
+
_VERSION_PIN_RE = re.compile(
|
|
117
|
+
r"(?:codeforerunner==|codeforerunner@v)"
|
|
118
|
+
r"(\d+\.\d+\.\d+)"
|
|
119
|
+
)
|
|
120
|
+
_PYPROJECT_VERSION_RE = re.compile(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', re.MULTILINE)
|
|
121
|
+
_CHANGELOG_FILENAME = "CHANGELOG.md"
|
|
122
|
+
|
|
82
123
|
|
|
83
124
|
def _trigger_exists(repo: Path, patterns: tuple[str, ...]) -> bool:
|
|
84
125
|
for pat in patterns:
|
|
@@ -114,6 +155,54 @@ def _path_ignored(repo: Path, doc: Path, ignore_patterns: tuple[str, ...]) -> bo
|
|
|
114
155
|
return any(fnmatch.fnmatch(rel, pat) for pat in ignore_patterns)
|
|
115
156
|
|
|
116
157
|
|
|
158
|
+
def _current_version(repo: Path) -> str | None:
|
|
159
|
+
pyproject = repo / "pyproject.toml"
|
|
160
|
+
if not pyproject.is_file():
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
164
|
+
except OSError:
|
|
165
|
+
return None
|
|
166
|
+
m = _PYPROJECT_VERSION_RE.search(text)
|
|
167
|
+
return m.group(1) if m else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _check_version_drift(
|
|
171
|
+
repo: Path,
|
|
172
|
+
docs: list[Path],
|
|
173
|
+
ignore_patterns: tuple[str, ...],
|
|
174
|
+
enabled: set[str] | None,
|
|
175
|
+
) -> list[Violation]:
|
|
176
|
+
if enabled is not None and "RV1-version-drift" not in enabled:
|
|
177
|
+
return []
|
|
178
|
+
current = _current_version(repo)
|
|
179
|
+
if current is None:
|
|
180
|
+
return []
|
|
181
|
+
violations: list[Violation] = []
|
|
182
|
+
for doc in docs:
|
|
183
|
+
if doc.name == _CHANGELOG_FILENAME:
|
|
184
|
+
continue
|
|
185
|
+
if _path_ignored(repo, doc, ignore_patterns):
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
text = doc.read_text(encoding="utf-8")
|
|
189
|
+
except (OSError, UnicodeDecodeError):
|
|
190
|
+
continue
|
|
191
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
192
|
+
for m in _VERSION_PIN_RE.finditer(line):
|
|
193
|
+
pinned = m.group(1)
|
|
194
|
+
if pinned != current:
|
|
195
|
+
violations.append(
|
|
196
|
+
Violation(
|
|
197
|
+
path=doc,
|
|
198
|
+
line=lineno,
|
|
199
|
+
rule_id="RV1-version-drift",
|
|
200
|
+
message=f"version pin {pinned!r} does not match current {current!r}",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return violations
|
|
204
|
+
|
|
205
|
+
|
|
117
206
|
def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
|
|
118
207
|
"""Scan repo docs for drift; return list of violations.
|
|
119
208
|
|
|
@@ -124,16 +213,18 @@ def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
|
|
|
124
213
|
enabled = set(config.enabled_rules) if (config and config.enabled_rules is not None) else None
|
|
125
214
|
ignore_patterns = config.ignore_paths if config else ()
|
|
126
215
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
216
|
+
docs = _scanned_docs(repo)
|
|
217
|
+
|
|
218
|
+
active_rules: list[_Rule] = []
|
|
219
|
+
for rule in _RULES:
|
|
220
|
+
if enabled is not None and rule.id not in enabled:
|
|
221
|
+
continue
|
|
222
|
+
trigger_found = _trigger_exists(repo, rule.triggers)
|
|
223
|
+
if (not rule.invert and trigger_found) or (rule.invert and not trigger_found):
|
|
224
|
+
active_rules.append(rule)
|
|
134
225
|
|
|
135
226
|
violations: list[Violation] = []
|
|
136
|
-
for doc in
|
|
227
|
+
for doc in docs:
|
|
137
228
|
if _path_ignored(repo, doc, ignore_patterns):
|
|
138
229
|
continue
|
|
139
230
|
try:
|
|
@@ -141,11 +232,13 @@ def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
|
|
|
141
232
|
except (OSError, UnicodeDecodeError):
|
|
142
233
|
continue
|
|
143
234
|
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
144
|
-
for
|
|
145
|
-
if
|
|
235
|
+
for rule in active_rules:
|
|
236
|
+
if rule.pattern.search(line):
|
|
146
237
|
violations.append(
|
|
147
|
-
Violation(path=doc, line=lineno, rule_id=
|
|
238
|
+
Violation(path=doc, line=lineno, rule_id=rule.id, message=rule.message)
|
|
148
239
|
)
|
|
240
|
+
|
|
241
|
+
violations.extend(_check_version_drift(repo, docs, ignore_patterns, enabled))
|
|
149
242
|
return violations
|
|
150
243
|
|
|
151
244
|
|
codeforerunner/cli.py
CHANGED
|
@@ -135,6 +135,17 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
135
135
|
print(f"error: missing API key; set ${env_var}", file=sys.stderr)
|
|
136
136
|
return 3
|
|
137
137
|
|
|
138
|
+
if getattr(args, "stream", False):
|
|
139
|
+
try:
|
|
140
|
+
for chunk in provider.stream(prompt=buf.getvalue(), model=model, api_key=api_key):
|
|
141
|
+
sys.stdout.write(chunk)
|
|
142
|
+
sys.stdout.flush()
|
|
143
|
+
except _providers.ProviderError as e:
|
|
144
|
+
print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
|
|
145
|
+
return 4
|
|
146
|
+
sys.stdout.write("\n")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
138
149
|
try:
|
|
139
150
|
result = provider.complete(prompt=buf.getvalue(), model=model, api_key=api_key)
|
|
140
151
|
except _providers.ProviderError as e:
|
|
@@ -151,7 +162,15 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
151
162
|
|
|
152
163
|
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
153
164
|
from codeforerunner import doctor
|
|
165
|
+
from codeforerunner.config import CONFIG_FILENAME
|
|
154
166
|
root = Path(args.repo).resolve() if args.repo else Path.cwd()
|
|
167
|
+
if getattr(args, "fix", False):
|
|
168
|
+
cfg_path = root / CONFIG_FILENAME
|
|
169
|
+
if not cfg_path.is_file():
|
|
170
|
+
cfg_path.write_text(doctor.starter_config(), encoding="utf-8")
|
|
171
|
+
print(f"wrote {cfg_path}", file=sys.stderr)
|
|
172
|
+
else:
|
|
173
|
+
print(f"{cfg_path} already exists; skipping --fix", file=sys.stderr)
|
|
155
174
|
findings = doctor.run(root)
|
|
156
175
|
sys.stdout.write(doctor.format_report(findings) + "\n")
|
|
157
176
|
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
@@ -200,12 +219,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
200
219
|
s_mcp.set_defaults(func=cmd_mcp_server)
|
|
201
220
|
|
|
202
221
|
s_doctor = sub.add_parser("doctor", help="health report: skill parity + marketplace + installed dests")
|
|
222
|
+
s_doctor.add_argument(
|
|
223
|
+
"--fix",
|
|
224
|
+
action="store_true",
|
|
225
|
+
help="write a starter forerunner.config.yaml if absent",
|
|
226
|
+
)
|
|
203
227
|
s_doctor.set_defaults(func=cmd_doctor)
|
|
204
228
|
|
|
205
229
|
s_gen = sub.add_parser("generate", help="resolve bundle for <task> and call the configured provider")
|
|
206
230
|
s_gen.add_argument("task", help="task basename under prompts/tasks/")
|
|
207
231
|
s_gen.add_argument("--provider", help="override config provider")
|
|
208
232
|
s_gen.add_argument("--model", help="override config model")
|
|
233
|
+
s_gen.add_argument("--stream", action="store_true", help="stream output token-by-token")
|
|
209
234
|
s_gen.set_defaults(func=cmd_generate)
|
|
210
235
|
|
|
211
236
|
from codeforerunner import installer
|
codeforerunner/doctor.py
CHANGED
|
@@ -274,6 +274,26 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
|
274
274
|
]
|
|
275
275
|
|
|
276
276
|
|
|
277
|
+
_STARTER_CONFIG = """\
|
|
278
|
+
# forerunner.config.yaml — generated by `forerunner doctor --fix`
|
|
279
|
+
# See https://github.com/derek-palmer/codeforerunner for docs.
|
|
280
|
+
|
|
281
|
+
enabled_rules:
|
|
282
|
+
- R1-no-cli
|
|
283
|
+
- R2-no-pre-commit
|
|
284
|
+
- R3-no-ci
|
|
285
|
+
- R4-no-installer
|
|
286
|
+
- R5-no-python-package
|
|
287
|
+
- R7-no-mcp
|
|
288
|
+
- R8-no-marketplace
|
|
289
|
+
ignore_paths: []
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def starter_config() -> str:
|
|
294
|
+
return _STARTER_CONFIG
|
|
295
|
+
|
|
296
|
+
|
|
277
297
|
def run(repo: Path) -> list[Finding]:
|
|
278
298
|
repo = repo.resolve()
|
|
279
299
|
findings: list[Finding] = []
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import urllib.error
|
|
7
7
|
import urllib.request
|
|
8
|
+
from typing import Iterator
|
|
8
9
|
|
|
9
10
|
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
10
11
|
|
|
@@ -59,3 +60,59 @@ class AnthropicProvider:
|
|
|
59
60
|
return CompletionResult(
|
|
60
61
|
text=text, model=data.get("model", model), usage=data.get("usage")
|
|
61
62
|
)
|
|
63
|
+
|
|
64
|
+
def stream(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
prompt: str,
|
|
68
|
+
model: str | None = None,
|
|
69
|
+
api_key: str | None = None,
|
|
70
|
+
) -> Iterator[str]:
|
|
71
|
+
if not api_key:
|
|
72
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
73
|
+
model = model or self.default_model
|
|
74
|
+
body = json.dumps(
|
|
75
|
+
{
|
|
76
|
+
"model": model,
|
|
77
|
+
"max_tokens": 4096,
|
|
78
|
+
"stream": True,
|
|
79
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
80
|
+
}
|
|
81
|
+
).encode("utf-8")
|
|
82
|
+
req = urllib.request.Request(
|
|
83
|
+
self.endpoint,
|
|
84
|
+
data=body,
|
|
85
|
+
method="POST",
|
|
86
|
+
headers={
|
|
87
|
+
"x-api-key": api_key,
|
|
88
|
+
"anthropic-version": "2023-06-01",
|
|
89
|
+
"content-type": "application/json",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
resp = urllib.request.urlopen(req)
|
|
94
|
+
except urllib.error.HTTPError as e:
|
|
95
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
97
|
+
except urllib.error.URLError as e:
|
|
98
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
99
|
+
try:
|
|
100
|
+
for raw_line in resp:
|
|
101
|
+
line = raw_line.decode("utf-8").rstrip("\n")
|
|
102
|
+
if not line.startswith("data: "):
|
|
103
|
+
continue
|
|
104
|
+
payload = line[6:]
|
|
105
|
+
if payload.strip() == "[DONE]":
|
|
106
|
+
break
|
|
107
|
+
try:
|
|
108
|
+
event = json.loads(payload)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
continue
|
|
111
|
+
if event.get("type") == "content_block_delta":
|
|
112
|
+
delta = event.get("delta", {})
|
|
113
|
+
if delta.get("type") == "text_delta":
|
|
114
|
+
text = delta.get("text", "")
|
|
115
|
+
if text:
|
|
116
|
+
yield text
|
|
117
|
+
finally:
|
|
118
|
+
resp.close()
|
codeforerunner/providers/base.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Protocol
|
|
6
|
+
from typing import Iterator, Protocol
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
@dataclass(frozen=True)
|
|
@@ -26,6 +26,14 @@ class Provider(Protocol):
|
|
|
26
26
|
api_key: str | None = None,
|
|
27
27
|
) -> CompletionResult: ...
|
|
28
28
|
|
|
29
|
+
def stream(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
prompt: str,
|
|
33
|
+
model: str | None = None,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
) -> Iterator[str]: ...
|
|
36
|
+
|
|
29
37
|
|
|
30
38
|
class ProviderError(Exception):
|
|
31
39
|
"""Raised on provider HTTP failures, missing keys, or malformed responses."""
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import urllib.error
|
|
7
7
|
import urllib.parse
|
|
8
8
|
import urllib.request
|
|
9
|
+
from typing import Iterator
|
|
9
10
|
|
|
10
11
|
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
11
12
|
|
|
@@ -18,6 +19,10 @@ class GoogleProvider:
|
|
|
18
19
|
endpoint_template = (
|
|
19
20
|
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
|
|
20
21
|
)
|
|
22
|
+
stream_endpoint_template = (
|
|
23
|
+
"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent"
|
|
24
|
+
"?key={key}&alt=sse"
|
|
25
|
+
)
|
|
21
26
|
|
|
22
27
|
def complete(
|
|
23
28
|
self,
|
|
@@ -60,3 +65,48 @@ class GoogleProvider:
|
|
|
60
65
|
model=data.get("modelVersion", model),
|
|
61
66
|
usage=data.get("usageMetadata"),
|
|
62
67
|
)
|
|
68
|
+
|
|
69
|
+
def stream(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
prompt: str,
|
|
73
|
+
model: str | None = None,
|
|
74
|
+
api_key: str | None = None,
|
|
75
|
+
) -> Iterator[str]:
|
|
76
|
+
if not api_key:
|
|
77
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
78
|
+
model = model or self.default_model
|
|
79
|
+
url = self.stream_endpoint_template.format(
|
|
80
|
+
model=urllib.parse.quote(model, safe=""),
|
|
81
|
+
key=urllib.parse.quote(api_key, safe=""),
|
|
82
|
+
)
|
|
83
|
+
body = json.dumps(
|
|
84
|
+
{"contents": [{"parts": [{"text": prompt}]}]}
|
|
85
|
+
).encode("utf-8")
|
|
86
|
+
req = urllib.request.Request(
|
|
87
|
+
url,
|
|
88
|
+
data=body,
|
|
89
|
+
method="POST",
|
|
90
|
+
headers={"content-type": "application/json"},
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
resp = urllib.request.urlopen(req)
|
|
94
|
+
except urllib.error.HTTPError as e:
|
|
95
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
97
|
+
except urllib.error.URLError as e:
|
|
98
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
99
|
+
try:
|
|
100
|
+
for raw_line in resp:
|
|
101
|
+
line = raw_line.decode("utf-8").rstrip("\n")
|
|
102
|
+
if not line.startswith("data: "):
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
event = json.loads(line[6:])
|
|
106
|
+
text = event["candidates"][0]["content"]["parts"][0]["text"]
|
|
107
|
+
if text:
|
|
108
|
+
yield text
|
|
109
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
|
|
110
|
+
continue
|
|
111
|
+
finally:
|
|
112
|
+
resp.close()
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import urllib.error
|
|
8
8
|
import urllib.request
|
|
9
|
+
from typing import Iterator
|
|
9
10
|
|
|
10
11
|
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
11
12
|
|
|
@@ -54,3 +55,47 @@ class OllamaProvider:
|
|
|
54
55
|
usage_keys = ("prompt_eval_count", "eval_count", "total_duration")
|
|
55
56
|
usage = {k: data[k] for k in usage_keys if k in data} or None
|
|
56
57
|
return CompletionResult(text=text, model=data.get("model", model), usage=usage)
|
|
58
|
+
|
|
59
|
+
def stream(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
prompt: str,
|
|
63
|
+
model: str | None = None,
|
|
64
|
+
api_key: str | None = None,
|
|
65
|
+
) -> Iterator[str]:
|
|
66
|
+
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
67
|
+
base = base.rstrip("/")
|
|
68
|
+
model = model or self.default_model
|
|
69
|
+
url = f"{base}/api/generate"
|
|
70
|
+
body = json.dumps(
|
|
71
|
+
{"model": model, "prompt": prompt, "stream": True}
|
|
72
|
+
).encode("utf-8")
|
|
73
|
+
req = urllib.request.Request(
|
|
74
|
+
url,
|
|
75
|
+
data=body,
|
|
76
|
+
method="POST",
|
|
77
|
+
headers={"content-type": "application/json"},
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
resp = urllib.request.urlopen(req)
|
|
81
|
+
except urllib.error.HTTPError as e:
|
|
82
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
83
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
84
|
+
except urllib.error.URLError as e:
|
|
85
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
86
|
+
try:
|
|
87
|
+
for raw_line in resp:
|
|
88
|
+
line = raw_line.decode("utf-8").strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
event = json.loads(line)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
continue
|
|
95
|
+
text = event.get("response", "")
|
|
96
|
+
if text:
|
|
97
|
+
yield text
|
|
98
|
+
if event.get("done", False):
|
|
99
|
+
break
|
|
100
|
+
finally:
|
|
101
|
+
resp.close()
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import urllib.error
|
|
7
7
|
import urllib.request
|
|
8
|
+
from typing import Iterator
|
|
8
9
|
|
|
9
10
|
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
10
11
|
|
|
@@ -57,3 +58,56 @@ class OpenAIProvider:
|
|
|
57
58
|
return CompletionResult(
|
|
58
59
|
text=text, model=data.get("model", model), usage=data.get("usage")
|
|
59
60
|
)
|
|
61
|
+
|
|
62
|
+
def stream(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
prompt: str,
|
|
66
|
+
model: str | None = None,
|
|
67
|
+
api_key: str | None = None,
|
|
68
|
+
) -> Iterator[str]:
|
|
69
|
+
if not api_key:
|
|
70
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
71
|
+
model = model or self.default_model
|
|
72
|
+
body = json.dumps(
|
|
73
|
+
{
|
|
74
|
+
"model": model,
|
|
75
|
+
"stream": True,
|
|
76
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
77
|
+
}
|
|
78
|
+
).encode("utf-8")
|
|
79
|
+
req = urllib.request.Request(
|
|
80
|
+
self.endpoint,
|
|
81
|
+
data=body,
|
|
82
|
+
method="POST",
|
|
83
|
+
headers={
|
|
84
|
+
"Authorization": f"Bearer {api_key}",
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
resp = urllib.request.urlopen(req)
|
|
90
|
+
except urllib.error.HTTPError as e:
|
|
91
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
92
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
93
|
+
except urllib.error.URLError as e:
|
|
94
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
95
|
+
try:
|
|
96
|
+
for raw_line in resp:
|
|
97
|
+
line = raw_line.decode("utf-8").rstrip("\n")
|
|
98
|
+
if not line.startswith("data: "):
|
|
99
|
+
continue
|
|
100
|
+
payload = line[6:]
|
|
101
|
+
if payload.strip() == "[DONE]":
|
|
102
|
+
break
|
|
103
|
+
try:
|
|
104
|
+
event = json.loads(payload)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
continue
|
|
107
|
+
choices = event.get("choices", [])
|
|
108
|
+
if choices:
|
|
109
|
+
text = choices[0].get("delta", {}).get("content")
|
|
110
|
+
if text:
|
|
111
|
+
yield text
|
|
112
|
+
finally:
|
|
113
|
+
resp.close()
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
codeforerunner/__init__.py,sha256=4ItVS_FzLddTK77jExpkV3QJ1nHl2Bh-QIujM7Hg_5w,205
|
|
2
2
|
codeforerunner/bundle.py,sha256=wWlhNaja8HPLzN-9pxiSrFD8X0mGi-c1HM9HRyVhmzk,2020
|
|
3
|
-
codeforerunner/check.py,sha256=
|
|
4
|
-
codeforerunner/cli.py,sha256=
|
|
3
|
+
codeforerunner/check.py,sha256=5sJVwMMSvVCrRmwBIunYbn2pINroUjONrVJAs5ov3O4,8188
|
|
4
|
+
codeforerunner/cli.py,sha256=Rk6T7G3QC78-HpxvGKONQ9zIauZw5LceBHurFechJuU,9032
|
|
5
5
|
codeforerunner/config.py,sha256=REs4FgmSSn7R2tLIwLJPL8VmSCGkTvw8J2SqBOggzho,6206
|
|
6
|
-
codeforerunner/doctor.py,sha256=
|
|
6
|
+
codeforerunner/doctor.py,sha256=dsnLfB7ZuYTM7HG9JxzLl63GEmhr_miMRZy9RPq7z_8,10456
|
|
7
7
|
codeforerunner/installer.py,sha256=9Ze_hyjvow-if-NMovk9CzVDYVZSR3vyLO6s2vhOs6g,10227
|
|
8
8
|
codeforerunner/mcp_server.py,sha256=oIfuAR7e_rH--B1aLOATVflyWAyGpkyeeXI4SAI4eTg,4657
|
|
9
9
|
codeforerunner/prompts/partials/context-format.md,sha256=WNfkr4kf2Awj0R8wLOrFotEiYCe6hfKTq5eA3Rt5_Xw,817
|
|
@@ -23,14 +23,14 @@ codeforerunner/prompts/tasks/scan.md,sha256=hYXf-IL1kgpBPHJapRrwTu88cLZ7y3bCmAq9
|
|
|
23
23
|
codeforerunner/prompts/tasks/stack-docs.md,sha256=Dy-JSXpSmHSyhR5shQBXKa_F0PqnjPcmtljthYZpaiM,1923
|
|
24
24
|
codeforerunner/prompts/tasks/version-audit.md,sha256=oK-pcoxt_VcvDOlj1Sz9OlEhXlcViLPn54r-qP5WfiA,5833
|
|
25
25
|
codeforerunner/providers/__init__.py,sha256=ttMAbHWJIO8s-8H6Kb_EWf3LN5oMzlmX1D12RyGSmIg,962
|
|
26
|
-
codeforerunner/providers/anthropic.py,sha256=
|
|
27
|
-
codeforerunner/providers/base.py,sha256=
|
|
28
|
-
codeforerunner/providers/google.py,sha256=
|
|
29
|
-
codeforerunner/providers/ollama.py,sha256=
|
|
30
|
-
codeforerunner/providers/openai.py,sha256=
|
|
31
|
-
codeforerunner-0.3.
|
|
32
|
-
codeforerunner-0.3.
|
|
33
|
-
codeforerunner-0.3.
|
|
34
|
-
codeforerunner-0.3.
|
|
35
|
-
codeforerunner-0.3.
|
|
36
|
-
codeforerunner-0.3.
|
|
26
|
+
codeforerunner/providers/anthropic.py,sha256=oPi4oLHcHEDs8gK-W5Jt06Ef5N23axFTyXhO-2YcCkk,4049
|
|
27
|
+
codeforerunner/providers/base.py,sha256=MMrOUVOXHWP1td-TndxhLhDyDPJZGExZCeFopZUSRCo,923
|
|
28
|
+
codeforerunner/providers/google.py,sha256=OWEE0FNupFWmZCeilIrgYUYDHH1iWvIwHEEsHYQoFFY,3979
|
|
29
|
+
codeforerunner/providers/ollama.py,sha256=k6keO6eN-iwLFLtYETMMMoAyjxJLO1x16p_rM2TGks0,3524
|
|
30
|
+
codeforerunner/providers/openai.py,sha256=999ZzIVh0cqW4xDnzK_NACqfJxNziHwpVjwmw9_jjRw,3825
|
|
31
|
+
codeforerunner-0.3.2.dist-info/licenses/LICENSE.md,sha256=iIhmJHib6GbdjcwiDMM-npiNRf3XgASom1WsOJivEdc,2915
|
|
32
|
+
codeforerunner-0.3.2.dist-info/METADATA,sha256=vCvhbtoSliPMvDFYubsockBIAMLg6JUVkbqsaMBu2zg,7737
|
|
33
|
+
codeforerunner-0.3.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
34
|
+
codeforerunner-0.3.2.dist-info/entry_points.txt,sha256=3p8BbPlq-wfcXk42tsweKePRaGlZ1WXho1gOkuZGyIQ,55
|
|
35
|
+
codeforerunner-0.3.2.dist-info/top_level.txt,sha256=pV1rt0-NIpNEotKXpL_sF2060DHr-_0F86LWhUlvXis,15
|
|
36
|
+
codeforerunner-0.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|