codeforerunner 0.3.1__tar.gz → 0.3.2__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.
Files changed (54) hide show
  1. {codeforerunner-0.3.1/src/codeforerunner.egg-info → codeforerunner-0.3.2}/PKG-INFO +1 -1
  2. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/pyproject.toml +1 -1
  3. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/check.py +116 -23
  4. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/cli.py +25 -0
  5. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/doctor.py +20 -0
  6. codeforerunner-0.3.2/src/codeforerunner/providers/anthropic.py +118 -0
  7. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/base.py +9 -1
  8. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/google.py +50 -0
  9. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/ollama.py +45 -0
  10. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/openai.py +54 -0
  11. {codeforerunner-0.3.1 → codeforerunner-0.3.2/src/codeforerunner.egg-info}/PKG-INFO +1 -1
  12. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_check.py +114 -0
  13. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_doctor.py +40 -1
  14. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_providers.py +177 -0
  15. codeforerunner-0.3.1/src/codeforerunner/providers/anthropic.py +0 -61
  16. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/LICENSE.md +0 -0
  17. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/README.md +0 -0
  18. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/setup.cfg +0 -0
  19. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/__init__.py +0 -0
  20. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/bundle.py +0 -0
  21. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/config.py +0 -0
  22. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/installer.py +0 -0
  23. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/mcp_server.py +0 -0
  24. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/partials/context-format.md +0 -0
  25. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/partials/output-rules.md +0 -0
  26. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/partials/stack-hints.md +0 -0
  27. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/system/base.md +0 -0
  28. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/api-docs.md +0 -0
  29. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/audit.md +0 -0
  30. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/changelog.md +0 -0
  31. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/check.md +0 -0
  32. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/diagrams.md +0 -0
  33. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/flows.md +0 -0
  34. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/init-agent-onboarding.md +0 -0
  35. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/readme.md +0 -0
  36. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/review.md +0 -0
  37. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/scan.md +0 -0
  38. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/stack-docs.md +0 -0
  39. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/version-audit.md +0 -0
  40. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/__init__.py +0 -0
  41. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/SOURCES.txt +0 -0
  42. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/dependency_links.txt +0 -0
  43. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/entry_points.txt +0 -0
  44. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/requires.txt +0 -0
  45. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/top_level.txt +0 -0
  46. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_check_config_integration.py +0 -0
  47. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_cli.py +0 -0
  48. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_config.py +0 -0
  49. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_examples.py +0 -0
  50. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_hooks_manifest.py +0 -0
  51. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_installer.py +0 -0
  52. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_mcp_server.py +0 -0
  53. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_validate_codex_marketplace.py +0 -0
  54. {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_workflows_yaml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeforerunner
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Model-agnostic repository documentation tooling (prompt-first; thin CLI).
5
5
  Author: Derek Palmer
6
6
  License-Expression: LicenseRef-Codeforerunner-SAL-0.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codeforerunner"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Model-agnostic repository documentation tooling (prompt-first; thin CLI)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,9 +1,9 @@
1
- """Drift detection for docs that claim files don't exist when they do."""
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
- _RULES = [
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
- active_rules = [
128
- (rid, rx, msg)
129
- for rid, rx, triggers, msg in _RULES
130
- if _trigger_exists(repo, triggers) and (enabled is None or rid in enabled)
131
- ]
132
- if not active_rules:
133
- return []
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 _scanned_docs(repo):
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 rid, rx, msg in active_rules:
145
- if rx.search(line):
235
+ for rule in active_rules:
236
+ if rule.pattern.search(line):
146
237
  violations.append(
147
- Violation(path=doc, line=lineno, rule_id=rid, message=msg)
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
 
@@ -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
@@ -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] = []
@@ -0,0 +1,118 @@
1
+ """Anthropic Messages API provider. Stdlib HTTP only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Iterator
9
+
10
+ from codeforerunner.providers.base import CompletionResult, ProviderError
11
+
12
+
13
+ class AnthropicProvider:
14
+ name = "anthropic"
15
+ default_env_var = "ANTHROPIC_API_KEY"
16
+ default_model = "claude-opus-4-5"
17
+
18
+ endpoint = "https://api.anthropic.com/v1/messages"
19
+
20
+ def complete(
21
+ self,
22
+ *,
23
+ prompt: str,
24
+ model: str | None = None,
25
+ api_key: str | None = None,
26
+ ) -> CompletionResult:
27
+ if not api_key:
28
+ raise ProviderError(f"missing API key (set ${self.default_env_var})")
29
+ model = model or self.default_model
30
+ body = json.dumps(
31
+ {
32
+ "model": model,
33
+ "max_tokens": 4096,
34
+ "messages": [{"role": "user", "content": prompt}],
35
+ }
36
+ ).encode("utf-8")
37
+ req = urllib.request.Request(
38
+ self.endpoint,
39
+ data=body,
40
+ method="POST",
41
+ headers={
42
+ "x-api-key": api_key,
43
+ "anthropic-version": "2023-06-01",
44
+ "content-type": "application/json",
45
+ },
46
+ )
47
+ try:
48
+ with urllib.request.urlopen(req) as resp:
49
+ raw = resp.read()
50
+ except urllib.error.HTTPError as e:
51
+ snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
52
+ raise ProviderError(f"HTTP {e.code}: {snippet}") from e
53
+ except urllib.error.URLError as e:
54
+ raise ProviderError(f"network error: {e.reason}") from e
55
+ try:
56
+ data = json.loads(raw.decode("utf-8"))
57
+ text = data["content"][0]["text"]
58
+ except (json.JSONDecodeError, KeyError, IndexError, TypeError) as e:
59
+ raise ProviderError(f"malformed response: {e}") from e
60
+ return CompletionResult(
61
+ text=text, model=data.get("model", model), usage=data.get("usage")
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()
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeforerunner
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Model-agnostic repository documentation tooling (prompt-first; thin CLI).
5
5
  Author: Derek Palmer
6
6
  License-Expression: LicenseRef-Codeforerunner-SAL-0.1
@@ -271,6 +271,120 @@ def test_violation_line_number_correct(tmp_path):
271
271
  assert vs[0].line == 3
272
272
 
273
273
 
274
+ # ── Inverse rules (RI*) ────────────────────────────────────────────────────
275
+
276
+
277
+ def test_ri1_fires_when_cli_absent(tmp_path):
278
+ _seed(tmp_path, {"README.md": "Run `forerunner init` to get started.\n"})
279
+ vs = run(tmp_path)
280
+ assert any(v.rule_id == "RI1-missing-cli" for v in vs)
281
+
282
+
283
+ def test_ri1_silent_when_cli_present(tmp_path):
284
+ _seed(
285
+ tmp_path,
286
+ {
287
+ "README.md": "Run `forerunner init` to get started.\n",
288
+ "src/codeforerunner/cli.py": "# cli\n",
289
+ },
290
+ )
291
+ assert not any(v.rule_id == "RI1-missing-cli" for v in run(tmp_path))
292
+
293
+
294
+ def test_ri5_fires_when_pyproject_absent(tmp_path):
295
+ _seed(tmp_path, {"README.md": "Install with `pip install codeforerunner`.\n"})
296
+ vs = run(tmp_path)
297
+ assert any(v.rule_id == "RI5-missing-python-package" for v in vs)
298
+
299
+
300
+ def test_ri5_silent_when_pyproject_present(tmp_path):
301
+ _seed(
302
+ tmp_path,
303
+ {
304
+ "README.md": "Install with `pip install codeforerunner`.\n",
305
+ "pyproject.toml": '[project]\nname = "codeforerunner"\n',
306
+ },
307
+ )
308
+ assert not any(v.rule_id == "RI5-missing-python-package" for v in run(tmp_path))
309
+
310
+
311
+ def test_ri7_fires_when_mcp_server_absent(tmp_path):
312
+ _seed(tmp_path, {"README.md": "Start with `forerunner mcp-server`.\n"})
313
+ vs = run(tmp_path)
314
+ assert any(v.rule_id == "RI7-missing-mcp" for v in vs)
315
+
316
+
317
+ def test_ri7_silent_when_mcp_server_present(tmp_path):
318
+ _seed(
319
+ tmp_path,
320
+ {
321
+ "README.md": "Start with `forerunner mcp-server`.\n",
322
+ "src/codeforerunner/mcp_server.py": "# mcp\n",
323
+ },
324
+ )
325
+ assert not any(v.rule_id == "RI7-missing-mcp" for v in run(tmp_path))
326
+
327
+
328
+ # ── Version drift (RV1) ────────────────────────────────────────────────────
329
+
330
+
331
+ def test_rv1_fires_when_pin_mismatches(tmp_path):
332
+ _seed(
333
+ tmp_path,
334
+ {
335
+ "README.md": "Install `pip install codeforerunner==0.1.0`.\n",
336
+ "pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
337
+ },
338
+ )
339
+ vs = run(tmp_path)
340
+ rv = [v for v in vs if v.rule_id == "RV1-version-drift"]
341
+ assert len(rv) == 1
342
+ assert "0.1.0" in rv[0].message
343
+ assert "0.3.1" in rv[0].message
344
+
345
+
346
+ def test_rv1_silent_when_pin_matches(tmp_path):
347
+ _seed(
348
+ tmp_path,
349
+ {
350
+ "README.md": "Install `pip install codeforerunner==0.3.1`.\n",
351
+ "pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
352
+ },
353
+ )
354
+ assert not any(v.rule_id == "RV1-version-drift" for v in run(tmp_path))
355
+
356
+
357
+ def test_rv1_skips_changelog_md(tmp_path):
358
+ # CHANGELOG.md not in _scanned_docs; old pins there should not fire
359
+ _seed(
360
+ tmp_path,
361
+ {
362
+ "CHANGELOG.md": "## 0.1.0\n- `pip install codeforerunner==0.1.0`\n",
363
+ "pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
364
+ },
365
+ )
366
+ assert not any(v.rule_id == "RV1-version-drift" for v in run(tmp_path))
367
+
368
+
369
+ def test_rv1_silent_when_no_pyproject(tmp_path):
370
+ _seed(tmp_path, {"README.md": "See `pip install codeforerunner==0.1.0`.\n"})
371
+ assert not any(v.rule_id == "RV1-version-drift" for v in run(tmp_path))
372
+
373
+
374
+ def test_rv1_not_run_when_excluded_from_enabled_rules(tmp_path):
375
+ from codeforerunner.config import CheckConfig
376
+
377
+ _seed(
378
+ tmp_path,
379
+ {
380
+ "README.md": "Install `pip install codeforerunner==0.1.0`.\n",
381
+ "pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
382
+ },
383
+ )
384
+ cfg = CheckConfig(enabled_rules=("R1-no-cli",))
385
+ assert not any(v.rule_id == "RV1-version-drift" for v in run(tmp_path, cfg))
386
+
387
+
274
388
  def test_workflows_lint_when_actionlint_available():
275
389
  import shutil
276
390
  import subprocess
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
 
9
9
  import pytest
10
10
 
11
- from codeforerunner.doctor import Finding, format_report, main, run
11
+ from codeforerunner.doctor import Finding, format_report, main, run, starter_config
12
12
 
13
13
  REPO = Path(__file__).resolve().parent.parent
14
14
 
@@ -135,3 +135,42 @@ def test_main_exits_one_when_error_present(tmp_path: Path, capsys):
135
135
  rc = main(["--repo", str(repo)])
136
136
  capsys.readouterr()
137
137
  assert rc == 1
138
+
139
+
140
+ # ── starter_config / --fix ─────────────────────────────────────────────────
141
+
142
+
143
+ def test_starter_config_contains_expected_rules():
144
+ cfg = starter_config()
145
+ assert "R1-no-cli" in cfg
146
+ assert "R7-no-mcp" in cfg
147
+ assert "R8-no-marketplace" in cfg
148
+ assert "ignore_paths" in cfg
149
+
150
+
151
+ def test_doctor_fix_writes_config_when_absent(tmp_path: Path, capsys):
152
+ from codeforerunner.cli import main as cli_main
153
+
154
+ repo = _copy_repo_layout(tmp_path)
155
+ cfg_path = repo / "forerunner.config.yaml"
156
+ assert not cfg_path.exists()
157
+
158
+ cli_main(["--repo", str(repo), "doctor", "--fix"])
159
+ capsys.readouterr()
160
+
161
+ assert cfg_path.is_file()
162
+ content = cfg_path.read_text(encoding="utf-8")
163
+ assert "R1-no-cli" in content
164
+
165
+
166
+ def test_doctor_fix_does_not_overwrite_existing_config(tmp_path: Path, capsys):
167
+ from codeforerunner.cli import main as cli_main
168
+
169
+ repo = _copy_repo_layout(tmp_path)
170
+ cfg_path = repo / "forerunner.config.yaml"
171
+ cfg_path.write_text("# my custom config\n", encoding="utf-8")
172
+
173
+ cli_main(["--repo", str(repo), "doctor", "--fix"])
174
+ capsys.readouterr()
175
+
176
+ assert cfg_path.read_text(encoding="utf-8") == "# my custom config\n"
@@ -302,3 +302,180 @@ def test_ollama_api_key_used_as_base_url(monkeypatch):
302
302
  with patch("urllib.request.urlopen", side_effect=_fake_urlopen(fake, captured)):
303
303
  OllamaProvider().complete(prompt="hi", api_key="http://custom:8888")
304
304
  assert captured["url"] == "http://custom:8888/api/generate"
305
+
306
+
307
+ # ── Streaming helpers ─────────────────────────────────────────────────────
308
+
309
+
310
+ def _fake_stream_urlopen(lines: list[bytes]):
311
+ """Return urlopen side_effect that yields byte-lines and has .close()."""
312
+
313
+ def _opener(req, *args, **kwargs):
314
+ mock = MagicMock()
315
+ mock.__iter__ = MagicMock(return_value=iter(lines))
316
+ mock.close = MagicMock()
317
+ return mock
318
+
319
+ return _opener
320
+
321
+
322
+ def _stream_http_error(code: int):
323
+ import urllib.error
324
+
325
+ def _opener(req, *args, **kwargs):
326
+ raise urllib.error.HTTPError(
327
+ url=req.full_url, code=code, msg="err", hdrs=None, fp=io.BytesIO(b"err")
328
+ )
329
+
330
+ return _opener
331
+
332
+
333
+ # ── Anthropic stream ──────────────────────────────────────────────────────
334
+
335
+
336
+ def test_anthropic_stream_yields_text():
337
+ lines = [
338
+ b'event: content_block_delta\n',
339
+ b'data: {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "hello "}}\n',
340
+ b'\n',
341
+ b'data: {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "world"}}\n',
342
+ b'data: {"type": "message_stop"}\n',
343
+ ]
344
+ with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
345
+ chunks = list(AnthropicProvider().stream(prompt="hi", api_key="sk"))
346
+ assert chunks == ["hello ", "world"]
347
+
348
+
349
+ def test_anthropic_stream_sends_stream_true():
350
+ lines = [b'data: {"type": "message_stop"}\n']
351
+ captured: dict = {}
352
+
353
+ def _opener(req, *args, **kwargs):
354
+ captured["body"] = json.loads(req.data.decode("utf-8"))
355
+ mock = MagicMock()
356
+ mock.__iter__ = MagicMock(return_value=iter(lines))
357
+ mock.close = MagicMock()
358
+ return mock
359
+
360
+ with patch("urllib.request.urlopen", side_effect=_opener):
361
+ list(AnthropicProvider().stream(prompt="hi", api_key="sk"))
362
+ assert captured["body"]["stream"] is True
363
+
364
+
365
+ def test_anthropic_stream_missing_key_raises():
366
+ with pytest.raises(ProviderError):
367
+ list(AnthropicProvider().stream(prompt="hi", api_key=None))
368
+
369
+
370
+ def test_anthropic_stream_http_error_raises():
371
+ with patch("urllib.request.urlopen", side_effect=_stream_http_error(401)):
372
+ with pytest.raises(ProviderError, match="HTTP 401"):
373
+ list(AnthropicProvider().stream(prompt="hi", api_key="sk"))
374
+
375
+
376
+ # ── OpenAI stream ─────────────────────────────────────────────────────────
377
+
378
+
379
+ def test_openai_stream_yields_text():
380
+ lines = [
381
+ b'data: {"choices": [{"delta": {"content": "foo"}}]}\n',
382
+ b'data: {"choices": [{"delta": {"content": " bar"}}]}\n',
383
+ b'data: [DONE]\n',
384
+ ]
385
+ with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
386
+ chunks = list(OpenAIProvider().stream(prompt="hi", api_key="sk"))
387
+ assert chunks == ["foo", " bar"]
388
+
389
+
390
+ def test_openai_stream_missing_key_raises():
391
+ with pytest.raises(ProviderError):
392
+ list(OpenAIProvider().stream(prompt="hi", api_key=None))
393
+
394
+
395
+ def test_openai_stream_http_error_raises():
396
+ with patch("urllib.request.urlopen", side_effect=_stream_http_error(429)):
397
+ with pytest.raises(ProviderError, match="HTTP 429"):
398
+ list(OpenAIProvider().stream(prompt="hi", api_key="sk"))
399
+
400
+
401
+ def test_openai_stream_ignores_done_sentinel():
402
+ lines = [
403
+ b'data: {"choices": [{"delta": {"content": "x"}}]}\n',
404
+ b'data: [DONE]\n',
405
+ b'data: {"choices": [{"delta": {"content": "should not appear"}}]}\n',
406
+ ]
407
+ with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
408
+ chunks = list(OpenAIProvider().stream(prompt="hi", api_key="sk"))
409
+ assert chunks == ["x"]
410
+
411
+
412
+ # ── Google stream ─────────────────────────────────────────────────────────
413
+
414
+
415
+ def test_google_stream_yields_text():
416
+ lines = [
417
+ b'data: {"candidates": [{"content": {"parts": [{"text": "hi "}]}}]}\n',
418
+ b'data: {"candidates": [{"content": {"parts": [{"text": "there"}]}}]}\n',
419
+ ]
420
+ with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
421
+ chunks = list(GoogleProvider().stream(prompt="q", api_key="g-key"))
422
+ assert chunks == ["hi ", "there"]
423
+
424
+
425
+ def test_google_stream_uses_stream_endpoint():
426
+ lines = [b'data: {"candidates": [{"content": {"parts": [{"text": "x"}]}}]}\n']
427
+ captured: dict = {}
428
+
429
+ def _opener(req, *args, **kwargs):
430
+ captured["url"] = req.full_url
431
+ mock = MagicMock()
432
+ mock.__iter__ = MagicMock(return_value=iter(lines))
433
+ mock.close = MagicMock()
434
+ return mock
435
+
436
+ with patch("urllib.request.urlopen", side_effect=_opener):
437
+ list(GoogleProvider().stream(prompt="q", api_key="g-key"))
438
+ assert "streamGenerateContent" in captured["url"]
439
+ assert "alt=sse" in captured["url"]
440
+
441
+
442
+ def test_google_stream_missing_key_raises():
443
+ with pytest.raises(ProviderError):
444
+ list(GoogleProvider().stream(prompt="hi", api_key=None))
445
+
446
+
447
+ def test_google_stream_http_error_raises():
448
+ with patch("urllib.request.urlopen", side_effect=_stream_http_error(403)):
449
+ with pytest.raises(ProviderError, match="HTTP 403"):
450
+ list(GoogleProvider().stream(prompt="hi", api_key="g-key"))
451
+
452
+
453
+ # ── Ollama stream ─────────────────────────────────────────────────────────
454
+
455
+
456
+ def test_ollama_stream_yields_text():
457
+ lines = [
458
+ b'{"response": "foo ", "done": false}\n',
459
+ b'{"response": "bar", "done": true}\n',
460
+ ]
461
+ with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
462
+ chunks = list(OllamaProvider().stream(prompt="hi"))
463
+ assert chunks == ["foo ", "bar"]
464
+
465
+
466
+ def test_ollama_stream_stops_at_done(monkeypatch):
467
+ monkeypatch.delenv("OLLAMA_HOST", raising=False)
468
+ lines = [
469
+ b'{"response": "a", "done": false}\n',
470
+ b'{"response": "b", "done": true}\n',
471
+ b'{"response": "c", "done": false}\n',
472
+ ]
473
+ with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
474
+ chunks = list(OllamaProvider().stream(prompt="hi"))
475
+ assert chunks == ["a", "b"]
476
+
477
+
478
+ def test_ollama_stream_http_error_raises():
479
+ with patch("urllib.request.urlopen", side_effect=_stream_http_error(500)):
480
+ with pytest.raises(ProviderError, match="HTTP 500"):
481
+ list(OllamaProvider().stream(prompt="hi"))
@@ -1,61 +0,0 @@
1
- """Anthropic Messages API provider. Stdlib HTTP only."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import urllib.error
7
- import urllib.request
8
-
9
- from codeforerunner.providers.base import CompletionResult, ProviderError
10
-
11
-
12
- class AnthropicProvider:
13
- name = "anthropic"
14
- default_env_var = "ANTHROPIC_API_KEY"
15
- default_model = "claude-opus-4-5"
16
-
17
- endpoint = "https://api.anthropic.com/v1/messages"
18
-
19
- def complete(
20
- self,
21
- *,
22
- prompt: str,
23
- model: str | None = None,
24
- api_key: str | None = None,
25
- ) -> CompletionResult:
26
- if not api_key:
27
- raise ProviderError(f"missing API key (set ${self.default_env_var})")
28
- model = model or self.default_model
29
- body = json.dumps(
30
- {
31
- "model": model,
32
- "max_tokens": 4096,
33
- "messages": [{"role": "user", "content": prompt}],
34
- }
35
- ).encode("utf-8")
36
- req = urllib.request.Request(
37
- self.endpoint,
38
- data=body,
39
- method="POST",
40
- headers={
41
- "x-api-key": api_key,
42
- "anthropic-version": "2023-06-01",
43
- "content-type": "application/json",
44
- },
45
- )
46
- try:
47
- with urllib.request.urlopen(req) as resp:
48
- raw = resp.read()
49
- except urllib.error.HTTPError as e:
50
- snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
51
- raise ProviderError(f"HTTP {e.code}: {snippet}") from e
52
- except urllib.error.URLError as e:
53
- raise ProviderError(f"network error: {e.reason}") from e
54
- try:
55
- data = json.loads(raw.decode("utf-8"))
56
- text = data["content"][0]["text"]
57
- except (json.JSONDecodeError, KeyError, IndexError, TypeError) as e:
58
- raise ProviderError(f"malformed response: {e}") from e
59
- return CompletionResult(
60
- text=text, model=data.get("model", model), usage=data.get("usage")
61
- )
File without changes
File without changes