devcouncil 0.2.0 → 0.2.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 +191 -253
- package/bin/devcouncil.js +130 -32
- package/package.json +6 -17
- package/pyproject.toml +0 -66
- package/src/devcouncil/__init__.py +0 -0
- package/src/devcouncil/__main__.py +0 -4
- package/src/devcouncil/app/__init__.py +0 -28
- package/src/devcouncil/app/config.py +0 -320
- package/src/devcouncil/app/errors.py +0 -23
- package/src/devcouncil/app/events.py +0 -44
- package/src/devcouncil/app/orchestrator.py +0 -92
- package/src/devcouncil/app/project_status.py +0 -29
- package/src/devcouncil/app/run_context.py +0 -39
- package/src/devcouncil/app/state_machine.py +0 -108
- package/src/devcouncil/artifacts/__init__.py +0 -1
- package/src/devcouncil/artifacts/coverage.py +0 -96
- package/src/devcouncil/artifacts/graph.py +0 -163
- 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/artifacts/validators.py +0 -27
- package/src/devcouncil/assets/__init__.py +0 -1
- package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/__init__.py +0 -0
- package/src/devcouncil/cli/commands/__init__.py +0 -0
- package/src/devcouncil/cli/commands/agents.py +0 -292
- package/src/devcouncil/cli/commands/artifacts.py +0 -54
- package/src/devcouncil/cli/commands/ast.py +0 -22
- package/src/devcouncil/cli/commands/baseline.py +0 -35
- package/src/devcouncil/cli/commands/check.py +0 -209
- package/src/devcouncil/cli/commands/config.py +0 -115
- package/src/devcouncil/cli/commands/cost.py +0 -57
- package/src/devcouncil/cli/commands/dashboard.py +0 -31
- package/src/devcouncil/cli/commands/doctor.py +0 -340
- package/src/devcouncil/cli/commands/evidence.py +0 -48
- package/src/devcouncil/cli/commands/go.py +0 -656
- package/src/devcouncil/cli/commands/handoff.py +0 -69
- package/src/devcouncil/cli/commands/hook.py +0 -237
- package/src/devcouncil/cli/commands/init.py +0 -289
- package/src/devcouncil/cli/commands/integrate.py +0 -1515
- package/src/devcouncil/cli/commands/lsp.py +0 -20
- package/src/devcouncil/cli/commands/map.py +0 -112
- package/src/devcouncil/cli/commands/mcp_server.py +0 -18
- package/src/devcouncil/cli/commands/plan.py +0 -488
- package/src/devcouncil/cli/commands/prompt.py +0 -61
- package/src/devcouncil/cli/commands/repair.py +0 -89
- package/src/devcouncil/cli/commands/report.py +0 -137
- package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
- package/src/devcouncil/cli/commands/rollback.py +0 -59
- package/src/devcouncil/cli/commands/run.py +0 -289
- package/src/devcouncil/cli/commands/runs.py +0 -223
- package/src/devcouncil/cli/commands/scaffold.py +0 -32
- package/src/devcouncil/cli/commands/semantic.py +0 -47
- package/src/devcouncil/cli/commands/setup.py +0 -362
- package/src/devcouncil/cli/commands/shell.py +0 -73
- package/src/devcouncil/cli/commands/show.py +0 -76
- package/src/devcouncil/cli/commands/skills.py +0 -88
- package/src/devcouncil/cli/commands/status.py +0 -141
- package/src/devcouncil/cli/commands/tasks.py +0 -55
- package/src/devcouncil/cli/commands/trace.py +0 -88
- package/src/devcouncil/cli/commands/verify.py +0 -328
- package/src/devcouncil/cli/commands/version.py +0 -20
- package/src/devcouncil/cli/commands/watch.py +0 -574
- package/src/devcouncil/cli/commands/watch_fs.py +0 -40
- package/src/devcouncil/cli/main.py +0 -137
- package/src/devcouncil/council/__init__.py +0 -0
- package/src/devcouncil/council/prompts/__init__.py +0 -0
- package/src/devcouncil/council/prompts/arbiter.md +0 -19
- package/src/devcouncil/council/prompts/critic_a.md +0 -10
- package/src/devcouncil/council/prompts/critic_b.md +0 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
- package/src/devcouncil/council/prompts/planner_a.md +0 -16
- package/src/devcouncil/council/prompts/planner_b.md +0 -16
- package/src/devcouncil/council/prompts/rebuttal.md +0 -10
- package/src/devcouncil/council/prompts/spec_writer.md +0 -12
- package/src/devcouncil/domain/__init__.py +0 -0
- package/src/devcouncil/domain/assumption.py +0 -17
- package/src/devcouncil/domain/critique.py +0 -32
- package/src/devcouncil/domain/evidence.py +0 -47
- package/src/devcouncil/domain/gap.py +0 -52
- package/src/devcouncil/domain/requirement.py +0 -22
- package/src/devcouncil/domain/task.py +0 -55
- package/src/devcouncil/execution/__init__.py +0 -1
- package/src/devcouncil/execution/checkpoints.py +0 -246
- package/src/devcouncil/execution/context_builder.py +0 -60
- package/src/devcouncil/execution/executor.py +0 -15
- package/src/devcouncil/execution/fs_watcher.py +0 -180
- package/src/devcouncil/execution/handoff.py +0 -102
- package/src/devcouncil/execution/hook_policy.py +0 -253
- package/src/devcouncil/execution/patch.py +0 -77
- package/src/devcouncil/execution/paths.py +0 -14
- package/src/devcouncil/execution/permissions.py +0 -85
- package/src/devcouncil/execution/policy_engine.py +0 -343
- package/src/devcouncil/execution/prompt_builder.py +0 -671
- package/src/devcouncil/execution/shell_session.py +0 -225
- package/src/devcouncil/execution/task_runner.py +0 -170
- package/src/devcouncil/executors/__init__.py +0 -1
- package/src/devcouncil/executors/agent_registry.py +0 -575
- package/src/devcouncil/executors/coding_cli.py +0 -736
- package/src/devcouncil/executors/mini_swe.py +0 -73
- package/src/devcouncil/executors/native/__init__.py +0 -0
- package/src/devcouncil/executors/native/agent.py +0 -208
- package/src/devcouncil/executors/openhands.py +0 -71
- package/src/devcouncil/gating/__init__.py +0 -1
- package/src/devcouncil/gating/checks/__init__.py +0 -0
- package/src/devcouncil/gating/checks/clean_git.py +0 -52
- package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
- package/src/devcouncil/gating/policy.py +0 -338
- package/src/devcouncil/hardware.py +0 -184
- package/src/devcouncil/indexing/__init__.py +0 -1
- package/src/devcouncil/indexing/ast_matcher.py +0 -168
- package/src/devcouncil/indexing/graph_index.py +0 -48
- package/src/devcouncil/indexing/lsp.py +0 -161
- package/src/devcouncil/indexing/repo_mapper.py +0 -1455
- package/src/devcouncil/indexing/semantic_index.py +0 -205
- package/src/devcouncil/integrations/actions.py +0 -146
- package/src/devcouncil/integrations/check.py +0 -423
- package/src/devcouncil/integrations/code_review_graph.py +0 -163
- package/src/devcouncil/integrations/github.py +0 -39
- package/src/devcouncil/integrations/github_intent.py +0 -142
- package/src/devcouncil/integrations/gitnexus.py +0 -62
- package/src/devcouncil/integrations/graphify.py +0 -34
- package/src/devcouncil/integrations/mcp/__init__.py +0 -0
- package/src/devcouncil/integrations/mcp/server.py +0 -2122
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
- package/src/devcouncil/integrations/pr_comments.py +0 -62
- package/src/devcouncil/live/__init__.py +0 -2
- package/src/devcouncil/live/cards.py +0 -349
- package/src/devcouncil/live/models.py +0 -63
- package/src/devcouncil/live/repair_prompt.py +0 -83
- package/src/devcouncil/live/reviewer.py +0 -70
- package/src/devcouncil/live/signals.py +0 -135
- package/src/devcouncil/live/summary.py +0 -34
- package/src/devcouncil/live/tasks.py +0 -18
- package/src/devcouncil/live/transcripts.py +0 -141
- package/src/devcouncil/llm/__init__.py +0 -1
- package/src/devcouncil/llm/cache.py +0 -42
- package/src/devcouncil/llm/model_defaults.yaml +0 -44
- package/src/devcouncil/llm/provider.py +0 -627
- package/src/devcouncil/llm/router.py +0 -310
- package/src/devcouncil/optimization/__init__.py +0 -1
- package/src/devcouncil/optimization/gepa_agent.py +0 -318
- package/src/devcouncil/planning/__init__.py +0 -1
- package/src/devcouncil/planning/arbiter_service.py +0 -57
- package/src/devcouncil/planning/correction_manifest.py +0 -303
- package/src/devcouncil/planning/critique_service.py +0 -71
- package/src/devcouncil/planning/plan_service.py +0 -60
- package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
- package/src/devcouncil/planning/repair_service.py +0 -39
- package/src/devcouncil/planning/spec_service.py +0 -70
- package/src/devcouncil/repo/__init__.py +0 -0
- package/src/devcouncil/repo/ci_scaffold.py +0 -157
- package/src/devcouncil/repo/gitignore.py +0 -123
- package/src/devcouncil/repo/sca.py +0 -374
- package/src/devcouncil/reporting/__init__.py +0 -0
- package/src/devcouncil/reporting/github_check.py +0 -32
- package/src/devcouncil/reporting/json_report.py +0 -30
- package/src/devcouncil/reporting/markdown_report.py +0 -83
- package/src/devcouncil/reporting/report_builder.py +0 -14
- package/src/devcouncil/skills/__init__.py +0 -19
- package/src/devcouncil/skills/library/README.md +0 -46
- package/src/devcouncil/skills/library/ai-training.md +0 -50
- package/src/devcouncil/skills/library/android.md +0 -50
- package/src/devcouncil/skills/library/backend.md +0 -52
- package/src/devcouncil/skills/library/core-engineering.md +0 -95
- package/src/devcouncil/skills/library/data-engineering.md +0 -47
- package/src/devcouncil/skills/library/desktop.md +0 -46
- package/src/devcouncil/skills/library/devops.md +0 -48
- package/src/devcouncil/skills/library/game-dev.md +0 -46
- package/src/devcouncil/skills/library/ios.md +0 -48
- package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
- package/src/devcouncil/skills/library/security.md +0 -48
- package/src/devcouncil/skills/library/systems.md +0 -48
- package/src/devcouncil/skills/library/web.md +0 -47
- package/src/devcouncil/skills/library/windows.md +0 -47
- package/src/devcouncil/skills/registry.py +0 -330
- package/src/devcouncil/storage/__init__.py +0 -0
- package/src/devcouncil/storage/db.py +0 -147
- package/src/devcouncil/storage/models.py +0 -204
- package/src/devcouncil/storage/native.py +0 -557
- package/src/devcouncil/storage/repositories.py +0 -485
- package/src/devcouncil/telemetry/__init__.py +0 -0
- package/src/devcouncil/telemetry/cost.py +0 -140
- package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
- package/src/devcouncil/telemetry/pricing.py +0 -28
- package/src/devcouncil/telemetry/traces.py +0 -146
- package/src/devcouncil/telemetry/tracker.py +0 -52
- package/src/devcouncil/ui/__init__.py +0 -1
- package/src/devcouncil/ui/dashboard.py +0 -423
- package/src/devcouncil/utils/__init__.py +0 -1
- package/src/devcouncil/utils/redaction.py +0 -147
- package/src/devcouncil/utils/subprocess_env.py +0 -69
- package/src/devcouncil/verification/__init__.py +0 -1
- package/src/devcouncil/verification/acceptance_compiler.py +0 -125
- package/src/devcouncil/verification/ad_hoc_check.py +0 -129
- package/src/devcouncil/verification/diff_coverage.py +0 -353
- package/src/devcouncil/verification/implementation_reviewer.py +0 -55
- package/src/devcouncil/verification/next_actions.py +0 -189
- package/src/devcouncil/verification/sandbox.py +0 -178
- package/src/devcouncil/verification/test_resolver.py +0 -91
- package/src/devcouncil/verification/verifier.py +0 -1548
- 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
|
-
)
|