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.
Files changed (128) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +62 -543
  3. package/package.json +1 -1
  4. package/pyproject.toml +29 -26
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +135 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +143 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/cli/commands/artifacts.py +51 -48
  22. package/src/devcouncil/cli/commands/ast.py +22 -0
  23. package/src/devcouncil/cli/commands/baseline.py +35 -32
  24. package/src/devcouncil/cli/commands/config.py +76 -54
  25. package/src/devcouncil/cli/commands/dashboard.py +26 -0
  26. package/src/devcouncil/cli/commands/doctor.py +86 -42
  27. package/src/devcouncil/cli/commands/go.py +237 -0
  28. package/src/devcouncil/cli/commands/hook.py +96 -29
  29. package/src/devcouncil/cli/commands/init.py +67 -56
  30. package/src/devcouncil/cli/commands/integrate.py +320 -14
  31. package/src/devcouncil/cli/commands/lsp.py +20 -0
  32. package/src/devcouncil/cli/commands/map.py +25 -21
  33. package/src/devcouncil/cli/commands/plan.py +257 -206
  34. package/src/devcouncil/cli/commands/prompt.py +36 -33
  35. package/src/devcouncil/cli/commands/repair.py +72 -69
  36. package/src/devcouncil/cli/commands/report.py +112 -54
  37. package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
  38. package/src/devcouncil/cli/commands/rollback.py +49 -47
  39. package/src/devcouncil/cli/commands/run.py +252 -207
  40. package/src/devcouncil/cli/commands/setup.py +159 -18
  41. package/src/devcouncil/cli/commands/show.py +76 -57
  42. package/src/devcouncil/cli/commands/status.py +117 -105
  43. package/src/devcouncil/cli/commands/tasks.py +55 -41
  44. package/src/devcouncil/cli/commands/trace.py +2 -1
  45. package/src/devcouncil/cli/commands/verify.py +158 -128
  46. package/src/devcouncil/cli/commands/version.py +20 -20
  47. package/src/devcouncil/cli/commands/watch.py +574 -0
  48. package/src/devcouncil/cli/main.py +42 -24
  49. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  50. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  51. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  52. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  53. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  54. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  55. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  56. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  57. package/src/devcouncil/domain/assumption.py +17 -17
  58. package/src/devcouncil/domain/critique.py +32 -32
  59. package/src/devcouncil/domain/evidence.py +27 -27
  60. package/src/devcouncil/domain/gap.py +26 -26
  61. package/src/devcouncil/domain/requirement.py +22 -22
  62. package/src/devcouncil/domain/task.py +26 -26
  63. package/src/devcouncil/execution/__init__.py +1 -1
  64. package/src/devcouncil/execution/context_builder.py +54 -54
  65. package/src/devcouncil/execution/executor.py +15 -15
  66. package/src/devcouncil/execution/hook_policy.py +24 -3
  67. package/src/devcouncil/execution/patch.py +28 -28
  68. package/src/devcouncil/execution/permissions.py +44 -44
  69. package/src/devcouncil/execution/prompt_builder.py +23 -23
  70. package/src/devcouncil/execution/task_runner.py +63 -63
  71. package/src/devcouncil/executors/__init__.py +1 -1
  72. package/src/devcouncil/executors/coding_cli.py +112 -0
  73. package/src/devcouncil/executors/mini_swe.py +63 -63
  74. package/src/devcouncil/executors/native/agent.py +81 -81
  75. package/src/devcouncil/executors/openhands.py +56 -56
  76. package/src/devcouncil/gating/__init__.py +1 -1
  77. package/src/devcouncil/gating/checks/clean_git.py +50 -45
  78. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  79. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  80. package/src/devcouncil/gating/checks/secret_scan_check.py +34 -34
  81. package/src/devcouncil/gating/policy.py +157 -157
  82. package/src/devcouncil/indexing/__init__.py +1 -1
  83. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  84. package/src/devcouncil/indexing/graph_index.py +48 -48
  85. package/src/devcouncil/indexing/lsp.py +120 -0
  86. package/src/devcouncil/indexing/repo_mapper.py +208 -204
  87. package/src/devcouncil/integrations/github.py +35 -35
  88. package/src/devcouncil/integrations/gitnexus.py +27 -27
  89. package/src/devcouncil/integrations/graphify.py +34 -34
  90. package/src/devcouncil/integrations/mcp/server.py +549 -96
  91. package/src/devcouncil/integrations/pr_comments.py +62 -0
  92. package/src/devcouncil/live/__init__.py +2 -0
  93. package/src/devcouncil/live/cards.py +207 -0
  94. package/src/devcouncil/live/models.py +63 -0
  95. package/src/devcouncil/live/repair_prompt.py +83 -0
  96. package/src/devcouncil/live/reviewer.py +70 -0
  97. package/src/devcouncil/live/signals.py +135 -0
  98. package/src/devcouncil/live/summary.py +34 -0
  99. package/src/devcouncil/live/tasks.py +18 -0
  100. package/src/devcouncil/live/transcripts.py +138 -0
  101. package/src/devcouncil/llm/__init__.py +1 -1
  102. package/src/devcouncil/llm/cache.py +38 -38
  103. package/src/devcouncil/llm/provider.py +146 -125
  104. package/src/devcouncil/llm/router.py +111 -111
  105. package/src/devcouncil/planning/__init__.py +1 -1
  106. package/src/devcouncil/planning/arbiter_service.py +57 -57
  107. package/src/devcouncil/planning/critique_service.py +66 -66
  108. package/src/devcouncil/planning/plan_service.py +46 -46
  109. package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
  110. package/src/devcouncil/planning/repair_service.py +39 -39
  111. package/src/devcouncil/planning/spec_service.py +44 -44
  112. package/src/devcouncil/reporting/github_check.py +32 -32
  113. package/src/devcouncil/reporting/json_report.py +20 -17
  114. package/src/devcouncil/reporting/markdown_report.py +68 -46
  115. package/src/devcouncil/reporting/report_builder.py +14 -14
  116. package/src/devcouncil/storage/db.py +66 -66
  117. package/src/devcouncil/storage/models.py +83 -83
  118. package/src/devcouncil/storage/repositories.py +299 -222
  119. package/src/devcouncil/telemetry/cost.py +34 -34
  120. package/src/devcouncil/telemetry/tracker.py +49 -49
  121. package/src/devcouncil/ui/__init__.py +1 -0
  122. package/src/devcouncil/ui/dashboard.py +122 -0
  123. package/src/devcouncil/utils/__init__.py +1 -1
  124. package/src/devcouncil/utils/redaction.py +141 -141
  125. package/src/devcouncil/verification/__init__.py +1 -1
  126. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  127. package/src/devcouncil/verification/verifier.py +319 -302
  128. 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
- class LLMResponse(BaseModel):
10
- content: str
11
- model: str
12
- usage: Dict[str, int]
13
- raw_response: Dict[str, Any]
14
-
15
- class Provider(ABC):
16
- @abstractmethod
17
- async def complete(
18
- self,
19
- model: str,
20
- messages: List[Dict[str, str]],
21
- temperature: float = 0.0,
22
- json_mode: bool = False
23
- ) -> LLMResponse:
24
- pass
25
-
26
- class OpenRouterProvider(Provider):
27
- def __init__(self, api_key: str):
28
- self.api_key = api_key
29
- self.base_url = "https://openrouter.ai/api/v1"
30
-
31
- async def complete(
32
- self,
33
- model: str,
34
- messages: List[Dict[str, str]],
35
- temperature: float = 0.0,
36
- json_mode: bool = False
37
- ) -> LLMResponse:
38
- # Deep-copy to avoid mutating the caller's messages list
39
- msgs = copy.deepcopy(messages)
40
-
41
- headers = {
42
- "Authorization": f"Bearer {self.api_key}",
43
- "Content-Type": "application/json",
44
- "HTTP-Referer": "https://github.com/devcouncil/devcouncil", # Optional
45
- "X-Title": "DevCouncil", # Optional
46
- }
47
-
48
- payload = {
49
- "model": model,
50
- "messages": msgs,
51
- "temperature": temperature,
52
- }
53
-
54
- if json_mode:
55
- payload["response_format"] = {"type": "json_object"}
56
- # Ensure the user message mentions JSON
57
- if msgs[-1]["role"] == "user":
58
- msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
59
-
60
- async with httpx.AsyncClient(timeout=180.0) as client:
61
- response = await client.post(
62
- f"{self.base_url}/chat/completions",
63
- headers=headers,
64
- json=payload
65
- )
66
- response.raise_for_status()
67
- data = response.json()
68
-
69
- resp = LLMResponse(
70
- content=data["choices"][0]["message"]["content"],
71
- model=data["model"],
72
- usage=data.get("usage", {}),
73
- raw_response=data
74
- )
75
-
76
- # Log the call
77
- try:
78
- from devcouncil.utils.redaction import redact_dict
79
- log_dir = Path(".devcouncil/logs")
80
- log_dir.mkdir(parents=True, exist_ok=True)
81
- log_file = log_dir / "model_calls.jsonl"
82
-
83
- # Create a redacted copy of both request and response for logging
84
- log_payload = {
85
- "request": redact_dict(payload),
86
- "response": redact_dict(data),
87
- "usage": resp.usage,
88
- }
89
- with open(log_file, "a", encoding="utf-8") as f:
90
- f.write(json.dumps(log_payload) + "\n")
91
- except Exception as e:
92
- import logging as _log
93
- _log.getLogger(__name__).debug("Failed to log model call: %s", e)
94
-
95
- return resp
96
-
97
- class MockProvider(Provider):
98
- """Mock provider for dry runs and testing."""
99
- def __init__(self, responses: Optional[Dict[str, Any]] = None):
100
- # responses can be a dict of model -> str OR model -> list of str
101
- self.responses = responses or {}
102
- self._counts: Dict[str, int] = {}
103
-
104
- async def complete(
105
- self,
106
- model: str,
107
- messages: List[Dict[str, str]],
108
- temperature: float = 0.0,
109
- json_mode: bool = False
110
- ) -> LLMResponse:
111
- res = self.responses.get(model, '{"mock": "response"}')
112
-
113
- if isinstance(res, list):
114
- count = self._counts.get(model, 0)
115
- content = res[min(count, len(res)-1)]
116
- self._counts[model] = count + 1
117
- else:
118
- content = res
119
-
120
- return LLMResponse(
121
- content=content,
122
- model=f"mock/{model}",
123
- usage={"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20},
124
- raw_response={"choices": [{"message": {"content": content}}]}
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
+ )