devcouncil 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,34 +1,123 @@
1
1
  from abc import ABC, abstractmethod
2
2
  import copy
3
+ from functools import lru_cache
4
+ from importlib import resources
5
+ import os
3
6
  from typing import List, Dict, Any, Optional
4
- from pydantic import BaseModel
7
+ from pydantic import BaseModel, field_validator
5
8
  import httpx
6
9
  import json
7
10
  from pathlib import Path
11
+ import yaml
8
12
 
9
- SUPPORTED_MODEL_PROVIDERS = ("openrouter",)
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)
10
70
 
11
71
 
12
72
  class LLMResponse(BaseModel):
13
73
  content: str
14
74
  model: str
15
- usage: Dict[str, int]
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]
16
79
  raw_response: Dict[str, Any]
17
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
+
18
89
  class Provider(ABC):
19
90
  @abstractmethod
20
91
  async def complete(
21
- self,
22
- model: str,
23
- messages: List[Dict[str, str]],
92
+ self,
93
+ model: str,
94
+ messages: List[Dict[str, str]],
24
95
  temperature: float = 0.0,
25
- json_mode: bool = False
96
+ json_mode: bool = False,
97
+ task_id: Optional[str] = None,
98
+ run_id: Optional[str] = None,
26
99
  ) -> LLMResponse:
27
100
  pass
28
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
+
29
117
 
30
118
  def validate_model_provider(provider_name: str) -> str:
31
119
  normalized = provider_name.strip().lower()
120
+ normalized = PROVIDER_ALIASES.get(normalized, normalized)
32
121
  if normalized in SUPPORTED_MODEL_PROVIDERS:
33
122
  return normalized
34
123
  supported = ", ".join(SUPPORTED_MODEL_PROVIDERS)
@@ -38,23 +127,131 @@ def validate_model_provider(provider_name: str) -> str:
38
127
  )
39
128
 
40
129
 
41
- def create_provider(provider_name: str, api_key: str) -> Provider:
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:
42
181
  normalized = validate_model_provider(provider_name)
43
182
  if normalized == "openrouter":
44
- return OpenRouterProvider(api_key)
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)
45
199
  raise AssertionError(f"Provider validation passed for unhandled provider: {normalized}")
46
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
+
47
241
  class OpenRouterProvider(Provider):
48
- def __init__(self, api_key: str):
242
+ def __init__(self, api_key: str, project_root: Path = Path(".")):
49
243
  self.api_key = api_key
50
244
  self.base_url = "https://openrouter.ai/api/v1"
245
+ self.project_root = project_root
51
246
 
52
247
  async def complete(
53
248
  self,
54
249
  model: str,
55
250
  messages: List[Dict[str, str]],
56
251
  temperature: float = 0.0,
57
- json_mode: bool = False
252
+ json_mode: bool = False,
253
+ task_id: Optional[str] = None,
254
+ run_id: Optional[str] = None,
58
255
  ) -> LLMResponse:
59
256
  # Deep-copy to avoid mutating the caller's messages list
60
257
  msgs = copy.deepcopy(messages)
@@ -84,35 +281,317 @@ class OpenRouterProvider(Provider):
84
281
  headers=headers,
85
282
  json=payload
86
283
  )
87
- response.raise_for_status()
284
+ raise_for_provider_status(response, "OpenRouter")
88
285
  data = response.json()
89
-
286
+
90
287
  resp = LLMResponse(
91
288
  content=data["choices"][0]["message"]["content"],
92
289
  model=data["model"],
93
290
  usage=data.get("usage", {}),
94
291
  raw_response=data
95
292
  )
96
-
97
- # Log the call
98
- try:
99
- from devcouncil.utils.redaction import redact_dict
100
- log_dir = Path(".devcouncil/logs")
101
- log_dir.mkdir(parents=True, exist_ok=True)
102
- log_file = log_dir / "model_calls.jsonl"
103
-
104
- # Create a redacted copy of both request and response for logging
105
- log_payload = {
106
- "request": redact_dict(payload),
107
- "response": redact_dict(data),
108
- "usage": resp.usage,
109
- }
110
- with open(log_file, "a", encoding="utf-8") as f:
111
- f.write(json.dumps(log_payload) + "\n")
112
- except Exception as e:
113
- import logging as _log
114
- _log.getLogger(__name__).debug("Failed to log model call: %s", e)
115
-
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)
116
595
  return resp
117
596
 
118
597
  class MockProvider(Provider):
@@ -127,7 +606,9 @@ class MockProvider(Provider):
127
606
  model: str,
128
607
  messages: List[Dict[str, str]],
129
608
  temperature: float = 0.0,
130
- json_mode: bool = False
609
+ json_mode: bool = False,
610
+ task_id: Optional[str] = None,
611
+ run_id: Optional[str] = None,
131
612
  ) -> LLMResponse:
132
613
  res = self.responses.get(model, '{"mock": "response"}')
133
614