devcouncil 0.1.1 → 0.3.0

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 (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,34 +1,162 @@
1
1
  from abc import ABC, abstractmethod
2
2
  import copy
3
+ from functools import lru_cache
4
+ from importlib import resources
5
+ import logging
6
+ import os
3
7
  from typing import List, Dict, Any, Optional
4
- from pydantic import BaseModel
8
+ from pydantic import BaseModel, field_validator
5
9
  import httpx
6
10
  import json
7
11
  from pathlib import Path
12
+ import yaml
8
13
 
9
- SUPPORTED_MODEL_PROVIDERS = ("openrouter",)
14
+ logger = logging.getLogger(__name__)
15
+
16
+ SUPPORTED_MODEL_PROVIDERS = ("openrouter", "vertexai", "doubleword", "ollama")
17
+ PROVIDER_ALIASES = {
18
+ "vertex-ai": "vertexai",
19
+ "vertex_ai": "vertexai",
20
+ "ollama-local": "ollama",
21
+ "ollama_local": "ollama",
22
+ }
23
+ MODEL_DEFAULTS_RESOURCE = "model_defaults.yaml"
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def load_default_role_models_by_provider() -> Dict[str, Dict[str, str]]:
28
+ data = resources.files(__package__).joinpath(MODEL_DEFAULTS_RESOURCE).read_text(encoding="utf-8")
29
+ loaded = yaml.safe_load(data) or {}
30
+ return {
31
+ str(provider): {str(role): str(model) for role, model in roles.items()}
32
+ for provider, roles in loaded.items()
33
+ if isinstance(roles, dict)
34
+ }
35
+
36
+
37
+ DEFAULT_ROLE_MODELS_BY_PROVIDER = load_default_role_models_by_provider()
38
+
39
+
40
+ class ProviderRequestError(RuntimeError):
41
+ """A provider HTTP request failed, with an actionable, user-facing message."""
42
+
43
+ def __init__(self, message: str, status_code: int | None = None):
44
+ super().__init__(message)
45
+ self.status_code = status_code
46
+
47
+
48
+ def raise_for_provider_status(response: "httpx.Response", provider: str) -> None:
49
+ """Translate an HTTP error response into an actionable ProviderRequestError.
50
+
51
+ The raw ``httpx.HTTPStatusError`` surfaces as an unhelpful traceback; common
52
+ statuses (auth, billing, rate limiting) have concrete remedies worth naming.
53
+ """
54
+ status = getattr(response, "status_code", None)
55
+ if status is None or status < 400:
56
+ return
57
+ hints = {
58
+ 401: "authentication failed — check the API key in .devcouncil/secrets.env",
59
+ 402: "payment required — the account is out of credits or has no active balance; add funds and retry",
60
+ 403: "access forbidden — the API key may lack access to the requested model",
61
+ 404: "not found — check the configured model id and provider base URL",
62
+ 429: "rate limited — too many requests; wait a moment and retry",
63
+ }
64
+ detail = hints.get(status, "the request was rejected")
65
+ body = ""
66
+ text = getattr(response, "text", None)
67
+ if isinstance(text, str):
68
+ body = text.strip()[:300]
69
+ message = f"{provider} API error {status}: {detail}."
70
+ if body:
71
+ message = f"{message} Response: {body}"
72
+ logger.error("Provider request failed: %s", message)
73
+ raise ProviderRequestError(message, status_code=status)
10
74
 
11
75
 
12
76
  class LLMResponse(BaseModel):
13
77
  content: str
14
78
  model: str
15
- usage: Dict[str, int]
79
+ # OpenRouter (and other providers) return richer usage payloads than plain
80
+ # token counts: a float ``cost`` plus nested ``*_details`` dicts. Keep this
81
+ # permissive so live responses parse; downstream only reads the int token keys.
82
+ usage: Dict[str, Any]
16
83
  raw_response: Dict[str, Any]
17
84
 
85
+ @field_validator("content", mode="before")
86
+ @classmethod
87
+ def _coerce_null_content(cls, value: Any) -> str:
88
+ # Providers return ``content: null`` for reasoning-only, tool-only, or
89
+ # filtered responses. Treat that as empty text so the router's parse /
90
+ # healing path can retry instead of crashing on a validation error.
91
+ return value if value is not None else ""
92
+
18
93
  class Provider(ABC):
19
94
  @abstractmethod
20
95
  async def complete(
21
- self,
22
- model: str,
23
- messages: List[Dict[str, str]],
96
+ self,
97
+ model: str,
98
+ messages: List[Dict[str, str]],
24
99
  temperature: float = 0.0,
25
- json_mode: bool = False
100
+ json_mode: bool = False,
101
+ task_id: Optional[str] = None,
102
+ run_id: Optional[str] = None,
26
103
  ) -> LLMResponse:
27
104
  pass
28
105
 
106
+ def _get_async_client(self, timeout: Any) -> "httpx.AsyncClient":
107
+ """Lazily create and reuse a single ``httpx.AsyncClient`` per provider instance.
108
+
109
+ Building an ``AsyncClient`` (connection pool + SSL context) is expensive and the
110
+ pool is meant to be reused across calls, so we keep one per instance rather than
111
+ constructing a fresh client on every ``complete()``. ``timeout`` is fixed per
112
+ provider instance (cloud providers use 180s; Ollama uses its resolved
113
+ ``self.timeout``), so binding it at construction time is equivalent to the previous
114
+ per-call client while still allowing the pool to be reused.
115
+
116
+ The client is bound to the event loop that created it. If the same provider
117
+ instance is ever driven from a *different* loop (e.g. a second ``asyncio.run``),
118
+ the old client's pool belongs to a now-closed loop and cannot be reused — we
119
+ detect that and rebind a fresh client to the current loop instead of failing. The
120
+ client lives for the provider's lifetime (one run / one cached router) and is
121
+ released on GC or via ``aclose()``; provider instances are bounded, so clients do
122
+ not accumulate."""
123
+ import asyncio
124
+
125
+ loop = asyncio.get_running_loop()
126
+ client = getattr(self, "_client", None)
127
+ if client is not None and not client.is_closed and getattr(self, "_client_loop", None) is loop:
128
+ return client
129
+ client = httpx.AsyncClient(timeout=timeout)
130
+ self._client: Optional[httpx.AsyncClient] = client
131
+ self._client_loop = loop
132
+ return client
133
+
134
+ async def aclose(self) -> None:
135
+ """Close the reused AsyncClient if one was created."""
136
+ client = getattr(self, "_client", None)
137
+ if client is not None:
138
+ self._client = None
139
+ await client.aclose()
140
+
141
+ def cache_fingerprint(self) -> str:
142
+ """Provider-specific options that change the model's output and therefore must
143
+ be part of the LLM cache key. Empty for providers whose output depends only on
144
+ ``(model, messages, temperature, json_mode)``; overridden where a runtime knob
145
+ (e.g. Ollama's ``num_ctx`` / base URL) silently alters results for an identical
146
+ prompt."""
147
+ return ""
148
+
149
+ def is_local_cost_free(self) -> bool:
150
+ """True for on-device providers that incur no per-token cost (Ollama). Lets the
151
+ telemetry tracker zero local usage by PROVIDER rather than by model-id matching —
152
+ local model tags are open-ended (``qwen2.5-coder:7b``) and may collide with priced
153
+ entries. Mirrors the provider-based zeroing in ``telemetry/cost.py``."""
154
+ return False
155
+
29
156
 
30
157
  def validate_model_provider(provider_name: str) -> str:
31
158
  normalized = provider_name.strip().lower()
159
+ normalized = PROVIDER_ALIASES.get(normalized, normalized)
32
160
  if normalized in SUPPORTED_MODEL_PROVIDERS:
33
161
  return normalized
34
162
  supported = ", ".join(SUPPORTED_MODEL_PROVIDERS)
@@ -38,26 +166,173 @@ def validate_model_provider(provider_name: str) -> str:
38
166
  )
39
167
 
40
168
 
41
- def create_provider(provider_name: str, api_key: str) -> Provider:
169
+ def apply_provider_default_role_models(
170
+ raw_config: Dict[str, Any],
171
+ previous_provider: str,
172
+ new_provider: str,
173
+ ) -> bool:
174
+ """Update role defaults when switching providers without overwriting custom models."""
175
+ new = validate_model_provider(new_provider)
176
+ models = raw_config.setdefault("models", {})
177
+ roles = models.setdefault("roles", {})
178
+ try:
179
+ previous = validate_model_provider(previous_provider)
180
+ previous_defaults = DEFAULT_ROLE_MODELS_BY_PROVIDER[previous]
181
+ except ValueError:
182
+ previous_defaults = {}
183
+ new_defaults = DEFAULT_ROLE_MODELS_BY_PROVIDER[new]
184
+ changed = False
185
+
186
+ for role, new_model in new_defaults.items():
187
+ role_config = roles.setdefault(role, {})
188
+ current_model = role_config.get("model")
189
+ if current_model is None or current_model == previous_defaults.get(role):
190
+ if current_model != new_model:
191
+ role_config["model"] = new_model
192
+ changed = True
193
+
194
+ return changed
195
+
196
+
197
+ def build_role_model_config(
198
+ provider: str = "openrouter",
199
+ model: str | None = None,
200
+ role_models: Dict[str, str] | None = None,
201
+ ) -> Dict[str, Dict[str, str]]:
202
+ """Build config-ready model role mappings for a provider.
203
+
204
+ If ``model`` is supplied, it is used for every known role. Per-role entries
205
+ in ``role_models`` override both provider defaults and the shared model.
206
+ """
207
+ normalized = validate_model_provider(provider)
208
+ roles = {
209
+ role: {"model": selected_model}
210
+ for role, selected_model in DEFAULT_ROLE_MODELS_BY_PROVIDER[normalized].items()
211
+ }
212
+ if model:
213
+ roles = {role: {"model": model} for role in roles}
214
+ for role, selected_model in (role_models or {}).items():
215
+ roles[role] = {"model": selected_model}
216
+ return roles
217
+
218
+
219
+ def openrouter_provider_payload(prefs: Any) -> Optional[Dict[str, Any]]:
220
+ """Translate DevCouncil's ``ProviderConfig`` (or a plain mapping) into OpenRouter's
221
+ ``provider`` routing object.
222
+
223
+ Returns ``None`` when no prefs are supplied so the request omits the field entirely
224
+ and OpenRouter applies its own defaults. Only the keys OpenRouter recognizes are
225
+ forwarded (``sort``, ``allow_fallbacks``, ``require_parameters``, ``data_collection``),
226
+ so adding unrelated fields to ``ProviderConfig`` never leaks into the API call.
227
+ """
228
+ if prefs is None:
229
+ return None
230
+ if hasattr(prefs, "model_dump"):
231
+ data = prefs.model_dump()
232
+ elif isinstance(prefs, dict):
233
+ data = prefs
234
+ else:
235
+ return None
236
+ allowed = ("sort", "allow_fallbacks", "require_parameters", "data_collection")
237
+ payload = {k: data[k] for k in allowed if data.get(k) is not None}
238
+ return payload or None
239
+
240
+
241
+ def create_provider(
242
+ provider_name: str,
243
+ api_key: str,
244
+ project_root: Path = Path("."),
245
+ provider_prefs: Any = None,
246
+ ) -> Provider:
42
247
  normalized = validate_model_provider(provider_name)
43
248
  if normalized == "openrouter":
44
- return OpenRouterProvider(api_key)
249
+ return OpenRouterProvider(api_key, project_root=project_root, provider_prefs=provider_prefs)
250
+ if normalized == "doubleword":
251
+ return DoublewordProvider(api_key, project_root=project_root)
252
+ if normalized == "ollama":
253
+ return OllamaProvider(api_key, project_root=project_root)
254
+ if normalized == "vertexai":
255
+ from devcouncil.app.config import load_local_secrets
256
+ local_secrets = load_local_secrets(project_root)
257
+ project_id = (
258
+ os.environ.get("VERTEXAI_PROJECT")
259
+ or os.environ.get("GOOGLE_CLOUD_PROJECT")
260
+ or local_secrets.get("VERTEXAI_PROJECT")
261
+ or local_secrets.get("GOOGLE_CLOUD_PROJECT")
262
+ )
263
+ location = os.environ.get("VERTEXAI_LOCATION") or local_secrets.get("VERTEXAI_LOCATION", "global")
264
+ return VertexAIProvider(api_key, project_id=project_id, location=location, project_root=project_root)
45
265
  raise AssertionError(f"Provider validation passed for unhandled provider: {normalized}")
46
266
 
267
+
268
+ def _log_model_call(
269
+ payload: Dict[str, Any],
270
+ data: Dict[str, Any],
271
+ usage: Dict[str, int],
272
+ project_root: Path = Path("."),
273
+ task_id: Optional[str] = None,
274
+ run_id: Optional[str] = None,
275
+ provider: Optional[str] = None,
276
+ ) -> None:
277
+ try:
278
+ from datetime import datetime, timezone
279
+
280
+ from devcouncil.utils.redaction import redact_dict
281
+ # Resolve against the provider's project root, not the process cwd — otherwise
282
+ # running `dev` from another directory logged spend to the wrong project.
283
+ log_dir = project_root / ".devcouncil" / "logs"
284
+ log_dir.mkdir(parents=True, exist_ok=True)
285
+ log_file = log_dir / "model_calls.jsonl"
286
+
287
+ # task_id/run_id/timestamp/provider are optional and backward-compatible: older
288
+ # records simply lack them and are grouped under "(unattributed)" by the cost
289
+ # reporter. provider lets the cost ledger zero-cost local providers (ollama)
290
+ # regardless of the open-ended model tag Ollama echoes back.
291
+ log_payload = {
292
+ "request": redact_dict(payload),
293
+ "response": redact_dict(data),
294
+ "usage": usage,
295
+ "task_id": task_id,
296
+ "run_id": run_id,
297
+ "provider": provider,
298
+ "timestamp": datetime.now(timezone.utc).isoformat(),
299
+ }
300
+ with open(log_file, "a", encoding="utf-8") as f:
301
+ f.write(json.dumps(log_payload) + "\n")
302
+ except Exception as e:
303
+ import logging as _log
304
+ _log.getLogger(__name__).debug("Failed to log model call: %s", e)
305
+
306
+
47
307
  class OpenRouterProvider(Provider):
48
- def __init__(self, api_key: str):
308
+ def __init__(self, api_key: str, project_root: Path = Path("."), provider_prefs: Any = None):
49
309
  self.api_key = api_key
50
310
  self.base_url = "https://openrouter.ai/api/v1"
311
+ self.project_root = project_root
312
+ # OpenRouter routing preferences (sort/allow_fallbacks/require_parameters/
313
+ # data_collection) sent as the request's ``provider`` field. None → omit it.
314
+ self.provider_prefs = openrouter_provider_payload(provider_prefs)
315
+
316
+ def cache_fingerprint(self) -> str:
317
+ # Routing prefs change which upstream provider/model serves the request (and the
318
+ # data-collection policy), so they can change the output for an identical prompt
319
+ # and must invalidate the cache. Empty when unset so default runs share one key.
320
+ if not self.provider_prefs:
321
+ return ""
322
+ return "openrouter:provider=" + json.dumps(self.provider_prefs, sort_keys=True)
51
323
 
52
324
  async def complete(
53
325
  self,
54
326
  model: str,
55
327
  messages: List[Dict[str, str]],
56
328
  temperature: float = 0.0,
57
- json_mode: bool = False
329
+ json_mode: bool = False,
330
+ task_id: Optional[str] = None,
331
+ run_id: Optional[str] = None,
58
332
  ) -> LLMResponse:
59
- # Deep-copy to avoid mutating the caller's messages list
60
- msgs = copy.deepcopy(messages)
333
+ # Only deep-copy when json_mode mutates the last message; otherwise the
334
+ # caller's list is read but never modified, so we can use it directly.
335
+ msgs = copy.deepcopy(messages) if json_mode else messages
61
336
 
62
337
  headers = {
63
338
  "Authorization": f"Bearer {self.api_key}",
@@ -65,55 +340,346 @@ class OpenRouterProvider(Provider):
65
340
  "HTTP-Referer": "https://github.com/devcouncil/devcouncil", # Optional
66
341
  "X-Title": "DevCouncil", # Optional
67
342
  }
68
-
343
+
69
344
  payload = {
70
345
  "model": model,
71
346
  "messages": msgs,
72
347
  "temperature": temperature,
73
348
  }
74
-
349
+
75
350
  if json_mode:
76
351
  payload["response_format"] = {"type": "json_object"}
77
352
  # Ensure the user message mentions JSON
78
353
  if msgs[-1]["role"] == "user":
79
354
  msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
80
355
 
81
- async with httpx.AsyncClient(timeout=180.0) as client:
356
+ if self.provider_prefs:
357
+ payload["provider"] = self.provider_prefs
358
+
359
+ client = self._get_async_client(180.0)
360
+ response = await client.post(
361
+ f"{self.base_url}/chat/completions",
362
+ headers=headers,
363
+ json=payload,
364
+ )
365
+ raise_for_provider_status(response, "OpenRouter")
366
+ data = response.json()
367
+
368
+ resp = LLMResponse(
369
+ content=data["choices"][0]["message"]["content"],
370
+ model=data["model"],
371
+ usage=data.get("usage", {}),
372
+ raw_response=data
373
+ )
374
+
375
+ _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
376
+
377
+ return resp
378
+
379
+
380
+ class DoublewordProvider(Provider):
381
+ def __init__(self, api_key: str, project_root: Path = Path(".")):
382
+ self.api_key = api_key
383
+ self.base_url = "https://api.doubleword.ai/v1"
384
+ self.project_root = project_root
385
+
386
+ async def complete(
387
+ self,
388
+ model: str,
389
+ messages: List[Dict[str, str]],
390
+ temperature: float = 0.0,
391
+ json_mode: bool = False,
392
+ task_id: Optional[str] = None,
393
+ run_id: Optional[str] = None,
394
+ ) -> LLMResponse:
395
+ # Only deep-copy when json_mode mutates the last message; otherwise the
396
+ # caller's list is read but never modified, so we can use it directly.
397
+ msgs = copy.deepcopy(messages) if json_mode else messages
398
+ headers = {
399
+ "Authorization": f"Bearer {self.api_key}",
400
+ "Content-Type": "application/json",
401
+ }
402
+ payload = {
403
+ "model": model,
404
+ "messages": msgs,
405
+ "temperature": temperature,
406
+ }
407
+
408
+ if json_mode:
409
+ payload["response_format"] = {"type": "json_object"}
410
+ if msgs[-1]["role"] == "user":
411
+ msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
412
+
413
+ client = self._get_async_client(180.0)
414
+ response = await client.post(
415
+ f"{self.base_url}/chat/completions",
416
+ headers=headers,
417
+ json=payload,
418
+ )
419
+ raise_for_provider_status(response, "Doubleword")
420
+ data = response.json()
421
+
422
+ resp = LLMResponse(
423
+ content=data["choices"][0]["message"]["content"],
424
+ model=data["model"],
425
+ usage=data.get("usage", {}),
426
+ raw_response=data
427
+ )
428
+
429
+ _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
430
+ return resp
431
+
432
+
433
+ class OllamaProvider(Provider):
434
+ """Local Ollama provider via its NATIVE ``/api/chat`` endpoint.
435
+
436
+ Ollama needs no API key. The base URL is overridable via ``OLLAMA_BASE_URL``
437
+ (taken verbatim) or Ollama's native ``OLLAMA_HOST`` (normalized: a missing scheme
438
+ is prefixed with ``http://`` and a missing ``/v1`` suffix is appended). The actual
439
+ request goes to the native ``/api/chat`` endpoint (derived by stripping a trailing
440
+ ``/v1``) rather than the OpenAI-compatible ``/v1/chat/completions`` — because the
441
+ native endpoint is the only one that honors ``options.num_ctx`` (set via
442
+ ``OLLAMA_NUM_CTX``) and ``format: json``. DevCouncil's planning prompts are large
443
+ (up to ~15k tokens), so without a raised ``num_ctx`` Ollama's small default context
444
+ would silently truncate them.
445
+ """
446
+
447
+ def __init__(
448
+ self,
449
+ api_key: str = "",
450
+ project_root: Path = Path("."),
451
+ base_url: str | None = None,
452
+ num_ctx: int | None = None,
453
+ ):
454
+ self.api_key = api_key
455
+ self.base_url = base_url or self._resolve_base_url()
456
+ self.project_root = project_root
457
+ self.num_ctx = num_ctx if num_ctx is not None else self._resolve_num_ctx()
458
+ self.timeout = self._resolve_timeout()
459
+
460
+ # Local generation latency is unbounded (cold loads, CPU-only hosts, large
461
+ # ``num_ctx``) and is not a network failure, so Ollama gets a generous default
462
+ # and an explicit override rather than the cloud providers' fixed 180s.
463
+ DEFAULT_TIMEOUT = 600.0
464
+
465
+ @staticmethod
466
+ def _resolve_timeout() -> float | None:
467
+ """Read timeout from ``OLLAMA_TIMEOUT`` seconds (positive float). ``0``/``none``/
468
+ ``off`` disables it entirely for very slow local models; unset/invalid falls back
469
+ to :data:`DEFAULT_TIMEOUT`."""
470
+ raw = os.environ.get("OLLAMA_TIMEOUT")
471
+ if raw is None:
472
+ return OllamaProvider.DEFAULT_TIMEOUT
473
+ raw = raw.strip().lower()
474
+ if raw in {"0", "none", "off", ""}:
475
+ return None
476
+ try:
477
+ value = float(raw)
478
+ except ValueError:
479
+ return OllamaProvider.DEFAULT_TIMEOUT
480
+ return value if value > 0 else None
481
+
482
+ def cache_fingerprint(self) -> str:
483
+ # num_ctx and the target server change the response for an identical prompt (a
484
+ # larger window avoids the truncation a smaller one silently applies; a different
485
+ # endpoint is a different model server), so both must invalidate the cache. Key on
486
+ # the *normalized* /api/chat endpoint, not the raw base_url, so equivalent configs
487
+ # (OLLAMA_HOST vs OLLAMA_BASE_URL, with/without a trailing /v1) collapse to one key.
488
+ return f"ollama:num_ctx={self.num_ctx};endpoint={self._chat_endpoint()}"
489
+
490
+ def is_local_cost_free(self) -> bool:
491
+ return True
492
+
493
+ @staticmethod
494
+ def _resolve_base_url() -> str:
495
+ explicit = os.environ.get("OLLAMA_BASE_URL")
496
+ if explicit:
497
+ return explicit.rstrip("/")
498
+ host = os.environ.get("OLLAMA_HOST")
499
+ if host:
500
+ host = host.strip()
501
+ if "://" not in host:
502
+ host = f"http://{host}"
503
+ host = host.rstrip("/")
504
+ if not host.endswith("/v1"):
505
+ host = f"{host}/v1"
506
+ return host
507
+ return "http://localhost:11434/v1"
508
+
509
+ @staticmethod
510
+ def _resolve_num_ctx() -> int | None:
511
+ """Context window from ``OLLAMA_NUM_CTX`` (positive int), else None (server default)."""
512
+ raw = os.environ.get("OLLAMA_NUM_CTX")
513
+ if not raw:
514
+ return None
515
+ try:
516
+ value = int(raw)
517
+ except (TypeError, ValueError):
518
+ return None
519
+ return value if value > 0 else None
520
+
521
+ def _chat_endpoint(self) -> str:
522
+ """Native chat endpoint derived from base_url (strip a trailing ``/v1``)."""
523
+ root = self.base_url.rstrip("/")
524
+ if root.endswith("/v1"):
525
+ root = root[: -len("/v1")].rstrip("/")
526
+ return f"{root}/api/chat"
527
+
528
+ async def complete(
529
+ self,
530
+ model: str,
531
+ messages: List[Dict[str, str]],
532
+ temperature: float = 0.0,
533
+ json_mode: bool = False,
534
+ task_id: Optional[str] = None,
535
+ run_id: Optional[str] = None,
536
+ ) -> LLMResponse:
537
+ # Only deep-copy when json_mode mutates the last message; otherwise the
538
+ # caller's list is read but never modified, so we can use it directly.
539
+ msgs = copy.deepcopy(messages) if json_mode else messages
540
+ headers = {
541
+ "Content-Type": "application/json",
542
+ }
543
+ # Ollama ignores auth, but a configured key (e.g. for a reverse proxy)
544
+ # passes through harmlessly.
545
+ if self.api_key:
546
+ headers["Authorization"] = f"Bearer {self.api_key}"
547
+
548
+ # Native /api/chat options. temperature and num_ctx live under "options"; a
549
+ # raised num_ctx (OLLAMA_NUM_CTX) prevents silent truncation of large prompts.
550
+ options: Dict[str, Any] = {"temperature": temperature}
551
+ if self.num_ctx:
552
+ options["num_ctx"] = self.num_ctx
553
+
554
+ payload: Dict[str, Any] = {
555
+ "model": model,
556
+ "messages": msgs,
557
+ "stream": False,
558
+ "options": options,
559
+ }
560
+
561
+ if json_mode:
562
+ # Native structured-output switch (more reliable than OpenAI response_format
563
+ # on Ollama). Still nudge the prompt so the model knows to emit JSON.
564
+ payload["format"] = "json"
565
+ if msgs[-1]["role"] == "user":
566
+ msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
567
+
568
+ client = self._get_async_client(self.timeout)
569
+ response = await client.post(
570
+ self._chat_endpoint(),
571
+ headers=headers,
572
+ json=payload,
573
+ )
574
+ raise_for_provider_status(response, "Ollama")
575
+ data = response.json()
576
+
577
+ # Native response shape: {"message": {"content": ...}, "model": ...,
578
+ # "prompt_eval_count": N, "eval_count": M}. Map token counts to the
579
+ # OpenAI-style keys the cost ledger/tracker expect.
580
+ prompt_tokens = int(data.get("prompt_eval_count", 0) or 0)
581
+ completion_tokens = int(data.get("eval_count", 0) or 0)
582
+ usage = {
583
+ "prompt_tokens": prompt_tokens,
584
+ "completion_tokens": completion_tokens,
585
+ "total_tokens": prompt_tokens + completion_tokens,
586
+ }
587
+ resp = LLMResponse(
588
+ content=(data.get("message") or {}).get("content", ""),
589
+ # Ollama may omit ``model`` or return a local tag — fall back to
590
+ # the requested id rather than KeyError-ing.
591
+ model=data.get("model", model),
592
+ usage=usage,
593
+ raw_response=data,
594
+ )
595
+
596
+ _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id, provider="ollama")
597
+ return resp
598
+
599
+
600
+ class VertexAIProvider(Provider):
601
+ """Vertex AI provider using Google's OpenAI-compatible Chat Completions API."""
602
+
603
+ def __init__(self, access_token: str, project_id: str | None = None, location: str | None = None, project_root: Path = Path(".")):
604
+ self.access_token = access_token
605
+ self.project_id = project_id or os.environ.get("VERTEXAI_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT")
606
+ self.location = location or os.environ.get("VERTEXAI_LOCATION", "global")
607
+ self.project_root = project_root
608
+
609
+ @property
610
+ def base_url(self) -> str:
611
+ if not self.project_id:
612
+ raise ValueError(
613
+ "Vertex AI project is not configured. Set VERTEXAI_PROJECT or GOOGLE_CLOUD_PROJECT."
614
+ )
615
+ return (
616
+ f"https://aiplatform.googleapis.com/v1/projects/{self.project_id}"
617
+ f"/locations/{self.location}/endpoints/openapi"
618
+ )
619
+
620
+ def _headers(self) -> Dict[str, str]:
621
+ return {
622
+ "Authorization": f"Bearer {self.access_token}",
623
+ "Content-Type": "application/json",
624
+ }
625
+
626
+ def _refresh_access_token_from_gcloud(self) -> bool:
627
+ from devcouncil.app.config import get_gcloud_access_token
628
+
629
+ refreshed = get_gcloud_access_token()
630
+ if not refreshed:
631
+ return False
632
+ self.access_token = refreshed
633
+ return True
634
+
635
+ async def complete(
636
+ self,
637
+ model: str,
638
+ messages: List[Dict[str, str]],
639
+ temperature: float = 0.0,
640
+ json_mode: bool = False,
641
+ task_id: Optional[str] = None,
642
+ run_id: Optional[str] = None,
643
+ ) -> LLMResponse:
644
+ # Only deep-copy when json_mode mutates the last message; otherwise the
645
+ # caller's list is read but never modified, so we can use it directly.
646
+ msgs = copy.deepcopy(messages) if json_mode else messages
647
+
648
+ payload = {
649
+ "model": model,
650
+ "messages": msgs,
651
+ "temperature": temperature,
652
+ }
653
+
654
+ if json_mode:
655
+ payload["response_format"] = {"type": "json_object"}
656
+ if msgs[-1]["role"] == "user":
657
+ msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
658
+
659
+ client = self._get_async_client(180.0)
660
+ response = await client.post(
661
+ f"{self.base_url}/chat/completions",
662
+ headers=self._headers(),
663
+ json=payload,
664
+ )
665
+ if response.status_code in {401, 403} and self._refresh_access_token_from_gcloud():
82
666
  response = await client.post(
83
667
  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
668
+ headers=self._headers(),
669
+ json=payload,
95
670
  )
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
671
+ raise_for_provider_status(response, "Vertex AI")
672
+ data = response.json()
673
+
674
+ resp = LLMResponse(
675
+ content=data["choices"][0]["message"]["content"],
676
+ model=data["model"],
677
+ usage=data.get("usage", {}),
678
+ raw_response=data
679
+ )
680
+
681
+ _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
682
+ return resp
117
683
 
118
684
  class MockProvider(Provider):
119
685
  """Mock provider for dry runs and testing."""
@@ -127,7 +693,9 @@ class MockProvider(Provider):
127
693
  model: str,
128
694
  messages: List[Dict[str, str]],
129
695
  temperature: float = 0.0,
130
- json_mode: bool = False
696
+ json_mode: bool = False,
697
+ task_id: Optional[str] = None,
698
+ run_id: Optional[str] = None,
131
699
  ) -> LLMResponse:
132
700
  res = self.responses.get(model, '{"mock": "response"}')
133
701