devcouncil 0.2.0 → 0.3.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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  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 +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
2
2
  import copy
3
3
  from functools import lru_cache
4
4
  from importlib import resources
5
+ import logging
5
6
  import os
6
7
  from typing import List, Dict, Any, Optional
7
8
  from pydantic import BaseModel, field_validator
@@ -10,6 +11,8 @@ import json
10
11
  from pathlib import Path
11
12
  import yaml
12
13
 
14
+ logger = logging.getLogger(__name__)
15
+
13
16
  SUPPORTED_MODEL_PROVIDERS = ("openrouter", "vertexai", "doubleword", "ollama")
14
17
  PROVIDER_ALIASES = {
15
18
  "vertex-ai": "vertexai",
@@ -66,6 +69,7 @@ def raise_for_provider_status(response: "httpx.Response", provider: str) -> None
66
69
  message = f"{provider} API error {status}: {detail}."
67
70
  if body:
68
71
  message = f"{message} Response: {body}"
72
+ logger.error("Provider request failed: %s", message)
69
73
  raise ProviderRequestError(message, status_code=status)
70
74
 
71
75
 
@@ -99,6 +103,41 @@ class Provider(ABC):
99
103
  ) -> LLMResponse:
100
104
  pass
101
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
+
102
141
  def cache_fingerprint(self) -> str:
103
142
  """Provider-specific options that change the model's output and therefore must
104
143
  be part of the LLM cache key. Empty for providers whose output depends only on
@@ -177,10 +216,37 @@ def build_role_model_config(
177
216
  return roles
178
217
 
179
218
 
180
- def create_provider(provider_name: str, api_key: str, project_root: Path = Path(".")) -> Provider:
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:
181
247
  normalized = validate_model_provider(provider_name)
182
248
  if normalized == "openrouter":
183
- return OpenRouterProvider(api_key, project_root=project_root)
249
+ return OpenRouterProvider(api_key, project_root=project_root, provider_prefs=provider_prefs)
184
250
  if normalized == "doubleword":
185
251
  return DoublewordProvider(api_key, project_root=project_root)
186
252
  if normalized == "ollama":
@@ -239,10 +305,21 @@ def _log_model_call(
239
305
 
240
306
 
241
307
  class OpenRouterProvider(Provider):
242
- def __init__(self, api_key: str, project_root: Path = Path(".")):
308
+ def __init__(self, api_key: str, project_root: Path = Path("."), provider_prefs: Any = None):
243
309
  self.api_key = api_key
244
310
  self.base_url = "https://openrouter.ai/api/v1"
245
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)
246
323
 
247
324
  async def complete(
248
325
  self,
@@ -253,8 +330,9 @@ class OpenRouterProvider(Provider):
253
330
  task_id: Optional[str] = None,
254
331
  run_id: Optional[str] = None,
255
332
  ) -> LLMResponse:
256
- # Deep-copy to avoid mutating the caller's messages list
257
- 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
258
336
 
259
337
  headers = {
260
338
  "Authorization": f"Bearer {self.api_key}",
@@ -262,38 +340,41 @@ class OpenRouterProvider(Provider):
262
340
  "HTTP-Referer": "https://github.com/devcouncil/devcouncil", # Optional
263
341
  "X-Title": "DevCouncil", # Optional
264
342
  }
265
-
343
+
266
344
  payload = {
267
345
  "model": model,
268
346
  "messages": msgs,
269
347
  "temperature": temperature,
270
348
  }
271
-
349
+
272
350
  if json_mode:
273
351
  payload["response_format"] = {"type": "json_object"}
274
352
  # Ensure the user message mentions JSON
275
353
  if msgs[-1]["role"] == "user":
276
354
  msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
277
355
 
278
- async with httpx.AsyncClient(timeout=180.0) as client:
279
- response = await client.post(
280
- f"{self.base_url}/chat/completions",
281
- headers=headers,
282
- json=payload
283
- )
284
- raise_for_provider_status(response, "OpenRouter")
285
- data = response.json()
286
-
287
- resp = LLMResponse(
288
- content=data["choices"][0]["message"]["content"],
289
- model=data["model"],
290
- usage=data.get("usage", {}),
291
- raw_response=data
292
- )
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
+ )
293
374
 
294
- _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
375
+ _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
295
376
 
296
- return resp
377
+ return resp
297
378
 
298
379
 
299
380
  class DoublewordProvider(Provider):
@@ -311,7 +392,9 @@ class DoublewordProvider(Provider):
311
392
  task_id: Optional[str] = None,
312
393
  run_id: Optional[str] = None,
313
394
  ) -> LLMResponse:
314
- msgs = copy.deepcopy(messages)
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
315
398
  headers = {
316
399
  "Authorization": f"Bearer {self.api_key}",
317
400
  "Content-Type": "application/json",
@@ -327,24 +410,24 @@ class DoublewordProvider(Provider):
327
410
  if msgs[-1]["role"] == "user":
328
411
  msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
329
412
 
330
- async with httpx.AsyncClient(timeout=180.0) as client:
331
- response = await client.post(
332
- f"{self.base_url}/chat/completions",
333
- headers=headers,
334
- json=payload
335
- )
336
- raise_for_provider_status(response, "Doubleword")
337
- data = response.json()
338
-
339
- resp = LLMResponse(
340
- content=data["choices"][0]["message"]["content"],
341
- model=data["model"],
342
- usage=data.get("usage", {}),
343
- raw_response=data
344
- )
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
+ )
345
428
 
346
- _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
347
- return resp
429
+ _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
430
+ return resp
348
431
 
349
432
 
350
433
  class OllamaProvider(Provider):
@@ -451,7 +534,9 @@ class OllamaProvider(Provider):
451
534
  task_id: Optional[str] = None,
452
535
  run_id: Optional[str] = None,
453
536
  ) -> LLMResponse:
454
- msgs = copy.deepcopy(messages)
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
455
540
  headers = {
456
541
  "Content-Type": "application/json",
457
542
  }
@@ -480,36 +565,36 @@ class OllamaProvider(Provider):
480
565
  if msgs[-1]["role"] == "user":
481
566
  msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
482
567
 
483
- async with httpx.AsyncClient(timeout=self.timeout) as client:
484
- response = await client.post(
485
- self._chat_endpoint(),
486
- headers=headers,
487
- json=payload,
488
- )
489
- raise_for_provider_status(response, "Ollama")
490
- data = response.json()
491
-
492
- # Native response shape: {"message": {"content": ...}, "model": ...,
493
- # "prompt_eval_count": N, "eval_count": M}. Map token counts to the
494
- # OpenAI-style keys the cost ledger/tracker expect.
495
- prompt_tokens = int(data.get("prompt_eval_count", 0) or 0)
496
- completion_tokens = int(data.get("eval_count", 0) or 0)
497
- usage = {
498
- "prompt_tokens": prompt_tokens,
499
- "completion_tokens": completion_tokens,
500
- "total_tokens": prompt_tokens + completion_tokens,
501
- }
502
- resp = LLMResponse(
503
- content=(data.get("message") or {}).get("content", ""),
504
- # Ollama may omit ``model`` or return a local tag — fall back to
505
- # the requested id rather than KeyError-ing.
506
- model=data.get("model", model),
507
- usage=usage,
508
- raw_response=data,
509
- )
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
+ )
510
595
 
511
- _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id, provider="ollama")
512
- return resp
596
+ _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id, provider="ollama")
597
+ return resp
513
598
 
514
599
 
515
600
  class VertexAIProvider(Provider):
@@ -556,7 +641,9 @@ class VertexAIProvider(Provider):
556
641
  task_id: Optional[str] = None,
557
642
  run_id: Optional[str] = None,
558
643
  ) -> LLMResponse:
559
- msgs = copy.deepcopy(messages)
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
560
647
 
561
648
  payload = {
562
649
  "model": model,
@@ -569,30 +656,30 @@ class VertexAIProvider(Provider):
569
656
  if msgs[-1]["role"] == "user":
570
657
  msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
571
658
 
572
- async with httpx.AsyncClient(timeout=180.0) as client:
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():
573
666
  response = await client.post(
574
667
  f"{self.base_url}/chat/completions",
575
668
  headers=self._headers(),
576
- json=payload
577
- )
578
- if response.status_code in {401, 403} and self._refresh_access_token_from_gcloud():
579
- response = await client.post(
580
- f"{self.base_url}/chat/completions",
581
- headers=self._headers(),
582
- json=payload
583
- )
584
- raise_for_provider_status(response, "Vertex AI")
585
- data = response.json()
586
-
587
- resp = LLMResponse(
588
- content=data["choices"][0]["message"]["content"],
589
- model=data["model"],
590
- usage=data.get("usage", {}),
591
- raw_response=data
669
+ json=payload,
592
670
  )
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
+ )
593
680
 
594
- _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
595
- return resp
681
+ _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
682
+ return resp
596
683
 
597
684
  class MockProvider(Provider):
598
685
  """Mock provider for dry runs and testing."""
@@ -1,8 +1,10 @@
1
1
  from typing import List, Dict, Any, Type, Optional, TypeVar
2
2
  import copy
3
+ import functools
3
4
  import json
4
5
  import logging
5
6
  import asyncio
7
+ import time
6
8
  from pathlib import Path
7
9
 
8
10
  from pydantic import BaseModel
@@ -15,6 +17,11 @@ logger = logging.getLogger(__name__)
15
17
  StructuredModel = TypeVar("StructuredModel", bound=BaseModel)
16
18
 
17
19
 
20
+ @functools.lru_cache(maxsize=128)
21
+ def _cached_schema_json(schema_class) -> str:
22
+ return json.dumps(schema_class.model_json_schema(), indent=2)
23
+
24
+
18
25
  class StructuredOutputError(RuntimeError):
19
26
  """A model could not produce valid structured output for a role, even after
20
27
  a healing retry. Carries the role/model so the CLI can give actionable advice
@@ -42,6 +49,51 @@ class ModelRouter:
42
49
  self.provider = provider
43
50
  self.role_config = role_config
44
51
  self.project_root = project_root
52
+ # LLMCache and TraceLogger do disk I/O (mkdir) in their constructors, so build
53
+ # them once here and reuse across calls. TelemetryTracker is deliberately *not*
54
+ # hoisted: it is constructed per-call so log_usage's reload-before-save stays
55
+ # concurrent-write safe.
56
+ self._cache = LLMCache(self.project_root)
57
+ self._traces = TraceLogger(self.project_root)
58
+ # Lazily-built providers for roles that override ``models.provider`` with
59
+ # their own ``provider:`` (e.g. live_reviewer on Ollama while planners run
60
+ # on OpenRouter). Keyed by normalized provider name; the default provider
61
+ # passed in above is reused for roles without an override.
62
+ self._role_providers: Dict[str, Provider] = {}
63
+
64
+ def _provider_for_role(self, role_config: Dict[str, Any]) -> Provider:
65
+ """Resolve the provider for a role, honoring a per-role ``provider`` override.
66
+
67
+ Roles without an override use the default provider supplied at construction.
68
+ Overriding roles get a provider built on demand (and cached) from the
69
+ configured credentials, so one router can fan a single run across multiple
70
+ providers."""
71
+ role_provider = role_config.get("provider")
72
+ if not role_provider:
73
+ return self.provider
74
+ # Local imports avoid a circular import at module load (provider/config
75
+ # both reference this package).
76
+ from devcouncil.llm.provider import create_provider, validate_model_provider
77
+ from devcouncil.app.config import get_api_key
78
+
79
+ normalized = validate_model_provider(role_provider)
80
+ if normalized not in self._role_providers:
81
+ api_key = get_api_key(normalized, self.project_root)
82
+ # An override to OpenRouter must still honor the project's provider-routing
83
+ # prefs (sort/allow_fallbacks/data_collection); other providers ignore them.
84
+ # Best-effort: a missing/invalid config just yields default routing.
85
+ prefs = None
86
+ if normalized == "openrouter":
87
+ try:
88
+ from devcouncil.app.config import load_config
89
+
90
+ prefs = load_config(self.project_root).provider
91
+ except Exception:
92
+ prefs = None
93
+ self._role_providers[normalized] = create_provider(
94
+ normalized, api_key, project_root=self.project_root, provider_prefs=prefs
95
+ )
96
+ return self._role_providers[normalized]
45
97
 
46
98
  @staticmethod
47
99
  def _extract_json(content: str) -> str:
@@ -96,6 +148,23 @@ class ModelRouter:
96
148
  break
97
149
  return text
98
150
 
151
+ @staticmethod
152
+ def _looks_like_schema_echo(text: str) -> bool:
153
+ """True when the model returned the JSON *schema* instead of an instance.
154
+
155
+ Weaker/local models sometimes parrot the schema document we showed them
156
+ (``{"$defs": ..., "properties": ..., "type": "object"}``). That parses as JSON
157
+ but never validates, so detecting it lets the healing retry give a pointed
158
+ correction instead of the generic "fix your JSON" nudge."""
159
+ try:
160
+ obj = json.loads(text)
161
+ except Exception:
162
+ return False
163
+ if not isinstance(obj, dict):
164
+ return False
165
+ markers = {"$schema", "$defs", "properties", "additionalProperties", "$ref"}
166
+ return bool(markers & set(obj.keys()))
167
+
99
168
  async def _complete_with_retry(
100
169
  self,
101
170
  *,
@@ -103,15 +172,18 @@ class ModelRouter:
103
172
  messages: List[Dict[str, str]],
104
173
  temperature: float,
105
174
  run_id: Optional[str],
175
+ provider: Optional[Provider] = None,
106
176
  attempts: int = 3,
107
177
  ) -> "LLMResponse":
108
178
  """Provider completion with bounded exponential-backoff retry. Used for BOTH the
109
179
  initial call and the healing call so a transient fault in either is retried (and,
110
180
  if still failing, surfaced to the caller's fallback logic) rather than aborting
111
- the run."""
181
+ the run. ``provider`` defaults to the router's default provider but may be a
182
+ per-role provider for roles that override ``models.provider``."""
183
+ provider = provider or self.provider
112
184
  for attempt in range(attempts):
113
185
  try:
114
- return await self.provider.complete(
186
+ return await provider.complete(
115
187
  model=model,
116
188
  messages=messages,
117
189
  temperature=temperature,
@@ -141,16 +213,25 @@ class ModelRouter:
141
213
  config = self.role_config.get(role)
142
214
  if not config:
143
215
  raise ValueError(f"No config found for role: {role}")
144
-
216
+
145
217
  model = config["model"]
146
218
  temp = temperature if temperature is not None else config.get("temperature", 0.0)
219
+ provider = self._provider_for_role(config)
147
220
 
148
221
  # Deep-copy to avoid mutating the caller's messages list
149
222
  msgs = copy.deepcopy(messages)
150
223
 
151
- # Add schema instructions to system or user message
152
- schema_json = json.dumps(schema.model_json_schema(), indent=2)
153
- instruction = f"\n\nYou MUST output a JSON object matching this schema:\n{schema_json}"
224
+ # Add schema instructions to system or user message. Spell out "instance, not
225
+ # the schema" explicitly: weaker/local models otherwise sometimes echo the schema
226
+ # document back (``{"$defs": ..., "properties": ..., "type": "object"}``), which
227
+ # parses as JSON but fails validation and wastes a healing round.
228
+ schema_json = _cached_schema_json(schema)
229
+ instruction = (
230
+ "\n\nYou MUST output a single JSON object that is an INSTANCE of this schema — "
231
+ "real values for each field. Do NOT output the schema itself; never include "
232
+ 'keys like "$defs", "$schema", "properties", or "type".\nSchema:\n'
233
+ f"{schema_json}"
234
+ )
154
235
 
155
236
  found_system = False
156
237
  for msg in msgs:
@@ -164,25 +245,27 @@ class ModelRouter:
164
245
 
165
246
  logger.info("LLM call: role=%s model=%s run_id=%s", role, model, run_id)
166
247
 
167
- cache = LLMCache(self.project_root)
248
+ cache = self._cache
168
249
  tracker = TelemetryTracker(self.project_root)
169
- traces = TraceLogger(self.project_root)
250
+ traces = self._traces
170
251
 
171
252
  # Provider knobs (e.g. Ollama num_ctx / base_url) that change the output for an
172
253
  # identical prompt must be part of the cache key, else raising OLLAMA_NUM_CTX
173
254
  # after a truncated answer would keep serving the stale response.
174
- provider_fp = self.provider.cache_fingerprint()
255
+ provider_fp = provider.cache_fingerprint()
175
256
  # Zero local (Ollama) usage by provider so telemetry matches the cost ledger.
176
- provider_local = self.provider.is_local_cost_free()
257
+ provider_local = provider.is_local_cost_free()
177
258
 
178
259
  # Check cache first
179
260
  response = cache.get(model, msgs, temp, True, provider_fp)
180
261
  cache_hit = response is not None
181
262
 
263
+ started = time.monotonic()
182
264
  if not response:
183
265
  response = await self._complete_with_retry(
184
- model=model, messages=msgs, temperature=temp, run_id=run_id
266
+ model=model, messages=msgs, temperature=temp, run_id=run_id, provider=provider
185
267
  )
268
+ elapsed = time.monotonic() - started
186
269
 
187
270
  if response is None:
188
271
  raise RuntimeError(f"LLM request for role {role} did not return a response.")
@@ -190,9 +273,12 @@ class ModelRouter:
190
273
  if not cache_hit:
191
274
  tracker.log_usage(model, response.usage, local=provider_local)
192
275
 
276
+ # Include latency + cache status: on a slow (e.g. local) model this is what tells
277
+ # you *which* call dominated a multi-minute planning/verification stage.
193
278
  logger.info(
194
- "LLM response: role=%s model=%s tokens=%s",
279
+ "LLM response: role=%s model=%s tokens=%s %s",
195
280
  role, response.model, response.usage,
281
+ "cache_hit" if cache_hit else f"{elapsed:.1f}s",
196
282
  )
197
283
 
198
284
  try:
@@ -218,13 +304,23 @@ class ModelRouter:
218
304
  summary=f"Structured response parse failed for {role}; attempting repair.",
219
305
  )
220
306
 
221
- # Healing attempt: Ask the model to fix its own JSON
307
+ # Healing attempt: Ask the model to fix its own JSON. If it echoed the schema
308
+ # back instead of an instance, say so explicitly — the generic "fix it" nudge
309
+ # otherwise tends to produce the schema again.
310
+ echo_hint = ""
311
+ if self._looks_like_schema_echo(self._extract_json(response.content)):
312
+ echo_hint = (
313
+ "\nIMPORTANT: You returned the JSON *schema* (it contains keys like "
314
+ '"$defs"/"properties"/"type"), not a value. Return a concrete INSTANCE: '
315
+ "a JSON object whose keys are the schema's property names, each with a "
316
+ "real value of the correct type."
317
+ )
222
318
  healing_prompt = f"""
223
319
  The following JSON was returned but failed to parse or validate against the schema.
224
320
  Error: {str(e)}
225
321
  Content:
226
322
  {response.content}
227
-
323
+ {echo_hint}
228
324
  Please return the corrected JSON object only. No prose.
229
325
  """
230
326
  # The healing completion runs INSIDE this try (with the same retry/backoff as
@@ -239,6 +335,7 @@ Please return the corrected JSON object only. No prose.
239
335
  messages=[{"role": "user", "content": healing_prompt}],
240
336
  temperature=0.0,
241
337
  run_id=run_id,
338
+ provider=provider,
242
339
  )
243
340
  tracker.log_usage(healed_response.model, healed_response.usage, local=provider_local)
244
341
  healed_content = self._extract_json(healed_response.content)
@@ -272,15 +369,17 @@ Please return the corrected JSON object only. No prose.
272
369
  "(attempt %d/%d).",
273
370
  role, _attempt + 2, self.STRUCTURED_ATTEMPTS,
274
371
  )
275
- strict_messages = copy.deepcopy(messages)
276
- strict_messages.insert(0, {
277
- "role": "system",
278
- "content": (
279
- "Respond with a single valid JSON object only — no prose, no "
280
- "markdown fences, no trailing text. It must parse with a strict "
281
- "JSON parser and match the requested schema."
282
- ),
283
- })
372
+ strict_messages = [
373
+ {
374
+ "role": "system",
375
+ "content": (
376
+ "Respond with a single valid JSON object only — no prose, no "
377
+ "markdown fences, no trailing text. It must parse with a strict "
378
+ "JSON parser and match the requested schema."
379
+ ),
380
+ },
381
+ *messages,
382
+ ]
284
383
  return await self.complete_structured(
285
384
  role,
286
385
  strict_messages,