devcouncil 0.2.0 → 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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- 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
|
|
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
|
-
#
|
|
257
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
375
|
+
_log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
|
|
295
376
|
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
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 =
|
|
248
|
+
cache = self._cache
|
|
168
249
|
tracker = TelemetryTracker(self.project_root)
|
|
169
|
-
traces =
|
|
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 =
|
|
255
|
+
provider_fp = provider.cache_fingerprint()
|
|
175
256
|
# Zero local (Ollama) usage by provider so telemetry matches the cost ledger.
|
|
176
|
-
provider_local =
|
|
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 =
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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,
|