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.
- package/README.md +92 -33
- package/package.json +6 -2
- package/packages/codeintel-grammars/hatch_build.py +43 -0
- package/packages/codeintel-grammars/pyproject.toml +16 -0
- package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
- package/pyproject.toml +99 -4
- package/src/devcouncil/app/config.py +512 -20
- package/src/devcouncil/app/events.py +4 -23
- package/src/devcouncil/app/orchestrator.py +5 -0
- package/src/devcouncil/app/run_context.py +3 -3
- package/src/devcouncil/assets/__init__.py +4 -1
- package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
- package/src/devcouncil/campaign/__init__.py +71 -0
- package/src/devcouncil/campaign/bloom.py +137 -0
- package/src/devcouncil/campaign/dashboard.py +123 -0
- package/src/devcouncil/campaign/mailbox.py +305 -0
- package/src/devcouncil/campaign/notify.py +91 -0
- package/src/devcouncil/campaign/orchestrator.py +592 -0
- package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
- package/src/devcouncil/campaign/prompts/director.md +21 -0
- package/src/devcouncil/campaign/prompts/protocol.md +46 -0
- package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
- package/src/devcouncil/campaign/prompts/worker.md +24 -0
- package/src/devcouncil/campaign/roles.py +202 -0
- package/src/devcouncil/campaign/watcher.py +153 -0
- package/src/devcouncil/cli/commands/agents.py +24 -17
- package/src/devcouncil/cli/commands/artifacts.py +36 -27
- package/src/devcouncil/cli/commands/ast.py +12 -3
- package/src/devcouncil/cli/commands/baseline.py +21 -12
- package/src/devcouncil/cli/commands/boot.py +218 -0
- package/src/devcouncil/cli/commands/campaign.py +302 -0
- package/src/devcouncil/cli/commands/check.py +225 -12
- package/src/devcouncil/cli/commands/config.py +221 -74
- package/src/devcouncil/cli/commands/cost.py +137 -28
- package/src/devcouncil/cli/commands/dashboard.py +12 -4
- package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
- package/src/devcouncil/cli/commands/design.py +27 -17
- package/src/devcouncil/cli/commands/doctor.py +790 -8
- package/src/devcouncil/cli/commands/evidence.py +41 -20
- package/src/devcouncil/cli/commands/export.py +73 -0
- package/src/devcouncil/cli/commands/gaps.py +175 -0
- package/src/devcouncil/cli/commands/gated_write.py +76 -0
- package/src/devcouncil/cli/commands/go.py +220 -68
- package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
- package/src/devcouncil/cli/commands/handoff.py +45 -34
- package/src/devcouncil/cli/commands/hook.py +630 -85
- package/src/devcouncil/cli/commands/init.py +89 -30
- package/src/devcouncil/cli/commands/integrate.py +296 -1385
- package/src/devcouncil/cli/commands/lease.py +120 -0
- package/src/devcouncil/cli/commands/logs.py +12 -5
- package/src/devcouncil/cli/commands/lsp.py +40 -5
- package/src/devcouncil/cli/commands/map.py +317 -74
- package/src/devcouncil/cli/commands/mcp_server.py +12 -2
- package/src/devcouncil/cli/commands/okf.py +44 -6
- package/src/devcouncil/cli/commands/plan.py +184 -69
- package/src/devcouncil/cli/commands/prompt.py +26 -17
- package/src/devcouncil/cli/commands/provenance.py +79 -0
- package/src/devcouncil/cli/commands/repair.py +60 -49
- package/src/devcouncil/cli/commands/report.py +148 -40
- package/src/devcouncil/cli/commands/requirements.py +104 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
- package/src/devcouncil/cli/commands/rollback.py +46 -35
- package/src/devcouncil/cli/commands/run.py +173 -8
- package/src/devcouncil/cli/commands/runs.py +298 -68
- package/src/devcouncil/cli/commands/scaffold.py +33 -12
- package/src/devcouncil/cli/commands/semantic.py +29 -14
- package/src/devcouncil/cli/commands/setup.py +103 -93
- package/src/devcouncil/cli/commands/shell.py +51 -42
- package/src/devcouncil/cli/commands/show.py +56 -42
- package/src/devcouncil/cli/commands/skills.py +29 -20
- package/src/devcouncil/cli/commands/status.py +80 -67
- package/src/devcouncil/cli/commands/task_gate.py +295 -0
- package/src/devcouncil/cli/commands/tasks.py +248 -19
- package/src/devcouncil/cli/commands/trace.py +14 -8
- package/src/devcouncil/cli/commands/verify.py +33 -8
- package/src/devcouncil/cli/commands/version.py +14 -6
- package/src/devcouncil/cli/commands/watch.py +56 -40
- package/src/devcouncil/cli/commands/watch_fs.py +30 -19
- package/src/devcouncil/cli/commands/wiki.py +278 -0
- package/src/devcouncil/cli/main.py +58 -1
- package/src/devcouncil/codeintel/__init__.py +16 -0
- package/src/devcouncil/codeintel/build_control.py +429 -0
- package/src/devcouncil/codeintel/build_worker.py +78 -0
- package/src/devcouncil/codeintel/debug/__init__.py +17 -0
- package/src/devcouncil/codeintel/debug/broker.py +114 -0
- package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
- package/src/devcouncil/codeintel/debug/consent.py +36 -0
- package/src/devcouncil/codeintel/debug/discovery.py +132 -0
- package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
- package/src/devcouncil/codeintel/debug/protocol.py +259 -0
- package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
- package/src/devcouncil/codeintel/debug/session.py +238 -0
- package/src/devcouncil/codeintel/debug/tracing.py +201 -0
- package/src/devcouncil/codeintel/languages/__init__.py +17 -0
- package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
- package/src/devcouncil/codeintel/languages/registry.py +149 -0
- package/src/devcouncil/codeintel/languages/workers.py +245 -0
- package/src/devcouncil/codeintel/query/__init__.py +5 -0
- package/src/devcouncil/codeintel/query/engine.py +289 -0
- package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
- package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
- package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
- package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
- package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
- package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
- package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
- package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
- package/src/devcouncil/codeintel/service.py +104 -0
- package/src/devcouncil/codeintel/store/__init__.py +15 -0
- package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
- package/src/devcouncil/codeintel/sync/__init__.py +19 -0
- package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
- package/src/devcouncil/codeintel/sync/incremental.py +484 -0
- package/src/devcouncil/codeintel/sync/lease.py +96 -0
- package/src/devcouncil/codeintel/sync/scope.py +98 -0
- package/src/devcouncil/council/__init__.py +4 -0
- package/src/devcouncil/council/prompts/__init__.py +4 -0
- package/src/devcouncil/domain/checkpoint_refs.py +17 -0
- package/src/devcouncil/domain/evidence.py +1 -0
- package/src/devcouncil/domain/gap.py +10 -0
- package/src/devcouncil/domain/requirement.py +5 -1
- package/src/devcouncil/domain/task.py +43 -2
- package/src/devcouncil/execution/checkpoints.py +25 -31
- package/src/devcouncil/execution/context_builder.py +15 -44
- package/src/devcouncil/execution/fs_watcher.py +64 -0
- package/src/devcouncil/execution/gated_write.py +203 -0
- package/src/devcouncil/execution/handoff.py +2 -1
- package/src/devcouncil/execution/hook_policy.py +19 -5
- package/src/devcouncil/execution/lease_ops.py +177 -0
- package/src/devcouncil/execution/lease_validation.py +71 -0
- package/src/devcouncil/execution/patch.py +3 -0
- package/src/devcouncil/execution/permissions.py +1 -0
- package/src/devcouncil/execution/policy_engine.py +205 -10
- package/src/devcouncil/execution/prompt_builder.py +278 -33
- package/src/devcouncil/execution/run_trace.py +356 -0
- package/src/devcouncil/execution/shell_session.py +46 -5
- package/src/devcouncil/execution/stop_gate.py +746 -0
- package/src/devcouncil/execution/stop_gate_history.py +113 -0
- package/src/devcouncil/execution/stop_gate_state.py +54 -0
- package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
- package/src/devcouncil/execution/task_gate_ops.py +590 -0
- package/src/devcouncil/execution/task_runner.py +19 -0
- package/src/devcouncil/executors/advisor_tool.py +315 -0
- package/src/devcouncil/executors/agent_registry.py +125 -17
- package/src/devcouncil/executors/claude_sdk.py +376 -0
- package/src/devcouncil/executors/coding_cli.py +724 -25
- package/src/devcouncil/executors/mini_swe.py +50 -8
- package/src/devcouncil/executors/native/agent.py +224 -19
- package/src/devcouncil/executors/openhands.py +50 -8
- package/src/devcouncil/executors/transient_retry.py +99 -0
- package/src/devcouncil/gating/checks/clean_git.py +5 -2
- package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
- package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
- package/src/devcouncil/gating/policy.py +46 -2
- package/src/devcouncil/indexing/ast_matcher.py +41 -4
- package/src/devcouncil/indexing/graph/__init__.py +78 -0
- package/src/devcouncil/indexing/graph/api_routes.py +522 -0
- package/src/devcouncil/indexing/graph/build.py +862 -0
- package/src/devcouncil/indexing/graph/cache.py +329 -0
- package/src/devcouncil/indexing/graph/communities.py +28 -0
- package/src/devcouncil/indexing/graph/cypher.py +107 -0
- package/src/devcouncil/indexing/graph/embeddings.py +194 -0
- package/src/devcouncil/indexing/graph/export.py +381 -0
- package/src/devcouncil/indexing/graph/export_links.py +81 -0
- package/src/devcouncil/indexing/graph/extract_python.py +307 -0
- package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
- package/src/devcouncil/indexing/graph/intel.py +668 -0
- package/src/devcouncil/indexing/graph/liveness.py +992 -0
- package/src/devcouncil/indexing/graph/okf_export.py +65 -0
- package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
- package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
- package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
- package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
- package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
- package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
- package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
- package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
- package/src/devcouncil/indexing/graph/query.py +302 -0
- package/src/devcouncil/indexing/graph/resolve.py +1020 -0
- package/src/devcouncil/indexing/graph/schema.py +103 -0
- package/src/devcouncil/indexing/graph_index.py +20 -29
- package/src/devcouncil/indexing/lsp.py +57 -25
- package/src/devcouncil/indexing/lsp_client.py +577 -0
- package/src/devcouncil/indexing/map_artifacts.py +355 -0
- package/src/devcouncil/indexing/map_refresh.py +141 -0
- package/src/devcouncil/indexing/repo_mapper.py +1509 -138
- package/src/devcouncil/indexing/semantic_index.py +12 -6
- package/src/devcouncil/indexing/subsystem_map.py +163 -0
- package/src/devcouncil/indexing/ts_imports.py +343 -0
- package/src/devcouncil/indexing/viz.py +964 -0
- package/src/devcouncil/indexing/walk.py +52 -0
- package/src/devcouncil/indexing/wiring.py +1776 -0
- package/src/devcouncil/integrations/actions.py +27 -4
- package/src/devcouncil/integrations/check.py +211 -16
- package/src/devcouncil/integrations/claude_assets.py +209 -12
- package/src/devcouncil/integrations/clients/__init__.py +1 -0
- package/src/devcouncil/integrations/clients/aider.py +52 -0
- package/src/devcouncil/integrations/clients/antigravity.py +87 -0
- package/src/devcouncil/integrations/clients/claude.py +339 -0
- package/src/devcouncil/integrations/clients/codex.py +39 -0
- package/src/devcouncil/integrations/clients/common.py +332 -0
- package/src/devcouncil/integrations/clients/cursor.py +164 -0
- package/src/devcouncil/integrations/clients/gemini.py +49 -0
- package/src/devcouncil/integrations/clients/grok.py +105 -0
- package/src/devcouncil/integrations/clients/hooks.py +500 -0
- package/src/devcouncil/integrations/clients/opencode.py +96 -0
- package/src/devcouncil/integrations/clients/warp.py +75 -0
- package/src/devcouncil/integrations/code_review_graph.py +2 -2
- package/src/devcouncil/integrations/github.py +73 -7
- package/src/devcouncil/integrations/integration_cli.py +197 -0
- package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
- package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
- package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
- package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
- package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
- package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
- package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/git.py +281 -0
- package/src/devcouncil/integrations/mcp/handlers/graph.py +34 -0
- package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +28 -0
- package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
- package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
- package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
- package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
- package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
- package/src/devcouncil/integrations/mcp/handlers/provenance.py +87 -0
- package/src/devcouncil/integrations/mcp/handlers/read.py +103 -0
- package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/runs.py +69 -0
- package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
- package/src/devcouncil/integrations/mcp/handlers/status.py +199 -0
- package/src/devcouncil/integrations/mcp/handlers/task.py +88 -0
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +922 -0
- package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
- package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
- package/src/devcouncil/integrations/mcp/handlers/wiki.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
- package/src/devcouncil/integrations/mcp/server.py +265 -2391
- package/src/devcouncil/integrations/mcp/util.py +325 -0
- package/src/devcouncil/integrations/setup.py +152 -0
- package/src/devcouncil/knowledge/fetch.py +4 -0
- package/src/devcouncil/knowledge/knowledge_select.py +38 -0
- package/src/devcouncil/knowledge/okf.py +2 -1
- package/src/devcouncil/knowledge/resource_discovery.py +40 -0
- package/src/devcouncil/knowledge/wiki.py +643 -0
- package/src/devcouncil/knowledge/wiki_read.py +87 -0
- package/src/devcouncil/live/cards.py +7 -7
- package/src/devcouncil/live/models.py +4 -1
- package/src/devcouncil/live/reviewer.py +90 -11
- package/src/devcouncil/live/signals.py +4 -2
- package/src/devcouncil/live/summary.py +21 -3
- package/src/devcouncil/live/tasks.py +12 -3
- package/src/devcouncil/live/transcripts.py +69 -2
- package/src/devcouncil/llm/cache.py +5 -6
- package/src/devcouncil/llm/model_defaults.yaml +10 -10
- package/src/devcouncil/llm/provider.py +647 -73
- package/src/devcouncil/llm/router.py +271 -46
- package/src/devcouncil/llm/semantic_bridge.py +614 -0
- package/src/devcouncil/optimization/gepa_agent.py +6 -4
- package/src/devcouncil/optimization/skillopt.py +9 -5
- package/src/devcouncil/planning/arbiter_service.py +12 -3
- package/src/devcouncil/planning/correction_manifest.py +107 -10
- package/src/devcouncil/planning/plan_difficulty.py +69 -0
- package/src/devcouncil/planning/plan_service.py +5 -2
- package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
- package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
- package/src/devcouncil/planning/question_conversion.py +56 -0
- package/src/devcouncil/planning/spec_service.py +9 -3
- package/src/devcouncil/repo/ci_scaffold.py +197 -1
- package/src/devcouncil/repo/gitignore.py +1 -2
- package/src/devcouncil/reporting/evidence_export.py +124 -0
- package/src/devcouncil/reporting/evidence_html.py +210 -0
- package/src/devcouncil/reporting/json_report.py +16 -12
- package/src/devcouncil/reporting/markdown_report.py +38 -9
- package/src/devcouncil/reporting/mcp_resources.py +142 -0
- package/src/devcouncil/reporting/report_builder.py +40 -4
- package/src/devcouncil/reporting/task_provenance.py +42 -0
- package/src/devcouncil/reporting/verdict.py +75 -0
- package/src/devcouncil/skills/library/README.md +1 -0
- package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
- package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
- package/src/devcouncil/skills/library/devcouncil.md +93 -0
- package/src/devcouncil/skills/registry.py +43 -12
- package/src/devcouncil/storage/db.py +57 -11
- package/src/devcouncil/storage/models.py +6 -0
- package/src/devcouncil/storage/native.py +5 -3
- package/src/devcouncil/storage/repositories.py +50 -18
- package/src/devcouncil/telemetry/context.py +28 -0
- package/src/devcouncil/telemetry/cost.py +4 -5
- package/src/devcouncil/telemetry/logging_setup.py +78 -11
- package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
- package/src/devcouncil/telemetry/stages.py +27 -2
- package/src/devcouncil/telemetry/tracker.py +50 -13
- package/src/devcouncil/ui/dashboard.py +120 -8
- package/src/devcouncil/utils/fsio.py +58 -0
- package/src/devcouncil/utils/git_snapshot.py +112 -0
- package/src/devcouncil/utils/json_persist.py +53 -0
- package/src/devcouncil/utils/proc.py +89 -0
- package/src/devcouncil/verification/acceptance_compiler.py +36 -13
- package/src/devcouncil/verification/ad_hoc_check.py +95 -3
- package/src/devcouncil/verification/checks/__init__.py +41 -0
- package/src/devcouncil/verification/checks/acceptance.py +39 -0
- package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
- package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
- package/src/devcouncil/verification/checks/command_evidence.py +148 -0
- package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
- package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
- package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
- package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
- package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
- package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
- package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
- package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
- package/src/devcouncil/verification/checks/planned_files.py +98 -0
- package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
- package/src/devcouncil/verification/checks/stale_map.py +80 -0
- package/src/devcouncil/verification/checks/stub_scan.py +71 -0
- package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
- package/src/devcouncil/verification/checks/wiring.py +216 -0
- package/src/devcouncil/verification/claims/__init__.py +23 -0
- package/src/devcouncil/verification/claims/checks.py +395 -0
- package/src/devcouncil/verification/claims/mapper.py +168 -0
- package/src/devcouncil/verification/claims/models.py +39 -0
- package/src/devcouncil/verification/claims/transcript.py +92 -0
- package/src/devcouncil/verification/claims/verdict.py +88 -0
- package/src/devcouncil/verification/command_evidence.py +170 -0
- package/src/devcouncil/verification/command_malformation.py +147 -0
- package/src/devcouncil/verification/command_runner.py +164 -0
- package/src/devcouncil/verification/coverage_measurement.py +292 -0
- package/src/devcouncil/verification/diff_coverage.py +151 -0
- package/src/devcouncil/verification/difficulty.py +296 -0
- package/src/devcouncil/verification/effort_heuristics.py +178 -0
- package/src/devcouncil/verification/gap_ids.py +63 -0
- package/src/devcouncil/verification/gate_cache.py +194 -0
- package/src/devcouncil/verification/gate_selector.py +344 -0
- package/src/devcouncil/verification/git_diff_fallback.py +272 -0
- package/src/devcouncil/verification/implementation_reviewer.py +13 -0
- package/src/devcouncil/verification/incremental_check.py +241 -0
- package/src/devcouncil/verification/next_actions.py +60 -1
- package/src/devcouncil/verification/rigor_analytics.py +130 -0
- package/src/devcouncil/verification/sandbox.py +38 -11
- package/src/devcouncil/verification/stub_detector.py +369 -0
- package/src/devcouncil/verification/test_resolver.py +67 -1
- package/src/devcouncil/verification/verifier.py +137 -1666
- package/src/devcouncil/verification/verify_orchestration.py +610 -0
- package/src/devcouncil/verification/verify_setup.py +176 -0
- package/src/devcouncil/verification/wiki_refresh.py +208 -0
- package/src/semantic_layer/__init__.py +58 -0
- package/src/semantic_layer/benchmark.py +75 -0
- package/src/semantic_layer/cache.py +290 -0
- package/src/semantic_layer/compressor.py +137 -0
- package/src/semantic_layer/config.py +75 -0
- package/src/semantic_layer/embeddings.py +69 -0
- package/src/semantic_layer/llm_backends.py +99 -0
- package/src/semantic_layer/pipeline.py +111 -0
- package/src/semantic_layer/router.py +128 -0
- package/src/semantic_layer/tuner.py +72 -0
- package/uv.lock +973 -9
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/integrations/gitnexus.py +0 -70
- 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__(
|
|
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(
|
|
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
|
-
|
|
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-
|
|
288
|
-
# records simply lack them and are grouped under
|
|
289
|
-
# reporter. provider lets the cost ledger
|
|
290
|
-
# regardless of the open-ended model tag
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
696
|
+
latency_ms = int((_time.monotonic() - started) * 1000)
|
|
697
|
+
data = _parse_provider_json(response, "Doubleword")
|
|
421
698
|
|
|
422
699
|
resp = LLMResponse(
|
|
423
|
-
content=data
|
|
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(
|
|
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
|
|
484
|
-
# larger window avoids the truncation a smaller
|
|
485
|
-
#
|
|
486
|
-
#
|
|
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
|
|
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)
|
|
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
|
|
992
|
+
return OllamaProvider.DEFAULT_NUM_CTX
|
|
515
993
|
try:
|
|
516
994
|
value = int(raw)
|
|
517
995
|
except (TypeError, ValueError):
|
|
518
|
-
return
|
|
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";
|
|
549
|
-
#
|
|
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
|
-
|
|
552
|
-
|
|
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
|
|
563
|
-
#
|
|
564
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|