devcouncil 0.3.1 → 0.4.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 (366) hide show
  1. package/README.md +92 -33
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +56 -40
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +964 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +281 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +34 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +28 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +87 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +103 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +69 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +199 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +88 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +922 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +53 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +325 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/summary.py +21 -3
  255. package/src/devcouncil/live/tasks.py +12 -3
  256. package/src/devcouncil/live/transcripts.py +69 -2
  257. package/src/devcouncil/llm/cache.py +5 -6
  258. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  259. package/src/devcouncil/llm/provider.py +647 -73
  260. package/src/devcouncil/llm/router.py +271 -46
  261. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  262. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  263. package/src/devcouncil/optimization/skillopt.py +9 -5
  264. package/src/devcouncil/planning/arbiter_service.py +12 -3
  265. package/src/devcouncil/planning/correction_manifest.py +107 -10
  266. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  267. package/src/devcouncil/planning/plan_service.py +5 -2
  268. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  269. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  270. package/src/devcouncil/planning/question_conversion.py +56 -0
  271. package/src/devcouncil/planning/spec_service.py +9 -3
  272. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  273. package/src/devcouncil/repo/gitignore.py +1 -2
  274. package/src/devcouncil/reporting/evidence_export.py +124 -0
  275. package/src/devcouncil/reporting/evidence_html.py +210 -0
  276. package/src/devcouncil/reporting/json_report.py +16 -12
  277. package/src/devcouncil/reporting/markdown_report.py +38 -9
  278. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  279. package/src/devcouncil/reporting/report_builder.py +40 -4
  280. package/src/devcouncil/reporting/task_provenance.py +42 -0
  281. package/src/devcouncil/reporting/verdict.py +75 -0
  282. package/src/devcouncil/skills/library/README.md +1 -0
  283. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  285. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  286. package/src/devcouncil/skills/registry.py +43 -12
  287. package/src/devcouncil/storage/db.py +57 -11
  288. package/src/devcouncil/storage/models.py +6 -0
  289. package/src/devcouncil/storage/native.py +5 -3
  290. package/src/devcouncil/storage/repositories.py +50 -18
  291. package/src/devcouncil/telemetry/context.py +28 -0
  292. package/src/devcouncil/telemetry/cost.py +4 -5
  293. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  294. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  295. package/src/devcouncil/telemetry/stages.py +27 -2
  296. package/src/devcouncil/telemetry/tracker.py +50 -13
  297. package/src/devcouncil/ui/dashboard.py +120 -8
  298. package/src/devcouncil/utils/fsio.py +58 -0
  299. package/src/devcouncil/utils/git_snapshot.py +112 -0
  300. package/src/devcouncil/utils/json_persist.py +53 -0
  301. package/src/devcouncil/utils/proc.py +89 -0
  302. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  303. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  304. package/src/devcouncil/verification/checks/__init__.py +41 -0
  305. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  306. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  307. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  308. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  309. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  310. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  311. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  312. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  313. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  314. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  315. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  316. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  317. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  318. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  319. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  320. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  321. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  322. package/src/devcouncil/verification/checks/wiring.py +216 -0
  323. package/src/devcouncil/verification/claims/__init__.py +23 -0
  324. package/src/devcouncil/verification/claims/checks.py +395 -0
  325. package/src/devcouncil/verification/claims/mapper.py +168 -0
  326. package/src/devcouncil/verification/claims/models.py +39 -0
  327. package/src/devcouncil/verification/claims/transcript.py +92 -0
  328. package/src/devcouncil/verification/claims/verdict.py +88 -0
  329. package/src/devcouncil/verification/command_evidence.py +170 -0
  330. package/src/devcouncil/verification/command_malformation.py +147 -0
  331. package/src/devcouncil/verification/command_runner.py +164 -0
  332. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  333. package/src/devcouncil/verification/diff_coverage.py +151 -0
  334. package/src/devcouncil/verification/difficulty.py +296 -0
  335. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  336. package/src/devcouncil/verification/gap_ids.py +63 -0
  337. package/src/devcouncil/verification/gate_cache.py +194 -0
  338. package/src/devcouncil/verification/gate_selector.py +344 -0
  339. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  340. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  341. package/src/devcouncil/verification/incremental_check.py +241 -0
  342. package/src/devcouncil/verification/next_actions.py +60 -1
  343. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  344. package/src/devcouncil/verification/sandbox.py +38 -11
  345. package/src/devcouncil/verification/stub_detector.py +369 -0
  346. package/src/devcouncil/verification/test_resolver.py +67 -1
  347. package/src/devcouncil/verification/verifier.py +137 -1666
  348. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  349. package/src/devcouncil/verification/verify_setup.py +176 -0
  350. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  351. package/src/semantic_layer/__init__.py +58 -0
  352. package/src/semantic_layer/benchmark.py +75 -0
  353. package/src/semantic_layer/cache.py +290 -0
  354. package/src/semantic_layer/compressor.py +137 -0
  355. package/src/semantic_layer/config.py +75 -0
  356. package/src/semantic_layer/embeddings.py +69 -0
  357. package/src/semantic_layer/llm_backends.py +99 -0
  358. package/src/semantic_layer/pipeline.py +111 -0
  359. package/src/semantic_layer/router.py +128 -0
  360. package/src/semantic_layer/tuner.py +72 -0
  361. package/uv.lock +973 -9
  362. package/src/devcouncil/artifacts/migrations.py +0 -20
  363. package/src/devcouncil/artifacts/schemas.py +0 -23
  364. package/src/devcouncil/artifacts/serializer.py +0 -21
  365. package/src/devcouncil/integrations/gitnexus.py +0 -70
  366. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -1,11 +1,15 @@
1
1
  from abc import ABC, abstractmethod
2
+ import contextlib
2
3
  import copy
3
4
  from functools import lru_cache
4
5
  from importlib import resources
5
6
  import logging
6
7
  import os
7
- from typing import List, Dict, Any, Optional
8
+ from typing import TYPE_CHECKING, List, Dict, Any, Optional, cast
8
9
  from pydantic import BaseModel, field_validator
10
+
11
+ if TYPE_CHECKING:
12
+ import asyncio
9
13
  import httpx
10
14
  import json
11
15
  from pathlib import Path
@@ -40,9 +44,28 @@ DEFAULT_ROLE_MODELS_BY_PROVIDER = load_default_role_models_by_provider()
40
44
  class ProviderRequestError(RuntimeError):
41
45
  """A provider HTTP request failed, with an actionable, user-facing message."""
42
46
 
43
- def __init__(self, message: str, status_code: int | None = None):
47
+ def __init__(
48
+ self,
49
+ message: str,
50
+ status_code: int | None = None,
51
+ retry_after_seconds: float | None = None,
52
+ ):
44
53
  super().__init__(message)
45
54
  self.status_code = status_code
55
+ self.retry_after_seconds = retry_after_seconds
56
+
57
+
58
+ def _parse_retry_after(response: "httpx.Response") -> float | None:
59
+ headers = getattr(response, "headers", None)
60
+ if headers is None:
61
+ return None
62
+ raw = headers.get("retry-after") or headers.get("Retry-After")
63
+ if raw is None:
64
+ return None
65
+ try:
66
+ return max(0.0, float(raw))
67
+ except (TypeError, ValueError):
68
+ return None
46
69
 
47
70
 
48
71
  def raise_for_provider_status(response: "httpx.Response", provider: str) -> None:
@@ -70,7 +93,48 @@ def raise_for_provider_status(response: "httpx.Response", provider: str) -> None
70
93
  if body:
71
94
  message = f"{message} Response: {body}"
72
95
  logger.error("Provider request failed: %s", message)
73
- raise ProviderRequestError(message, status_code=status)
96
+ raise ProviderRequestError(
97
+ message,
98
+ status_code=status,
99
+ retry_after_seconds=_parse_retry_after(response) if status == 429 else None,
100
+ )
101
+
102
+
103
+ def _parse_provider_json(response: "httpx.Response", provider: str) -> Dict[str, Any]:
104
+ """Parse a provider response body, translating a non-JSON body (proxy HTML
105
+ error page, empty body on a flaky gateway) into an actionable
106
+ ProviderRequestError instead of a raw JSONDecodeError traceback — the CLI's
107
+ graceful-exit paths only catch ProviderRequestError/StructuredOutputError."""
108
+ try:
109
+ return cast(Dict[str, Any], response.json())
110
+ except Exception as exc:
111
+ body = (getattr(response, "text", "") or "").strip()[:300]
112
+ raise ProviderRequestError(
113
+ f"{provider} returned a non-JSON response body"
114
+ + (f": {body!r}" if body else " (empty body).")
115
+ ) from exc
116
+
117
+
118
+ def _extract_chat_content(data: Dict[str, Any], provider: str, model: str) -> Any:
119
+ """Extract choices[0].message.content, translating a missing-choices shape into
120
+ an actionable ProviderRequestError. OpenRouter (and OpenAI-compatible gateways)
121
+ can return HTTP 200 whose body is an ``error`` object instead of choices (e.g.
122
+ upstream provider failure, moderation) — a raw KeyError here would crash the
123
+ run with no hint of the actual provider message."""
124
+ try:
125
+ return data["choices"][0]["message"]["content"]
126
+ except (KeyError, IndexError, TypeError):
127
+ detail = ""
128
+ if isinstance(data, dict):
129
+ err = data.get("error")
130
+ if isinstance(err, dict):
131
+ detail = str(err.get("message") or err)[:300]
132
+ elif err:
133
+ detail = str(err)[:300]
134
+ raise ProviderRequestError(
135
+ f"{provider} returned no completion choices for model '{model}'"
136
+ + (f": {detail}" if detail else " (unrecognized response shape).")
137
+ )
74
138
 
75
139
 
76
140
  class LLMResponse(BaseModel):
@@ -100,7 +164,15 @@ class Provider(ABC):
100
164
  json_mode: bool = False,
101
165
  task_id: Optional[str] = None,
102
166
  run_id: Optional[str] = None,
167
+ json_schema: Optional[Dict[str, Any]] = None,
103
168
  ) -> LLMResponse:
169
+ """``json_schema`` (optional, only meaningful with ``json_mode=True``) is the
170
+ JSON Schema of the expected structured output. Providers that support
171
+ grammar-constrained decoding (Ollama's native ``format: <schema>``) use it to
172
+ make the model *incapable* of emitting invalid JSON — the single biggest
173
+ reliability lever for weak/local models, which otherwise waste healing
174
+ round-trips echoing the schema or emitting prose. Providers without such
175
+ support ignore it."""
104
176
  pass
105
177
 
106
178
  def _get_async_client(self, timeout: Any) -> "httpx.AsyncClient":
@@ -125,19 +197,12 @@ class Provider(ABC):
125
197
  loop = asyncio.get_running_loop()
126
198
  client = getattr(self, "_client", None)
127
199
  if client is not None and not client.is_closed and getattr(self, "_client_loop", None) is loop:
128
- return client
200
+ return cast("httpx.AsyncClient", client)
129
201
  client = httpx.AsyncClient(timeout=timeout)
130
202
  self._client: Optional[httpx.AsyncClient] = client
131
203
  self._client_loop = loop
132
204
  return client
133
205
 
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
206
  def cache_fingerprint(self) -> str:
142
207
  """Provider-specific options that change the model's output and therefore must
143
208
  be part of the LLM cache key. Empty for providers whose output depends only on
@@ -273,6 +338,7 @@ def _log_model_call(
273
338
  task_id: Optional[str] = None,
274
339
  run_id: Optional[str] = None,
275
340
  provider: Optional[str] = None,
341
+ latency_ms: Optional[int] = None,
276
342
  ) -> None:
277
343
  try:
278
344
  from datetime import datetime, timezone
@@ -280,14 +346,20 @@ def _log_model_call(
280
346
  from devcouncil.utils.redaction import redact_dict
281
347
  # Resolve against the provider's project root, not the process cwd — otherwise
282
348
  # running `dev` from another directory logged spend to the wrong project.
283
- log_dir = project_root / ".devcouncil" / "logs"
349
+ # DEVCOUNCIL_LOG_DIR (set by the test suite, optionally by CI) overrides so
350
+ # test/mocked calls never pollute a real project's spend ledger — observed:
351
+ # 298 of 302 entries in a real model_calls.jsonl were test-fixture pings.
352
+ override = os.environ.get("DEVCOUNCIL_LOG_DIR")
353
+ log_dir = Path(override) if override else project_root / ".devcouncil" / "logs"
284
354
  log_dir.mkdir(parents=True, exist_ok=True)
285
355
  log_file = log_dir / "model_calls.jsonl"
286
356
 
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.
357
+ # task_id/run_id/timestamp/provider/latency_ms are optional and backward-
358
+ # compatible: older records simply lack them and are grouped under
359
+ # "(unattributed)" by the cost reporter. provider lets the cost ledger
360
+ # zero-cost local providers (ollama) regardless of the open-ended model tag
361
+ # Ollama echoes back; latency_ms makes slow local calls diagnosable from the
362
+ # log alone (which call dominated a multi-minute verification stage).
291
363
  log_payload = {
292
364
  "request": redact_dict(payload),
293
365
  "response": redact_dict(data),
@@ -295,6 +367,7 @@ def _log_model_call(
295
367
  "task_id": task_id,
296
368
  "run_id": run_id,
297
369
  "provider": provider,
370
+ "latency_ms": latency_ms,
298
371
  "timestamp": datetime.now(timezone.utc).isoformat(),
299
372
  }
300
373
  with open(log_file, "a", encoding="utf-8") as f:
@@ -305,6 +378,10 @@ def _log_model_call(
305
378
 
306
379
 
307
380
  class OpenRouterProvider(Provider):
381
+ # Cap in-flight OpenRouter calls so acceptance-check fan-out stays under common
382
+ # ~20 RPM free-tier limits instead of tripping 429 mid-run.
383
+ DEFAULT_MAX_CONCURRENCY = 3
384
+
308
385
  def __init__(self, api_key: str, project_root: Path = Path("."), provider_prefs: Any = None):
309
386
  self.api_key = api_key
310
387
  self.base_url = "https://openrouter.ai/api/v1"
@@ -312,6 +389,119 @@ class OpenRouterProvider(Provider):
312
389
  # OpenRouter routing preferences (sort/allow_fallbacks/require_parameters/
313
390
  # data_collection) sent as the request's ``provider`` field. None → omit it.
314
391
  self.provider_prefs = openrouter_provider_payload(provider_prefs)
392
+ # Models whose serving stack rejected schema-constrained ``response_format``
393
+ # (per MODEL, not per instance: one router instance fans across models).
394
+ # Remembered so each such model pays the degrade retry only once.
395
+ self._schema_format_unsupported: set = set()
396
+ # Models whose endpoints reject ``response_format`` ENTIRELY (e.g. free-tier
397
+ # endpoints advertising no response_format/structured_outputs support: with
398
+ # ``require_parameters: true`` OpenRouter then 404s "no endpoints found" for
399
+ # BOTH json_schema and json_object). For these, JSON-mode requests rely on the
400
+ # prompt's JSON instruction + the router's extraction/healing path instead of
401
+ # failing the whole run.
402
+ self._response_format_unsupported: set = set()
403
+ self.max_concurrency = self._resolve_max_concurrency()
404
+ self._sem: "asyncio.Semaphore | None" = None
405
+ self._sem_loop: "asyncio.AbstractEventLoop | None" = None
406
+ # Client-side RPM pacing (OPENROUTER_RPM). Concurrency capping alone does
407
+ # not bound the REQUEST RATE: 2-at-a-time short calls still exceed a ~20 RPM
408
+ # endpoint cap and trip 429s that then burn the router's retry budget
409
+ # mid-run. Pacing spaces request STARTS so the cap is never hit at all.
410
+ self.requests_per_minute = self._resolve_rpm()
411
+ self._pace_lock: "asyncio.Lock | None" = None
412
+ self._pace_loop: "asyncio.AbstractEventLoop | None" = None
413
+ self._next_request_at = 0.0
414
+
415
+ @staticmethod
416
+ def _resolve_max_concurrency() -> int | None:
417
+ raw = os.environ.get("OPENROUTER_MAX_CONCURRENCY")
418
+ if raw is None:
419
+ return OpenRouterProvider.DEFAULT_MAX_CONCURRENCY
420
+ raw = raw.strip().lower()
421
+ if raw in {"0", "none", "off", ""}:
422
+ return None
423
+ try:
424
+ value = int(raw)
425
+ except ValueError:
426
+ return OpenRouterProvider.DEFAULT_MAX_CONCURRENCY
427
+ return value if value > 0 else None
428
+
429
+ def _get_semaphore(self) -> "asyncio.Semaphore | None":
430
+ import asyncio
431
+
432
+ if not self.max_concurrency:
433
+ return None
434
+ loop = asyncio.get_running_loop()
435
+ sem = getattr(self, "_sem", None)
436
+ if sem is not None and getattr(self, "_sem_loop", None) is loop:
437
+ return cast("asyncio.Semaphore", sem)
438
+ sem = asyncio.Semaphore(self.max_concurrency)
439
+ self._sem = sem
440
+ self._sem_loop = loop
441
+ return sem
442
+
443
+ @staticmethod
444
+ def _resolve_rpm() -> float | None:
445
+ """Requests-per-minute pacing from OPENROUTER_RPM; None/off disables."""
446
+ raw = os.environ.get("OPENROUTER_RPM")
447
+ if raw is None:
448
+ return None
449
+ raw = raw.strip().lower()
450
+ if raw in {"", "0", "none", "off"}:
451
+ return None
452
+ try:
453
+ value = float(raw)
454
+ except ValueError:
455
+ logger.warning("Ignoring invalid OPENROUTER_RPM=%r", raw)
456
+ return None
457
+ return value if value > 0 else None
458
+
459
+ async def _pace(self) -> None:
460
+ """Space request starts to at most ``requests_per_minute`` per minute,
461
+ and honor any active 429 cooldown (see ``_note_rate_limited``) even when
462
+ RPM pacing itself is disabled.
463
+
464
+ A short critical section reserves this request's start slot; the sleep
465
+ happens OUTSIDE the lock so waiting requests queue timestamps rather
466
+ than serializing their full durations."""
467
+ if not self.requests_per_minute and self._next_request_at <= 0.0:
468
+ return
469
+ import asyncio
470
+ import time
471
+
472
+ loop = asyncio.get_running_loop()
473
+ if self._pace_lock is None or self._pace_loop is not loop:
474
+ self._pace_lock = asyncio.Lock()
475
+ self._pace_loop = loop
476
+ interval = (60.0 / self.requests_per_minute) if self.requests_per_minute else 0.0
477
+ async with self._pace_lock:
478
+ now = time.monotonic()
479
+ wait = self._next_request_at - now
480
+ self._next_request_at = max(now, self._next_request_at) + interval
481
+ if wait > 0:
482
+ await asyncio.sleep(wait)
483
+
484
+ # Fallback cooldown after a 429 that carried no Retry-After header — matches
485
+ # the router's first 429 backoff step so both layers agree on the pause.
486
+ RATE_LIMIT_FALLBACK_COOLDOWN = 15.0
487
+
488
+ def _note_rate_limited(self, retry_after: float | None) -> None:
489
+ """Push the SHARED pacing slot past the provider-announced cooldown.
490
+
491
+ Without this, only the request that received the 429 backs off; its
492
+ concurrent siblings (acceptance-check fan-out) each slam into the same
493
+ exhausted window and burn their own retry budgets. Called from the
494
+ event-loop thread with no await between read and write, so the plain
495
+ max() update is safe without the pace lock."""
496
+ import time
497
+
498
+ delay = retry_after if retry_after and retry_after > 0 else self.RATE_LIMIT_FALLBACK_COOLDOWN
499
+ self._next_request_at = max(self._next_request_at, time.monotonic() + min(120.0, delay))
500
+
501
+ # Statuses that mean "this endpoint/parameter combination is rejected" — the only
502
+ # ones worth a degrade retry. 429/5xx are transient and must surface to the
503
+ # caller's retry/backoff instead of permanently disabling structured output.
504
+ _PARAM_REJECTED_STATUSES = frozenset({400, 404, 422})
315
505
 
316
506
  def cache_fingerprint(self) -> str:
317
507
  # Routing prefs change which upstream provider/model serves the request (and the
@@ -322,13 +512,14 @@ class OpenRouterProvider(Provider):
322
512
  return "openrouter:provider=" + json.dumps(self.provider_prefs, sort_keys=True)
323
513
 
324
514
  async def complete(
325
- self,
326
- model: str,
327
- messages: List[Dict[str, str]],
515
+ self,
516
+ model: str,
517
+ messages: List[Dict[str, str]],
328
518
  temperature: float = 0.0,
329
519
  json_mode: bool = False,
330
520
  task_id: Optional[str] = None,
331
521
  run_id: Optional[str] = None,
522
+ json_schema: Optional[Dict[str, Any]] = None,
332
523
  ) -> LLMResponse:
333
524
  # Only deep-copy when json_mode mutates the last message; otherwise the
334
525
  # caller's list is read but never modified, so we can use it directly.
@@ -348,7 +539,27 @@ class OpenRouterProvider(Provider):
348
539
  }
349
540
 
350
541
  if json_mode:
351
- payload["response_format"] = {"type": "json_object"}
542
+ # Schema-constrained structured output when the caller supplied a schema
543
+ # and this model hasn't rejected it before. Cloud models on plain
544
+ # ``json_object`` routinely OMIT fields that would be empty (observed:
545
+ # gemini-2.5-flash dropping empty ``blocking_questions``/``final_tasks``
546
+ # lists), which crashes planning schemas; ``json_schema`` makes the
547
+ # response structurally complete. Models/routes that reject it degrade
548
+ # to ``json_object`` (and, if even that is rejected, to NO
549
+ # response_format at all) below and are remembered per model.
550
+ if model in self._response_format_unsupported:
551
+ pass # prompt-only JSON; the router's extraction/healing handles it
552
+ elif json_schema is not None and model not in self._schema_format_unsupported:
553
+ payload["response_format"] = {
554
+ "type": "json_schema",
555
+ "json_schema": {
556
+ "name": "structured_output",
557
+ "strict": True,
558
+ "schema": json_schema,
559
+ },
560
+ }
561
+ else:
562
+ payload["response_format"] = {"type": "json_object"}
352
563
  # Ensure the user message mentions JSON
353
564
  if msgs[-1]["role"] == "user":
354
565
  msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
@@ -356,25 +567,86 @@ class OpenRouterProvider(Provider):
356
567
  if self.provider_prefs:
357
568
  payload["provider"] = self.provider_prefs
358
569
 
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
- )
570
+ def _param_rejected(resp: "httpx.Response") -> bool:
571
+ return getattr(resp, "status_code", 200) in self._PARAM_REJECTED_STATUSES
572
+
573
+ import time as _time
574
+
575
+ started = _time.monotonic()
576
+ semaphore = self._get_semaphore()
577
+ async with semaphore if semaphore is not None else contextlib.nullcontext():
578
+ client = self._get_async_client(180.0)
579
+
580
+ async def _post_paced() -> "httpx.Response":
581
+ # Pace EVERY post (including degrade-chain retries): each one is
582
+ # a real request against the endpoint's RPM budget.
583
+ await self._pace()
584
+ return await client.post(
585
+ f"{self.base_url}/chat/completions",
586
+ headers=headers,
587
+ json=payload,
588
+ )
589
+
590
+ response = await _post_paced()
591
+ response_format = payload.get("response_format")
592
+ if (
593
+ isinstance(response_format, dict)
594
+ and response_format.get("type") == "json_schema"
595
+ and _param_rejected(response)
596
+ ):
597
+ # The model/route rejected schema-constrained output (unsupported
598
+ # parameter, incompatible schema subset, ...). Degrade once to the
599
+ # plain json_object switch — the prompt still carries the schema
600
+ # instruction — and never pay this retry again for this model.
601
+ logger.info(
602
+ "OpenRouter rejected json_schema response_format for model %s "
603
+ "(HTTP %s); retrying with json_object",
604
+ model, response.status_code,
605
+ )
606
+ self._schema_format_unsupported.add(model)
607
+ payload["response_format"] = {"type": "json_object"}
608
+ response = await _post_paced()
609
+ if "response_format" in payload and _param_rejected(response):
610
+ # Even ``json_object`` was rejected: some endpoints (commonly free
611
+ # tiers) support no ``response_format`` variant at all, and with
612
+ # ``provider.require_parameters: true`` OpenRouter answers 404
613
+ # "no endpoints found" rather than routing around the parameter —
614
+ # which previously killed planning outright (observed: every arm-B
615
+ # benchmark task erroring in ~8s on such a model). Drop the field,
616
+ # remember per model, and rely on the prompt's JSON instruction +
617
+ # the router's extraction/healing path.
618
+ logger.warning(
619
+ "OpenRouter rejected response_format entirely for model %s "
620
+ "(HTTP %s); retrying without structured output. JSON will be "
621
+ "prompt-enforced only — prefer a model with response_format "
622
+ "support for planning roles if this recurs.",
623
+ model, response.status_code,
624
+ )
625
+ self._response_format_unsupported.add(model)
626
+ payload.pop("response_format", None)
627
+ response = await _post_paced()
628
+ if getattr(response, "status_code", None) == 429:
629
+ # Cooldown is shared across ALL in-flight callers so the whole
630
+ # process backs off together, not one request at a time.
631
+ self._note_rate_limited(_parse_retry_after(response))
632
+ raise_for_provider_status(response, "OpenRouter")
633
+ data = _parse_provider_json(response, "OpenRouter")
634
+
635
+ resp = LLMResponse(
636
+ content=_extract_chat_content(data, "OpenRouter", model),
637
+ model=data["model"],
638
+ usage=data.get("usage", {}),
639
+ raw_response=data
640
+ )
374
641
 
375
- _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
642
+ # Includes queue/pacing wait the latency the caller experienced.
643
+ latency_ms = int((_time.monotonic() - started) * 1000)
644
+ _log_model_call(
645
+ payload, data, resp.usage, self.project_root,
646
+ task_id=task_id, run_id=run_id, provider="openrouter", latency_ms=latency_ms,
647
+ )
376
648
 
377
- return resp
649
+ return resp
378
650
 
379
651
 
380
652
  class DoublewordProvider(Provider):
@@ -391,6 +663,7 @@ class DoublewordProvider(Provider):
391
663
  json_mode: bool = False,
392
664
  task_id: Optional[str] = None,
393
665
  run_id: Optional[str] = None,
666
+ json_schema: Optional[Dict[str, Any]] = None, # accepted for interface parity; not used
394
667
  ) -> LLMResponse:
395
668
  # Only deep-copy when json_mode mutates the last message; otherwise the
396
669
  # caller's list is read but never modified, so we can use it directly.
@@ -410,6 +683,9 @@ class DoublewordProvider(Provider):
410
683
  if msgs[-1]["role"] == "user":
411
684
  msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
412
685
 
686
+ import time as _time
687
+
688
+ started = _time.monotonic()
413
689
  client = self._get_async_client(180.0)
414
690
  response = await client.post(
415
691
  f"{self.base_url}/chat/completions",
@@ -417,16 +693,20 @@ class DoublewordProvider(Provider):
417
693
  json=payload,
418
694
  )
419
695
  raise_for_provider_status(response, "Doubleword")
420
- data = response.json()
696
+ latency_ms = int((_time.monotonic() - started) * 1000)
697
+ data = _parse_provider_json(response, "Doubleword")
421
698
 
422
699
  resp = LLMResponse(
423
- content=data["choices"][0]["message"]["content"],
700
+ content=_extract_chat_content(data, "Doubleword", model),
424
701
  model=data["model"],
425
702
  usage=data.get("usage", {}),
426
703
  raw_response=data
427
704
  )
428
705
 
429
- _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
706
+ _log_model_call(
707
+ payload, data, resp.usage, self.project_root,
708
+ task_id=task_id, run_id=run_id, provider="doubleword", latency_ms=latency_ms,
709
+ )
430
710
  return resp
431
711
 
432
712
 
@@ -455,13 +735,203 @@ class OllamaProvider(Provider):
455
735
  self.base_url = base_url or self._resolve_base_url()
456
736
  self.project_root = project_root
457
737
  self.num_ctx = num_ctx if num_ctx is not None else self._resolve_num_ctx()
738
+ self.max_num_ctx = self._resolve_max_num_ctx()
739
+ self.keep_alive = self._resolve_keep_alive()
458
740
  self.timeout = self._resolve_timeout()
741
+ self.think = self._resolve_think()
742
+ self.num_predict = self._resolve_num_predict()
743
+ self.max_concurrency = self._resolve_max_concurrency()
744
+ # Set when a server rejects the ``think`` field (older Ollama / non-thinking
745
+ # model) so subsequent calls skip it instead of paying a retry every time.
746
+ self._think_unsupported = False
747
+ self._sem: "asyncio.Semaphore | None" = None
748
+ self._sem_loop: "asyncio.AbstractEventLoop | None" = None
459
749
 
460
750
  # Local generation latency is unbounded (cold loads, CPU-only hosts, large
461
751
  # ``num_ctx``) and is not a network failure, so Ollama gets a generous default
462
752
  # and an explicit override rather than the cloud providers' fixed 180s.
463
753
  DEFAULT_TIMEOUT = 600.0
464
754
 
755
+ # Default context window when OLLAMA_NUM_CTX is unset. Ollama's own server
756
+ # default (2k–4k depending on version) silently TRUNCATES DevCouncil's planning
757
+ # and verification prompts (up to ~15k tokens) — the model then plans/reviews
758
+ # against half a prompt, which surfaces as garbage plans and miscalibrated
759
+ # verdicts on local models. 16k covers the largest prompt DevCouncil builds.
760
+ # Override (up or down, e.g. for a VRAM-limited host) with OLLAMA_NUM_CTX.
761
+ DEFAULT_NUM_CTX = 16384
762
+
763
+ # Default model keep-alive when OLLAMA_KEEP_ALIVE is unset. Ollama unloads a
764
+ # model after 5 minutes idle; a gated run interleaves LLM calls with long
765
+ # non-LLM phases (executor runs can exceed 5m), so each council/verify stage
766
+ # would otherwise pay a multi-minute cold reload of a 30B+ model. "30m" keeps
767
+ # the model resident across a typical task cycle at zero cost when idle-free.
768
+ DEFAULT_KEEP_ALIVE = "30m"
769
+
770
+ # Ceiling for the ADAPTIVE context window (see complete()): when a prompt would
771
+ # not fit the configured num_ctx, the request's num_ctx is raised to fit — up to
772
+ # this cap — instead of letting Ollama silently truncate the prompt (which makes
773
+ # the model review/plan against half a prompt and surfaces as garbage output and
774
+ # miscalibrated verdicts). Capped because num_ctx directly scales KV-cache VRAM.
775
+ # Override with OLLAMA_MAX_NUM_CTX; an explicit OLLAMA_NUM_CTX also raises it
776
+ # (the cap is never below the configured window).
777
+ DEFAULT_MAX_NUM_CTX = 65536
778
+
779
+ # Crude chars-per-token estimate for sizing the adaptive window. 3 chars/token
780
+ # deliberately over-estimates tokens (most code/text averages ~3.5-4), so the
781
+ # adaptive window errs toward "too big" rather than silent truncation.
782
+ _CHARS_PER_TOKEN = 3.0
783
+ # Headroom reserved for generation + chat template overhead when fitting a
784
+ # prompt into the adaptive window.
785
+ _RESPONSE_HEADROOM_TOKENS = 2048
786
+
787
+ @staticmethod
788
+ def _resolve_keep_alive() -> str | None:
789
+ """Keep-alive from ``OLLAMA_KEEP_ALIVE`` (Ollama duration string like ``10m``,
790
+ ``0`` to unload immediately, ``-1`` to pin forever). Unset falls back to
791
+ :data:`DEFAULT_KEEP_ALIVE`; the literal ``default`` defers to the server."""
792
+ raw = os.environ.get("OLLAMA_KEEP_ALIVE")
793
+ if raw is None:
794
+ return OllamaProvider.DEFAULT_KEEP_ALIVE
795
+ raw = raw.strip()
796
+ if not raw or raw.lower() == "default":
797
+ return None # omit from payload; let the server decide
798
+ return raw
799
+
800
+ @staticmethod
801
+ def _resolve_think() -> bool | str | None:
802
+ """Thinking-mode / thinking-BUDGET override from ``OLLAMA_THINK``.
803
+
804
+ Unset -> ``None`` (omit the field; the server/model default applies).
805
+ Truthy (``1``/``true``/``on``/``yes``) -> request thinking explicitly.
806
+ Falsy (``0``/``false``/``off``/``no``) -> disable thinking.
807
+ ``low``/``medium``/``high`` -> a thinking-budget LEVEL, passed through
808
+ verbatim (Ollama >= 0.12 supports string levels on budget-capable models,
809
+ e.g. gpt-oss; the existing 400-degrade path covers servers/models that
810
+ reject it).
811
+
812
+ Why this exists: on thinking-capable local models (qwen3 family, deepseek-r1,
813
+ Ornith) the reasoning channel dominates latency for DevCouncil's structured
814
+ review/verification calls — measured locally at ~65x (156s with thinking vs
815
+ 2.4s without for one acceptance-check compile). Thinking often *helps* answer
816
+ quality, so DevCouncil does not flip the default; this knob lets a user trade
817
+ latency for quality per host. Servers/models that reject the field degrade
818
+ gracefully (one retry without it, then it is skipped for the provider's life).
819
+ """
820
+ raw = os.environ.get("OLLAMA_THINK")
821
+ if raw is None:
822
+ return None
823
+ raw = raw.strip().lower()
824
+ if raw in {"1", "true", "on", "yes"}:
825
+ return True
826
+ if raw in {"0", "false", "off", "no"}:
827
+ return False
828
+ if raw in {"low", "medium", "high"}:
829
+ return raw
830
+ return None
831
+
832
+ # Default client-side cap on in-flight requests to one Ollama server. Callers
833
+ # legitimately fan out (per-criterion acceptance compiles x samples can launch
834
+ # 20+ concurrent calls), but a local server generates (near-)serially — so the
835
+ # HTTP read timeout of a QUEUED request starts ticking long before the server
836
+ # even sees it, and late requests time out at any timeout setting (the observed
837
+ # benchmark failure). Capping in-flight requests makes queue wait happen
838
+ # client-side, where it does not count against the per-request timeout.
839
+ DEFAULT_MAX_CONCURRENCY = 2
840
+
841
+ @staticmethod
842
+ def _resolve_max_concurrency() -> int | None:
843
+ """In-flight request cap from ``OLLAMA_MAX_CONCURRENCY`` (positive int).
844
+ ``0``/``none``/``off`` disables the cap (e.g. for a serving stack that
845
+ genuinely parallelizes); unset/invalid falls back to
846
+ :data:`DEFAULT_MAX_CONCURRENCY`."""
847
+ raw = os.environ.get("OLLAMA_MAX_CONCURRENCY")
848
+ if raw is None:
849
+ return OllamaProvider.DEFAULT_MAX_CONCURRENCY
850
+ raw = raw.strip().lower()
851
+ if raw in {"0", "none", "off", ""}:
852
+ return None
853
+ try:
854
+ value = int(raw)
855
+ except ValueError:
856
+ return OllamaProvider.DEFAULT_MAX_CONCURRENCY
857
+ return value if value > 0 else None
858
+
859
+ def _get_semaphore(self) -> "asyncio.Semaphore | None":
860
+ """Lazily create the concurrency semaphore, rebound per event loop.
861
+
862
+ Like ``_get_async_client``: an ``asyncio.Semaphore`` belongs to the loop
863
+ that created it, and one provider instance may be driven from successive
864
+ ``asyncio.run`` loops. ``None`` when the cap is disabled."""
865
+ import asyncio
866
+
867
+ if not self.max_concurrency:
868
+ return None
869
+ loop = asyncio.get_running_loop()
870
+ sem = getattr(self, "_sem", None)
871
+ if sem is not None and getattr(self, "_sem_loop", None) is loop:
872
+ return cast("asyncio.Semaphore", sem)
873
+ sem = asyncio.Semaphore(self.max_concurrency)
874
+ self._sem = sem
875
+ self._sem_loop = loop
876
+ return sem
877
+
878
+ @staticmethod
879
+ def _resolve_num_predict() -> int | None:
880
+ """Generation-token cap from ``OLLAMA_NUM_PREDICT`` (positive int; unset or
881
+ invalid -> no cap). Bounds the WORST CASE of an unbounded thinking spiral: a
882
+ reasoning model that never stops thinking otherwise generates until the HTTP
883
+ timeout (600s default), and the router's structured-output layers can stack
884
+ those stalls past an outer scheduler/benchmark kill. With a cap, a runaway
885
+ call instead returns quickly with ``done_reason=length`` and flows into the
886
+ existing truncation warning + healing path."""
887
+ raw = os.environ.get("OLLAMA_NUM_PREDICT")
888
+ if not raw:
889
+ return None
890
+ try:
891
+ value = int(raw)
892
+ except (TypeError, ValueError):
893
+ return None
894
+ return value if value > 0 else None
895
+
896
+ @staticmethod
897
+ def _resolve_max_num_ctx() -> int:
898
+ """Adaptive-context ceiling from ``OLLAMA_MAX_NUM_CTX`` (positive int).
899
+ Unset/invalid falls back to :data:`DEFAULT_MAX_NUM_CTX`."""
900
+ raw = os.environ.get("OLLAMA_MAX_NUM_CTX")
901
+ if not raw:
902
+ return OllamaProvider.DEFAULT_MAX_NUM_CTX
903
+ try:
904
+ value = int(raw)
905
+ except (TypeError, ValueError):
906
+ return OllamaProvider.DEFAULT_MAX_NUM_CTX
907
+ return value if value > 0 else OllamaProvider.DEFAULT_MAX_NUM_CTX
908
+
909
+ def _effective_num_ctx(self, messages: List[Dict[str, str]]) -> int | None:
910
+ """The context window to request for THIS call.
911
+
912
+ Starts from the configured ``num_ctx`` and, when the prompt's estimated token
913
+ count (plus response headroom) would overflow it, grows the window to fit — up
914
+ to ``max_num_ctx`` (never below an explicitly configured window). Ollama
915
+ silently TRUNCATES a prompt that exceeds num_ctx, so without this a large
916
+ verification diff or planning prompt gets reviewed half-read; a too-large
917
+ request merely costs KV-cache memory. Returns None when num_ctx is disabled
918
+ (explicit server-default opt-out)."""
919
+ if not self.num_ctx:
920
+ return None
921
+ prompt_chars = sum(len(m.get("content") or "") for m in messages)
922
+ needed = int(prompt_chars / self._CHARS_PER_TOKEN) + self._RESPONSE_HEADROOM_TOKENS
923
+ if needed <= self.num_ctx:
924
+ return self.num_ctx
925
+ ceiling = max(self.max_num_ctx, self.num_ctx)
926
+ effective = min(needed, ceiling)
927
+ if effective > self.num_ctx:
928
+ logger.info(
929
+ "Ollama: raising num_ctx %d -> %d for a ~%d-token prompt "
930
+ "(prevents silent server-side truncation; cap OLLAMA_MAX_NUM_CTX=%d)",
931
+ self.num_ctx, effective, needed - self._RESPONSE_HEADROOM_TOKENS, ceiling,
932
+ )
933
+ return effective
934
+
465
935
  @staticmethod
466
936
  def _resolve_timeout() -> float | None:
467
937
  """Read timeout from ``OLLAMA_TIMEOUT`` seconds (positive float). ``0``/``none``/
@@ -480,12 +950,17 @@ class OllamaProvider(Provider):
480
950
  return value if value > 0 else None
481
951
 
482
952
  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
953
+ # num_ctx (and the adaptive ceiling), think, and the target server all change the
954
+ # response for an identical prompt (a larger window avoids the truncation a smaller
955
+ # one silently applies; thinking alters generation; a different endpoint is a
956
+ # different model server), so each must invalidate the cache. Key on the
957
+ # *normalized* /api/chat endpoint, not the raw base_url, so equivalent configs
487
958
  # (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()}"
959
+ return (
960
+ f"ollama:num_ctx={self.num_ctx};max_num_ctx={self.max_num_ctx};"
961
+ f"think={self.think};num_predict={self.num_predict};"
962
+ f"endpoint={self._chat_endpoint()}"
963
+ )
489
964
 
490
965
  def is_local_cost_free(self) -> bool:
491
966
  return True
@@ -508,14 +983,17 @@ class OllamaProvider(Provider):
508
983
 
509
984
  @staticmethod
510
985
  def _resolve_num_ctx() -> int | None:
511
- """Context window from ``OLLAMA_NUM_CTX`` (positive int), else None (server default)."""
986
+ """Context window from ``OLLAMA_NUM_CTX`` (positive int). Unset/invalid falls
987
+ back to :data:`DEFAULT_NUM_CTX` — never the server default, which is small
988
+ enough to silently truncate DevCouncil's planning prompts. ``0``/negative
989
+ explicitly requests the server default (opt-out)."""
512
990
  raw = os.environ.get("OLLAMA_NUM_CTX")
513
991
  if not raw:
514
- return None
992
+ return OllamaProvider.DEFAULT_NUM_CTX
515
993
  try:
516
994
  value = int(raw)
517
995
  except (TypeError, ValueError):
518
- return None
996
+ return OllamaProvider.DEFAULT_NUM_CTX
519
997
  return value if value > 0 else None
520
998
 
521
999
  def _chat_endpoint(self) -> str:
@@ -533,6 +1011,7 @@ class OllamaProvider(Provider):
533
1011
  json_mode: bool = False,
534
1012
  task_id: Optional[str] = None,
535
1013
  run_id: Optional[str] = None,
1014
+ json_schema: Optional[Dict[str, Any]] = None,
536
1015
  ) -> LLMResponse:
537
1016
  # Only deep-copy when json_mode mutates the last message; otherwise the
538
1017
  # caller's list is read but never modified, so we can use it directly.
@@ -545,11 +1024,17 @@ class OllamaProvider(Provider):
545
1024
  if self.api_key:
546
1025
  headers["Authorization"] = f"Bearer {self.api_key}"
547
1026
 
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.
1027
+ # Native /api/chat options. temperature and num_ctx live under "options"; the
1028
+ # window is sized per call (see _effective_num_ctx) so a large verification /
1029
+ # planning prompt is never silently truncated server-side.
550
1030
  options: Dict[str, Any] = {"temperature": temperature}
551
- if self.num_ctx:
552
- options["num_ctx"] = self.num_ctx
1031
+ effective_ctx = self._effective_num_ctx(msgs)
1032
+ if effective_ctx:
1033
+ options["num_ctx"] = effective_ctx
1034
+ # Cap generation so a thinking model that never stops reasoning returns a
1035
+ # truncated (healable) response instead of running into the HTTP timeout.
1036
+ if self.num_predict:
1037
+ options["num_predict"] = self.num_predict
553
1038
 
554
1039
  payload: Dict[str, Any] = {
555
1040
  "model": model,
@@ -557,26 +1042,80 @@ class OllamaProvider(Provider):
557
1042
  "stream": False,
558
1043
  "options": options,
559
1044
  }
1045
+ # Keep the model resident between DevCouncil's interleaved LLM / non-LLM
1046
+ # phases so a 30B+ local model isn't cold-reloaded mid-run (minutes each time).
1047
+ if self.keep_alive is not None:
1048
+ payload["keep_alive"] = self.keep_alive
1049
+ # Explicit thinking-mode request (OLLAMA_THINK). Omitted when unset or when a
1050
+ # previous call learned this server/model rejects the field.
1051
+ if self.think is not None and not self._think_unsupported:
1052
+ payload["think"] = self.think
560
1053
 
561
1054
  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"
1055
+ # Native structured output. Passing the actual JSON SCHEMA (supported by
1056
+ # Ollama >= 0.5) constrains DECODING to the schema's grammar a local
1057
+ # model literally cannot emit prose, fences, or a schema echo, which
1058
+ # eliminates most healing retries. Plain "json" is the fallback for
1059
+ # callers without a schema (and for older servers, handled below).
1060
+ payload["format"] = json_schema if json_schema else "json"
565
1061
  if msgs[-1]["role"] == "user":
566
1062
  msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
567
1063
 
1064
+ import contextlib
1065
+ import time as _time
1066
+
568
1067
  client = self._get_async_client(self.timeout)
569
- response = await client.post(
570
- self._chat_endpoint(),
571
- headers=headers,
572
- json=payload,
573
- )
1068
+ started = _time.monotonic()
1069
+ # Serialize in-flight requests up to max_concurrency: a local server
1070
+ # generates (near-)serially, so without this a caller fan-out (e.g.
1071
+ # per-criterion acceptance compiles x samples) queues requests SERVER-side
1072
+ # where their read timeouts tick while waiting — late requests then time
1073
+ # out at any timeout setting. The degrade retries below stay inside the
1074
+ # slot so one logical call holds one slot start-to-finish.
1075
+ semaphore = self._get_semaphore()
1076
+ async with (semaphore if semaphore is not None else contextlib.nullcontext()):
1077
+ response = await client.post(
1078
+ self._chat_endpoint(),
1079
+ headers=headers,
1080
+ json=payload,
1081
+ )
1082
+ if "think" in payload and getattr(response, "status_code", 200) >= 400:
1083
+ # Older Ollama servers (< 0.9) and non-thinking models reject the ``think``
1084
+ # field. Drop it once, remember, and never fail the run over a latency knob.
1085
+ logger.info(
1086
+ "Ollama rejected the think field (HTTP %s); retrying without it",
1087
+ response.status_code,
1088
+ )
1089
+ self._think_unsupported = True
1090
+ payload.pop("think", None)
1091
+ response = await client.post(
1092
+ self._chat_endpoint(),
1093
+ headers=headers,
1094
+ json=payload,
1095
+ )
1096
+ if json_schema is not None and getattr(response, "status_code", 200) >= 400:
1097
+ # An Ollama server predating schema-constrained ``format`` rejects the
1098
+ # request (400). Degrade once to the plain "json" switch rather than
1099
+ # failing the run over an optional optimization.
1100
+ logger.info(
1101
+ "Ollama rejected schema-constrained format (HTTP %s); retrying with format=json",
1102
+ response.status_code,
1103
+ )
1104
+ payload["format"] = "json"
1105
+ response = await client.post(
1106
+ self._chat_endpoint(),
1107
+ headers=headers,
1108
+ json=payload,
1109
+ )
574
1110
  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.
1111
+ # Includes any client-side queue wait — that is the latency the caller
1112
+ # actually experienced, which is what makes a slow stage diagnosable.
1113
+ latency_ms = int((_time.monotonic() - started) * 1000)
1114
+ data = _parse_provider_json(response, "Ollama")
1115
+
1116
+ # Native response shape: {"message": {"content": ..., "thinking": ...},
1117
+ # "model": ..., "prompt_eval_count": N, "eval_count": M}. Map token counts
1118
+ # to the OpenAI-style keys the cost ledger/tracker expect.
580
1119
  prompt_tokens = int(data.get("prompt_eval_count", 0) or 0)
581
1120
  completion_tokens = int(data.get("eval_count", 0) or 0)
582
1121
  usage = {
@@ -584,8 +1123,31 @@ class OllamaProvider(Provider):
584
1123
  "completion_tokens": completion_tokens,
585
1124
  "total_tokens": prompt_tokens + completion_tokens,
586
1125
  }
1126
+ message = data.get("message") or {}
1127
+ content = message.get("content", "") or ""
1128
+ if not content.strip() and (message.get("thinking") or "").strip():
1129
+ # A thinking model that spent its whole budget reasoning (or answered
1130
+ # inside the reasoning channel) returns an empty content. Surface the
1131
+ # thinking text so the router's extraction/healing path has SOMETHING
1132
+ # to parse instead of failing on an empty string.
1133
+ content = message["thinking"]
1134
+ logger.warning(
1135
+ "Ollama returned empty content with a non-empty thinking channel "
1136
+ "(model=%s); using the thinking text for parsing. If this recurs, "
1137
+ "set OLLAMA_THINK=false for this host.",
1138
+ data.get("model", model),
1139
+ )
1140
+ if data.get("done_reason") == "length":
1141
+ # Generation hit the token limit mid-answer — structured output is very
1142
+ # likely cut off. Loud, actionable log rather than a silent parse failure.
1143
+ logger.warning(
1144
+ "Ollama generation truncated by length (model=%s, eval_count=%s). "
1145
+ "Structured output may be incomplete; on a thinking model consider "
1146
+ "OLLAMA_THINK=false or a larger context (OLLAMA_NUM_CTX/OLLAMA_MAX_NUM_CTX).",
1147
+ data.get("model", model), completion_tokens,
1148
+ )
587
1149
  resp = LLMResponse(
588
- content=(data.get("message") or {}).get("content", ""),
1150
+ content=content,
589
1151
  # Ollama may omit ``model`` or return a local tag — fall back to
590
1152
  # the requested id rather than KeyError-ing.
591
1153
  model=data.get("model", model),
@@ -593,7 +1155,10 @@ class OllamaProvider(Provider):
593
1155
  raw_response=data,
594
1156
  )
595
1157
 
596
- _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id, provider="ollama")
1158
+ _log_model_call(
1159
+ payload, data, resp.usage, self.project_root,
1160
+ task_id=task_id, run_id=run_id, provider="ollama", latency_ms=latency_ms,
1161
+ )
597
1162
  return resp
598
1163
 
599
1164
 
@@ -640,6 +1205,7 @@ class VertexAIProvider(Provider):
640
1205
  json_mode: bool = False,
641
1206
  task_id: Optional[str] = None,
642
1207
  run_id: Optional[str] = None,
1208
+ json_schema: Optional[Dict[str, Any]] = None, # accepted for interface parity; not used
643
1209
  ) -> LLMResponse:
644
1210
  # Only deep-copy when json_mode mutates the last message; otherwise the
645
1211
  # caller's list is read but never modified, so we can use it directly.
@@ -656,6 +1222,9 @@ class VertexAIProvider(Provider):
656
1222
  if msgs[-1]["role"] == "user":
657
1223
  msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
658
1224
 
1225
+ import time as _time
1226
+
1227
+ started = _time.monotonic()
659
1228
  client = self._get_async_client(180.0)
660
1229
  response = await client.post(
661
1230
  f"{self.base_url}/chat/completions",
@@ -669,16 +1238,20 @@ class VertexAIProvider(Provider):
669
1238
  json=payload,
670
1239
  )
671
1240
  raise_for_provider_status(response, "Vertex AI")
672
- data = response.json()
1241
+ latency_ms = int((_time.monotonic() - started) * 1000)
1242
+ data = _parse_provider_json(response, "Vertex AI")
673
1243
 
674
1244
  resp = LLMResponse(
675
- content=data["choices"][0]["message"]["content"],
1245
+ content=_extract_chat_content(data, "Vertex AI", model),
676
1246
  model=data["model"],
677
1247
  usage=data.get("usage", {}),
678
1248
  raw_response=data
679
1249
  )
680
1250
 
681
- _log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
1251
+ _log_model_call(
1252
+ payload, data, resp.usage, self.project_root,
1253
+ task_id=task_id, run_id=run_id, provider="vertexai", latency_ms=latency_ms,
1254
+ )
682
1255
  return resp
683
1256
 
684
1257
  class MockProvider(Provider):
@@ -689,13 +1262,14 @@ class MockProvider(Provider):
689
1262
  self._counts: Dict[str, int] = {}
690
1263
 
691
1264
  async def complete(
692
- self,
693
- model: str,
694
- messages: List[Dict[str, str]],
1265
+ self,
1266
+ model: str,
1267
+ messages: List[Dict[str, str]],
695
1268
  temperature: float = 0.0,
696
1269
  json_mode: bool = False,
697
1270
  task_id: Optional[str] = None,
698
1271
  run_id: Optional[str] = None,
1272
+ json_schema: Optional[Dict[str, Any]] = None, # accepted for interface parity; not used
699
1273
  ) -> LLMResponse:
700
1274
  res = self.responses.get(model, '{"mock": "response"}')
701
1275