devcouncil 0.1.0 → 0.1.1
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.
- package/LICENSE +201 -201
- package/README.md +62 -543
- package/package.json +1 -1
- package/pyproject.toml +29 -26
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +135 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +143 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/cli/commands/artifacts.py +51 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/config.py +76 -54
- package/src/devcouncil/cli/commands/dashboard.py +26 -0
- package/src/devcouncil/cli/commands/doctor.py +86 -42
- package/src/devcouncil/cli/commands/go.py +237 -0
- package/src/devcouncil/cli/commands/hook.py +96 -29
- package/src/devcouncil/cli/commands/init.py +67 -56
- package/src/devcouncil/cli/commands/integrate.py +320 -14
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +25 -21
- package/src/devcouncil/cli/commands/plan.py +257 -206
- package/src/devcouncil/cli/commands/prompt.py +36 -33
- package/src/devcouncil/cli/commands/repair.py +72 -69
- package/src/devcouncil/cli/commands/report.py +112 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
- package/src/devcouncil/cli/commands/rollback.py +49 -47
- package/src/devcouncil/cli/commands/run.py +252 -207
- package/src/devcouncil/cli/commands/setup.py +159 -18
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/status.py +117 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +2 -1
- package/src/devcouncil/cli/commands/verify.py +158 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/main.py +42 -24
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +27 -27
- package/src/devcouncil/domain/gap.py +26 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +26 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/hook_policy.py +24 -3
- package/src/devcouncil/execution/patch.py +28 -28
- package/src/devcouncil/execution/permissions.py +44 -44
- package/src/devcouncil/execution/prompt_builder.py +23 -23
- package/src/devcouncil/execution/task_runner.py +63 -63
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/coding_cli.py +112 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +81 -81
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +50 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +34 -34
- package/src/devcouncil/gating/policy.py +157 -157
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +120 -0
- package/src/devcouncil/indexing/repo_mapper.py +208 -204
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/gitnexus.py +27 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +549 -96
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +207 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +138 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +38 -38
- package/src/devcouncil/llm/provider.py +146 -125
- package/src/devcouncil/llm/router.py +111 -111
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/critique_service.py +66 -66
- package/src/devcouncil/planning/plan_service.py +46 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +44 -44
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +20 -17
- package/src/devcouncil/reporting/markdown_report.py +68 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/storage/db.py +66 -66
- package/src/devcouncil/storage/models.py +83 -83
- package/src/devcouncil/storage/repositories.py +299 -222
- package/src/devcouncil/telemetry/cost.py +34 -34
- package/src/devcouncil/telemetry/tracker.py +49 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +122 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +141 -141
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/verifier.py +319 -302
- package/uv.lock +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
from devcouncil.live.models import AgentSession, AgentTurn, session_id_from_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CLAUDE_TRANSCRIPT_ROOT = Path.home() / ".claude" / "projects"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover_sessions(project_root: Path, client: str = "claude") -> list[AgentSession]:
|
|
14
|
+
"""Find local coding-agent transcripts DevCouncil can review."""
|
|
15
|
+
client = client.lower()
|
|
16
|
+
if client == "claude":
|
|
17
|
+
candidates = _claude_transcript_candidates(project_root)
|
|
18
|
+
else:
|
|
19
|
+
candidates = sorted((project_root / ".devcouncil" / "live" / client).glob("*.jsonl"))
|
|
20
|
+
|
|
21
|
+
sessions: list[AgentSession] = []
|
|
22
|
+
for path in candidates:
|
|
23
|
+
if not path.exists() or not path.is_file():
|
|
24
|
+
continue
|
|
25
|
+
stat = path.stat()
|
|
26
|
+
sessions.append(AgentSession(
|
|
27
|
+
id=session_id_from_path(path),
|
|
28
|
+
client=client,
|
|
29
|
+
transcript_path=str(path),
|
|
30
|
+
updated_at=str(stat.st_mtime),
|
|
31
|
+
turns=sum(1 for _ in _safe_lines(path)),
|
|
32
|
+
))
|
|
33
|
+
return sorted(sessions, key=lambda item: item.updated_at or "", reverse=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_turns(path: Path, client: str = "generic") -> list[AgentTurn]:
|
|
37
|
+
"""Parse a transcript into normalized turns.
|
|
38
|
+
|
|
39
|
+
Supports Claude Code JSONL plus generic JSONL records with role/content fields.
|
|
40
|
+
"""
|
|
41
|
+
turns: list[AgentTurn] = []
|
|
42
|
+
session_id = session_id_from_path(path)
|
|
43
|
+
for index, raw in enumerate(_read_jsonl(path)):
|
|
44
|
+
turn = _turn_from_record(raw, session_id=session_id, turn_index=index, client=client)
|
|
45
|
+
if turn and turn.content.strip():
|
|
46
|
+
turns.append(turn)
|
|
47
|
+
return turns
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def latest_assistant_turn(path: Path, client: str = "generic") -> AgentTurn | None:
|
|
51
|
+
for turn in reversed(load_turns(path, client=client)):
|
|
52
|
+
if turn.role == "assistant":
|
|
53
|
+
return turn
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _claude_transcript_candidates(project_root: Path) -> list[Path]:
|
|
58
|
+
local_runtime = project_root / ".devcouncil" / "live" / "claude"
|
|
59
|
+
candidates = list(local_runtime.glob("*.jsonl"))
|
|
60
|
+
if CLAUDE_TRANSCRIPT_ROOT.exists():
|
|
61
|
+
candidates.extend(CLAUDE_TRANSCRIPT_ROOT.rglob("*.jsonl"))
|
|
62
|
+
return sorted(set(candidates), key=lambda path: path.stat().st_mtime if path.exists() else 0, reverse=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _safe_lines(path: Path) -> Iterable[str]:
|
|
66
|
+
try:
|
|
67
|
+
return path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
68
|
+
except OSError:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_jsonl(path: Path) -> Iterable[dict[str, Any]]:
|
|
73
|
+
for line in _safe_lines(path):
|
|
74
|
+
if not line.strip():
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
value = json.loads(line)
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
continue
|
|
80
|
+
if isinstance(value, dict):
|
|
81
|
+
yield value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _turn_from_record(raw: dict[str, Any], session_id: str, turn_index: int, client: str) -> AgentTurn | None:
|
|
85
|
+
role = _role(raw)
|
|
86
|
+
content = _content(raw)
|
|
87
|
+
if not content:
|
|
88
|
+
return None
|
|
89
|
+
turn_id = str(raw.get("uuid") or raw.get("id") or raw.get("message_id") or f"turn-{turn_index}")
|
|
90
|
+
return AgentTurn(
|
|
91
|
+
session_id=str(raw.get("sessionId") or raw.get("session_id") or session_id),
|
|
92
|
+
turn_id=turn_id,
|
|
93
|
+
source=client,
|
|
94
|
+
role=role,
|
|
95
|
+
content=content,
|
|
96
|
+
timestamp=raw.get("timestamp") or raw.get("created_at"),
|
|
97
|
+
raw=raw,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _role(raw: dict[str, Any]) -> str:
|
|
102
|
+
role = raw.get("role")
|
|
103
|
+
if isinstance(role, str):
|
|
104
|
+
return role if role in {"user", "assistant", "system", "tool"} else "unknown"
|
|
105
|
+
message = raw.get("message")
|
|
106
|
+
if isinstance(message, dict):
|
|
107
|
+
nested = message.get("role")
|
|
108
|
+
if isinstance(nested, str):
|
|
109
|
+
return nested if nested in {"user", "assistant", "system", "tool"} else "unknown"
|
|
110
|
+
record_type = raw.get("type")
|
|
111
|
+
if record_type in {"user", "assistant", "system"}:
|
|
112
|
+
return str(record_type)
|
|
113
|
+
return "unknown"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _content(raw: dict[str, Any]) -> str:
|
|
117
|
+
direct = raw.get("content") or raw.get("text")
|
|
118
|
+
if isinstance(direct, str):
|
|
119
|
+
return direct
|
|
120
|
+
message = raw.get("message")
|
|
121
|
+
if isinstance(message, dict):
|
|
122
|
+
nested = message.get("content")
|
|
123
|
+
if isinstance(nested, str):
|
|
124
|
+
return nested
|
|
125
|
+
if isinstance(nested, list):
|
|
126
|
+
return "\n".join(_content_block_text(block) for block in nested).strip()
|
|
127
|
+
if isinstance(direct, list):
|
|
128
|
+
return "\n".join(_content_block_text(block) for block in direct).strip()
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _content_block_text(block: Any) -> str:
|
|
133
|
+
if isinstance(block, str):
|
|
134
|
+
return block
|
|
135
|
+
if isinstance(block, dict):
|
|
136
|
+
value = block.get("text") or block.get("content")
|
|
137
|
+
return value if isinstance(value, str) else ""
|
|
138
|
+
return ""
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import hashlib
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Optional
|
|
5
|
-
from devcouncil.llm.provider import LLMResponse
|
|
6
|
-
|
|
7
|
-
class LLMCache:
|
|
8
|
-
def __init__(self, project_root: Path):
|
|
9
|
-
self.cache_dir = project_root / ".devcouncil" / "cache" / "llm"
|
|
10
|
-
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
11
|
-
|
|
12
|
-
def _get_key(self, model: str, messages: list, temp: float, json_mode: bool) -> str:
|
|
13
|
-
data = {
|
|
14
|
-
"model": model,
|
|
15
|
-
"messages": messages,
|
|
16
|
-
"temp": temp,
|
|
17
|
-
"json_mode": json_mode
|
|
18
|
-
}
|
|
19
|
-
s = json.dumps(data, sort_keys=True)
|
|
20
|
-
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
21
|
-
|
|
22
|
-
def get(self, model: str, messages: list, temp: float, json_mode: bool) -> Optional[LLMResponse]:
|
|
23
|
-
key = self._get_key(model, messages, temp, json_mode)
|
|
24
|
-
cache_file = self.cache_dir / f"{key}.json"
|
|
25
|
-
if cache_file.exists():
|
|
26
|
-
try:
|
|
27
|
-
with open(cache_file, "r") as f:
|
|
28
|
-
data = json.load(f)
|
|
29
|
-
return LLMResponse(**data)
|
|
30
|
-
except Exception:
|
|
31
|
-
pass
|
|
32
|
-
return None
|
|
33
|
-
|
|
34
|
-
def set(self, model: str, messages: list, temp: float, json_mode: bool, response: LLMResponse):
|
|
35
|
-
key = self._get_key(model, messages, temp, json_mode)
|
|
36
|
-
cache_file = self.cache_dir / f"{key}.json"
|
|
37
|
-
with open(cache_file, "w") as f:
|
|
38
|
-
json.dump(response.model_dump(), f)
|
|
1
|
+
import json
|
|
2
|
+
import hashlib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from devcouncil.llm.provider import LLMResponse
|
|
6
|
+
|
|
7
|
+
class LLMCache:
|
|
8
|
+
def __init__(self, project_root: Path):
|
|
9
|
+
self.cache_dir = project_root / ".devcouncil" / "cache" / "llm"
|
|
10
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
|
|
12
|
+
def _get_key(self, model: str, messages: list, temp: float, json_mode: bool) -> str:
|
|
13
|
+
data = {
|
|
14
|
+
"model": model,
|
|
15
|
+
"messages": messages,
|
|
16
|
+
"temp": temp,
|
|
17
|
+
"json_mode": json_mode
|
|
18
|
+
}
|
|
19
|
+
s = json.dumps(data, sort_keys=True)
|
|
20
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
21
|
+
|
|
22
|
+
def get(self, model: str, messages: list, temp: float, json_mode: bool) -> Optional[LLMResponse]:
|
|
23
|
+
key = self._get_key(model, messages, temp, json_mode)
|
|
24
|
+
cache_file = self.cache_dir / f"{key}.json"
|
|
25
|
+
if cache_file.exists():
|
|
26
|
+
try:
|
|
27
|
+
with open(cache_file, "r") as f:
|
|
28
|
+
data = json.load(f)
|
|
29
|
+
return LLMResponse(**data)
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def set(self, model: str, messages: list, temp: float, json_mode: bool, response: LLMResponse):
|
|
35
|
+
key = self._get_key(model, messages, temp, json_mode)
|
|
36
|
+
cache_file = self.cache_dir / f"{key}.json"
|
|
37
|
+
with open(cache_file, "w") as f:
|
|
38
|
+
json.dump(response.model_dump(), f)
|
|
@@ -1,125 +1,146 @@
|
|
|
1
|
-
from abc import ABC, abstractmethod
|
|
2
|
-
import copy
|
|
3
|
-
from typing import List, Dict, Any, Optional
|
|
4
|
-
from pydantic import BaseModel
|
|
5
|
-
import httpx
|
|
6
|
-
import json
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import copy
|
|
3
|
+
from typing import List, Dict, Any, Optional
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
import httpx
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SUPPORTED_MODEL_PROVIDERS = ("openrouter",)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LLMResponse(BaseModel):
|
|
13
|
+
content: str
|
|
14
|
+
model: str
|
|
15
|
+
usage: Dict[str, int]
|
|
16
|
+
raw_response: Dict[str, Any]
|
|
17
|
+
|
|
18
|
+
class Provider(ABC):
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def complete(
|
|
21
|
+
self,
|
|
22
|
+
model: str,
|
|
23
|
+
messages: List[Dict[str, str]],
|
|
24
|
+
temperature: float = 0.0,
|
|
25
|
+
json_mode: bool = False
|
|
26
|
+
) -> LLMResponse:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_model_provider(provider_name: str) -> str:
|
|
31
|
+
normalized = provider_name.strip().lower()
|
|
32
|
+
if normalized in SUPPORTED_MODEL_PROVIDERS:
|
|
33
|
+
return normalized
|
|
34
|
+
supported = ", ".join(SUPPORTED_MODEL_PROVIDERS)
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Unsupported model provider '{provider_name}'. "
|
|
37
|
+
f"Supported providers: {supported}."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_provider(provider_name: str, api_key: str) -> Provider:
|
|
42
|
+
normalized = validate_model_provider(provider_name)
|
|
43
|
+
if normalized == "openrouter":
|
|
44
|
+
return OpenRouterProvider(api_key)
|
|
45
|
+
raise AssertionError(f"Provider validation passed for unhandled provider: {normalized}")
|
|
46
|
+
|
|
47
|
+
class OpenRouterProvider(Provider):
|
|
48
|
+
def __init__(self, api_key: str):
|
|
49
|
+
self.api_key = api_key
|
|
50
|
+
self.base_url = "https://openrouter.ai/api/v1"
|
|
51
|
+
|
|
52
|
+
async def complete(
|
|
53
|
+
self,
|
|
54
|
+
model: str,
|
|
55
|
+
messages: List[Dict[str, str]],
|
|
56
|
+
temperature: float = 0.0,
|
|
57
|
+
json_mode: bool = False
|
|
58
|
+
) -> LLMResponse:
|
|
59
|
+
# Deep-copy to avoid mutating the caller's messages list
|
|
60
|
+
msgs = copy.deepcopy(messages)
|
|
61
|
+
|
|
62
|
+
headers = {
|
|
63
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
"HTTP-Referer": "https://github.com/devcouncil/devcouncil", # Optional
|
|
66
|
+
"X-Title": "DevCouncil", # Optional
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
payload = {
|
|
70
|
+
"model": model,
|
|
71
|
+
"messages": msgs,
|
|
72
|
+
"temperature": temperature,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if json_mode:
|
|
76
|
+
payload["response_format"] = {"type": "json_object"}
|
|
77
|
+
# Ensure the user message mentions JSON
|
|
78
|
+
if msgs[-1]["role"] == "user":
|
|
79
|
+
msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
|
|
80
|
+
|
|
81
|
+
async with httpx.AsyncClient(timeout=180.0) as client:
|
|
82
|
+
response = await client.post(
|
|
83
|
+
f"{self.base_url}/chat/completions",
|
|
84
|
+
headers=headers,
|
|
85
|
+
json=payload
|
|
86
|
+
)
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
data = response.json()
|
|
89
|
+
|
|
90
|
+
resp = LLMResponse(
|
|
91
|
+
content=data["choices"][0]["message"]["content"],
|
|
92
|
+
model=data["model"],
|
|
93
|
+
usage=data.get("usage", {}),
|
|
94
|
+
raw_response=data
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Log the call
|
|
98
|
+
try:
|
|
99
|
+
from devcouncil.utils.redaction import redact_dict
|
|
100
|
+
log_dir = Path(".devcouncil/logs")
|
|
101
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
log_file = log_dir / "model_calls.jsonl"
|
|
103
|
+
|
|
104
|
+
# Create a redacted copy of both request and response for logging
|
|
105
|
+
log_payload = {
|
|
106
|
+
"request": redact_dict(payload),
|
|
107
|
+
"response": redact_dict(data),
|
|
108
|
+
"usage": resp.usage,
|
|
109
|
+
}
|
|
110
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
111
|
+
f.write(json.dumps(log_payload) + "\n")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
import logging as _log
|
|
114
|
+
_log.getLogger(__name__).debug("Failed to log model call: %s", e)
|
|
115
|
+
|
|
116
|
+
return resp
|
|
117
|
+
|
|
118
|
+
class MockProvider(Provider):
|
|
119
|
+
"""Mock provider for dry runs and testing."""
|
|
120
|
+
def __init__(self, responses: Optional[Dict[str, Any]] = None):
|
|
121
|
+
# responses can be a dict of model -> str OR model -> list of str
|
|
122
|
+
self.responses = responses or {}
|
|
123
|
+
self._counts: Dict[str, int] = {}
|
|
124
|
+
|
|
125
|
+
async def complete(
|
|
126
|
+
self,
|
|
127
|
+
model: str,
|
|
128
|
+
messages: List[Dict[str, str]],
|
|
129
|
+
temperature: float = 0.0,
|
|
130
|
+
json_mode: bool = False
|
|
131
|
+
) -> LLMResponse:
|
|
132
|
+
res = self.responses.get(model, '{"mock": "response"}')
|
|
133
|
+
|
|
134
|
+
if isinstance(res, list):
|
|
135
|
+
count = self._counts.get(model, 0)
|
|
136
|
+
content = res[min(count, len(res)-1)]
|
|
137
|
+
self._counts[model] = count + 1
|
|
138
|
+
else:
|
|
139
|
+
content = res
|
|
140
|
+
|
|
141
|
+
return LLMResponse(
|
|
142
|
+
content=content,
|
|
143
|
+
model=f"mock/{model}",
|
|
144
|
+
usage={"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20},
|
|
145
|
+
raw_response={"choices": [{"message": {"content": content}}]}
|
|
146
|
+
)
|