devcouncil 0.2.0 → 0.2.2

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