devcouncil 0.1.0 → 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.
- package/LICENSE +201 -201
- package/README.md +197 -494
- package/package.json +9 -2
- package/pyproject.toml +62 -27
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +297 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +163 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +54 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +115 -54
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +31 -0
- package/src/devcouncil/cli/commands/doctor.py +291 -47
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +656 -0
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +209 -33
- package/src/devcouncil/cli/commands/init.py +204 -57
- package/src/devcouncil/cli/commands/integrate.py +1171 -76
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +96 -22
- package/src/devcouncil/cli/commands/plan.py +422 -210
- package/src/devcouncil/cli/commands/prompt.py +48 -34
- package/src/devcouncil/cli/commands/repair.py +89 -69
- package/src/devcouncil/cli/commands/report.py +120 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
- package/src/devcouncil/cli/commands/rollback.py +55 -54
- package/src/devcouncil/cli/commands/run.py +285 -220
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +300 -20
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +141 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +49 -4
- package/src/devcouncil/cli/commands/verify.py +293 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +92 -25
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +47 -27
- package/src/devcouncil/domain/gap.py +52 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +55 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +186 -77
- package/src/devcouncil/execution/patch.py +77 -28
- package/src/devcouncil/execution/permissions.py +52 -59
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +650 -38
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +68 -64
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +736 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +186 -85
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +52 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
- package/src/devcouncil/gating/policy.py +315 -167
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +161 -0
- package/src/devcouncil/indexing/repo_mapper.py +1455 -204
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +62 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +2072 -96
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +349 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +141 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +42 -38
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +627 -125
- package/src/devcouncil/llm/router.py +303 -118
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +71 -66
- package/src/devcouncil/planning/plan_service.py +60 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +70 -44
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +30 -17
- package/src/devcouncil/reporting/markdown_report.py +83 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +147 -66
- package/src/devcouncil/storage/models.py +204 -83
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +388 -249
- package/src/devcouncil/telemetry/cost.py +140 -34
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +52 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +423 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +147 -141
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1342 -307
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -1,34 +1,140 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
from devcouncil.telemetry.pricing import pricing_for_model
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
UNATTRIBUTED = "(unattributed)"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CostEstimator:
|
|
14
|
+
"""Estimates LLM usage cost based on provider pricing."""
|
|
15
|
+
|
|
16
|
+
# Conservative default for unknown models
|
|
17
|
+
DEFAULT_PRICING = {"prompt_per_1k": 0.005, "completion_per_1k": 0.015}
|
|
18
|
+
|
|
19
|
+
# Local providers run on-device and incur no per-token cost. Ollama model ids
|
|
20
|
+
# are open-ended (e.g. ``qwen2.5-coder:7b`` or an ``ollama/<name>`` form), so
|
|
21
|
+
# match the conventional prefixes rather than relying on the open-ended yaml.
|
|
22
|
+
LOCAL_MODEL_PREFIXES = ("ollama/", "ollama:")
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def _is_local_model(cls, model: str) -> bool:
|
|
26
|
+
return model.startswith(cls.LOCAL_MODEL_PREFIXES)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def estimate_cost(cls, model: str, usage: Dict[str, int]) -> float:
|
|
30
|
+
# Local/Ollama models are free regardless of yaml coverage; short-circuit
|
|
31
|
+
# before the conservative DEFAULT_PRICING fallback would bill them.
|
|
32
|
+
if cls._is_local_model(model):
|
|
33
|
+
return 0.0
|
|
34
|
+
prices = pricing_for_model(model, cls.DEFAULT_PRICING)
|
|
35
|
+
if prices == cls.DEFAULT_PRICING:
|
|
36
|
+
logger.debug("Unknown model for cost estimation: %s — using default pricing", model)
|
|
37
|
+
|
|
38
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
39
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
40
|
+
|
|
41
|
+
cost = ((prompt_tokens / 1000.0) * prices["prompt_per_1k"]) + (
|
|
42
|
+
(completion_tokens / 1000.0) * prices["completion_per_1k"]
|
|
43
|
+
)
|
|
44
|
+
return cost
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _model_calls_file(project_root: Path) -> Path:
|
|
48
|
+
return project_root / ".devcouncil" / "logs" / "model_calls.jsonl"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_cost_records(project_root: Path) -> List[Dict[str, Any]]:
|
|
52
|
+
"""Read the model-call ledger and attach an estimated cost to each record.
|
|
53
|
+
|
|
54
|
+
Never raises: malformed lines are skipped. Records missing ``task_id`` /
|
|
55
|
+
``run_id`` (older entries written before per-task attribution) keep those as
|
|
56
|
+
``None`` so callers can bucket them under ``(unattributed)``.
|
|
57
|
+
"""
|
|
58
|
+
log_file = _model_calls_file(project_root)
|
|
59
|
+
records: List[Dict[str, Any]] = []
|
|
60
|
+
if not log_file.exists():
|
|
61
|
+
return records
|
|
62
|
+
try:
|
|
63
|
+
lines = log_file.read_text(encoding="utf-8").splitlines()
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
logger.debug("Failed to read model_calls ledger: %s", exc)
|
|
66
|
+
return records
|
|
67
|
+
|
|
68
|
+
for line in lines:
|
|
69
|
+
if not line.strip():
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
entry = json.loads(line)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
logger.debug("Skipping invalid model_calls line: %s", exc)
|
|
75
|
+
continue
|
|
76
|
+
model = ""
|
|
77
|
+
response = entry.get("response")
|
|
78
|
+
if isinstance(response, dict):
|
|
79
|
+
model = str(response.get("model", "") or "")
|
|
80
|
+
raw_usage = entry.get("usage")
|
|
81
|
+
usage: Dict[str, Any] = raw_usage if isinstance(raw_usage, dict) else {}
|
|
82
|
+
# Local providers (ollama) are always free, regardless of the open-ended model
|
|
83
|
+
# tag Ollama echoes back (e.g. ``mistral:latest``) — trust the recorded provider
|
|
84
|
+
# over fragile model-id prefix matching.
|
|
85
|
+
provider = str(entry.get("provider") or "")
|
|
86
|
+
try:
|
|
87
|
+
cost = 0.0 if provider == "ollama" else CostEstimator.estimate_cost(model, usage)
|
|
88
|
+
except Exception:
|
|
89
|
+
cost = 0.0
|
|
90
|
+
records.append(
|
|
91
|
+
{
|
|
92
|
+
"task_id": entry.get("task_id"),
|
|
93
|
+
"run_id": entry.get("run_id"),
|
|
94
|
+
"timestamp": entry.get("timestamp"),
|
|
95
|
+
"model": model,
|
|
96
|
+
"usage": usage,
|
|
97
|
+
"cost": cost,
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
return records
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _group(records: List[Dict[str, Any]], key: str) -> Dict[str, Dict[str, Any]]:
|
|
104
|
+
groups: Dict[str, Dict[str, Any]] = {}
|
|
105
|
+
for record in records:
|
|
106
|
+
raw = record.get(key)
|
|
107
|
+
bucket = str(raw) if isinstance(raw, str) and raw else UNATTRIBUTED
|
|
108
|
+
group = groups.setdefault(
|
|
109
|
+
bucket,
|
|
110
|
+
{"cost": 0.0, "calls": 0, "prompt_tokens": 0, "completion_tokens": 0},
|
|
111
|
+
)
|
|
112
|
+
raw_usage = record.get("usage")
|
|
113
|
+
usage: Dict[str, Any] = raw_usage if isinstance(raw_usage, dict) else {}
|
|
114
|
+
group["cost"] += float(record.get("cost", 0.0) or 0.0)
|
|
115
|
+
group["calls"] += 1
|
|
116
|
+
group["prompt_tokens"] += int(usage.get("prompt_tokens", 0) or 0)
|
|
117
|
+
group["completion_tokens"] += int(usage.get("completion_tokens", 0) or 0)
|
|
118
|
+
return groups
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def group_cost(project_root: Path) -> Dict[str, Any]:
|
|
122
|
+
"""Aggregate model-call cost grouped by ``task_id`` and ``run_id``.
|
|
123
|
+
|
|
124
|
+
Returns a JSON-friendly summary: a grand total plus per-task and per-run
|
|
125
|
+
breakdowns. Unattributed records (older entries, or calls made without a
|
|
126
|
+
task/run context) are bucketed under ``(unattributed)``. Never raises.
|
|
127
|
+
"""
|
|
128
|
+
records = read_cost_records(project_root)
|
|
129
|
+
total_cost = sum(float(record.get("cost", 0.0) or 0.0) for record in records)
|
|
130
|
+
return {
|
|
131
|
+
"total_cost": total_cost,
|
|
132
|
+
"total_calls": len(records),
|
|
133
|
+
"by_task": _group(records, "task_id"),
|
|
134
|
+
"by_run": _group(records, "run_id"),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cost_by_task(project_root: Path) -> Dict[str, Dict[str, Any]]:
|
|
139
|
+
"""Convenience accessor for the per-task cost breakdown (used by ``dev status``)."""
|
|
140
|
+
return group_cost(project_root)["by_task"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
anthropic/claude-3-opus:
|
|
2
|
+
prompt_per_1k: 0.015
|
|
3
|
+
completion_per_1k: 0.075
|
|
4
|
+
anthropic/claude-3.5-sonnet:
|
|
5
|
+
prompt_per_1k: 0.003
|
|
6
|
+
completion_per_1k: 0.015
|
|
7
|
+
anthropic/claude-sonnet-4:
|
|
8
|
+
prompt_per_1k: 0.003
|
|
9
|
+
completion_per_1k: 0.015
|
|
10
|
+
google/gemini-pro-1.5:
|
|
11
|
+
prompt_per_1k: 0.00125
|
|
12
|
+
completion_per_1k: 0.005
|
|
13
|
+
google/gemini-2.5-pro:
|
|
14
|
+
prompt_per_1k: 0.00125
|
|
15
|
+
completion_per_1k: 0.01
|
|
16
|
+
openai/gpt-4o:
|
|
17
|
+
prompt_per_1k: 0.005
|
|
18
|
+
completion_per_1k: 0.015
|
|
19
|
+
openai/o3-mini:
|
|
20
|
+
prompt_per_1k: 0.0011
|
|
21
|
+
completion_per_1k: 0.0044
|
|
22
|
+
anthropic/claude-sonnet-4.6:
|
|
23
|
+
prompt_per_1k: 0.003
|
|
24
|
+
completion_per_1k: 0.015
|
|
25
|
+
anthropic/claude-opus-4.8:
|
|
26
|
+
prompt_per_1k: 0.005
|
|
27
|
+
completion_per_1k: 0.025
|
|
28
|
+
openai/gpt-5.5:
|
|
29
|
+
prompt_per_1k: 0.005
|
|
30
|
+
completion_per_1k: 0.03
|
|
31
|
+
google/gemini-2.5-flash:
|
|
32
|
+
prompt_per_1k: 0.0003
|
|
33
|
+
completion_per_1k: 0.0025
|
|
34
|
+
# Local Ollama models run on-device and are free. Bare tags (no ``ollama/``
|
|
35
|
+
# prefix) are listed here so the cost reporter shows $0; the cost.py prefix
|
|
36
|
+
# guard covers any other ``ollama/`` or ``ollama:`` model id.
|
|
37
|
+
qwen2.5-coder:7b:
|
|
38
|
+
prompt_per_1k: 0.0
|
|
39
|
+
completion_per_1k: 0.0
|
|
40
|
+
qwen2.5-coder:14b:
|
|
41
|
+
prompt_per_1k: 0.0
|
|
42
|
+
completion_per_1k: 0.0
|
|
43
|
+
qwen2.5-coder:32b:
|
|
44
|
+
prompt_per_1k: 0.0
|
|
45
|
+
completion_per_1k: 0.0
|
|
46
|
+
llama3.1:
|
|
47
|
+
prompt_per_1k: 0.0
|
|
48
|
+
completion_per_1k: 0.0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from importlib import resources
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
MODEL_PRICING_RESOURCE = "model_pricing.yaml"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache(maxsize=1)
|
|
11
|
+
def load_model_pricing() -> Dict[str, Dict[str, float]]:
|
|
12
|
+
data = resources.files(__package__).joinpath(MODEL_PRICING_RESOURCE).read_text(encoding="utf-8")
|
|
13
|
+
loaded = yaml.safe_load(data) or {}
|
|
14
|
+
return {
|
|
15
|
+
str(model): {
|
|
16
|
+
"prompt_per_1k": float(pricing.get("prompt_per_1k", 0.0)),
|
|
17
|
+
"completion_per_1k": float(pricing.get("completion_per_1k", 0.0)),
|
|
18
|
+
}
|
|
19
|
+
for model, pricing in loaded.items()
|
|
20
|
+
if isinstance(pricing, dict)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pricing_for_model(model: str, default: Dict[str, float] | None = None) -> Dict[str, float]:
|
|
25
|
+
pricing = load_model_pricing().get(model)
|
|
26
|
+
if pricing is not None:
|
|
27
|
+
return pricing
|
|
28
|
+
return default or {"prompt_per_1k": 0.0, "completion_per_1k": 0.0}
|
|
@@ -3,7 +3,7 @@ import logging
|
|
|
3
3
|
import uuid
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any, Dict, Iterable, Optional
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel, ConfigDict, Field
|
|
9
9
|
|
|
@@ -31,14 +31,17 @@ class TraceEvent(BaseModel):
|
|
|
31
31
|
def from_legacy(cls, raw: Dict[str, Any]) -> "TraceEvent":
|
|
32
32
|
if raw.get("schema") == TRACE_SCHEMA_VERSION:
|
|
33
33
|
return cls.model_validate(raw)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
raw_details = raw.get("details")
|
|
35
|
+
details: Dict[str, Any] = raw_details if isinstance(raw_details, dict) else {}
|
|
36
|
+
raw_task_id = details.get("task_id")
|
|
37
|
+
raw_summary = details.get("summary")
|
|
38
|
+
raw_run_id = raw.get("run_id")
|
|
37
39
|
return cls(
|
|
40
|
+
schema=TRACE_SCHEMA_VERSION,
|
|
38
41
|
type=str(raw.get("type", "legacy_event")),
|
|
39
|
-
run_id=
|
|
40
|
-
task_id=
|
|
41
|
-
summary=
|
|
42
|
+
run_id=raw_run_id if isinstance(raw_run_id, str) else None,
|
|
43
|
+
task_id=raw_task_id if isinstance(raw_task_id, str) else None,
|
|
44
|
+
summary=raw_summary if isinstance(raw_summary, str) else "",
|
|
42
45
|
details=details,
|
|
43
46
|
)
|
|
44
47
|
|
|
@@ -61,6 +64,7 @@ class TraceLogger:
|
|
|
61
64
|
) -> TraceEvent:
|
|
62
65
|
"""Append an orchestration event trace."""
|
|
63
66
|
trace = TraceEvent(
|
|
67
|
+
schema=TRACE_SCHEMA_VERSION,
|
|
64
68
|
type=event_type,
|
|
65
69
|
details=details,
|
|
66
70
|
run_id=run_id,
|
|
@@ -89,3 +93,54 @@ def read_trace_events(project_root: Path) -> Iterable[TraceEvent]:
|
|
|
89
93
|
except Exception as exc:
|
|
90
94
|
logger.debug("Skipping invalid trace line: %s", exc)
|
|
91
95
|
return events
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_trace_events_since(
|
|
99
|
+
project_root: Path, cursor: Optional[int] = None
|
|
100
|
+
) -> Tuple[List[TraceEvent], int]:
|
|
101
|
+
"""Incrementally read trace events appended after ``cursor``.
|
|
102
|
+
|
|
103
|
+
The cursor is a byte offset into the trace file, which is robust to appends
|
|
104
|
+
(the file is append-only) and makes repeated polling O(new bytes) rather than
|
|
105
|
+
O(all events). Returns ``(events, next_cursor)`` where ``next_cursor`` should
|
|
106
|
+
be passed back on the next call to fetch only newer events.
|
|
107
|
+
|
|
108
|
+
Never raises: on any error it returns an empty batch and a safe cursor. A
|
|
109
|
+
``cursor`` past the current end-of-file (e.g. the log was rotated/truncated)
|
|
110
|
+
is treated as a reset so the caller still makes progress.
|
|
111
|
+
"""
|
|
112
|
+
trace_file = project_root / ".devcouncil" / "logs" / "traces.jsonl"
|
|
113
|
+
start = cursor if isinstance(cursor, int) and cursor >= 0 else 0
|
|
114
|
+
if not trace_file.exists():
|
|
115
|
+
return [], start
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
size = trace_file.stat().st_size
|
|
119
|
+
# File shrank (rotation/truncation) — restart from the beginning.
|
|
120
|
+
if start > size:
|
|
121
|
+
start = 0
|
|
122
|
+
|
|
123
|
+
with open(trace_file, "rb") as f:
|
|
124
|
+
f.seek(start)
|
|
125
|
+
chunk = f.read()
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
logger.debug("Failed incremental trace read: %s", exc)
|
|
128
|
+
return [], start
|
|
129
|
+
|
|
130
|
+
# Only consume up to the last complete line so a half-written final line is
|
|
131
|
+
# re-read (not skipped) on the next poll once it is fully flushed.
|
|
132
|
+
last_newline = chunk.rfind(b"\n")
|
|
133
|
+
if last_newline == -1:
|
|
134
|
+
return [], start
|
|
135
|
+
consumed = chunk[: last_newline + 1]
|
|
136
|
+
next_cursor = start + len(consumed)
|
|
137
|
+
|
|
138
|
+
events: List[TraceEvent] = []
|
|
139
|
+
for raw_line in consumed.decode("utf-8", errors="replace").splitlines():
|
|
140
|
+
if not raw_line.strip():
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
events.append(TraceEvent.from_legacy(json.loads(raw_line)))
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
logger.debug("Skipping invalid trace line: %s", exc)
|
|
146
|
+
return events, next_cursor
|
|
@@ -1,49 +1,52 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from typing import Dict, Any
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
self.stats["models"]
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
self.
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
|
|
5
|
+
from devcouncil.telemetry.pricing import pricing_for_model
|
|
6
|
+
|
|
7
|
+
class TelemetryTracker:
|
|
8
|
+
def __init__(self, project_root: Path):
|
|
9
|
+
self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
|
|
10
|
+
self.stats = self._load()
|
|
11
|
+
|
|
12
|
+
def _load(self) -> Dict[str, Any]:
|
|
13
|
+
if self.log_file.exists():
|
|
14
|
+
try:
|
|
15
|
+
with open(self.log_file, "r") as f:
|
|
16
|
+
return json.load(f)
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
return {"total_cost": 0.0, "total_prompt_tokens": 0, "total_completion_tokens": 0, "models": {}}
|
|
20
|
+
|
|
21
|
+
def _save(self):
|
|
22
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
with open(self.log_file, "w") as f:
|
|
24
|
+
json.dump(self.stats, f, indent=2)
|
|
25
|
+
|
|
26
|
+
def log_usage(self, model: str, usage: Dict[str, int], *, local: bool = False):
|
|
27
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
28
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
29
|
+
|
|
30
|
+
if local:
|
|
31
|
+
# On-device providers (Ollama) incur no per-token cost. Zero by provider, not
|
|
32
|
+
# by model-id matching — consistent with telemetry/cost.py — so a local tag
|
|
33
|
+
# that happens to collide with a priced entry is never billed.
|
|
34
|
+
cost = 0.0
|
|
35
|
+
else:
|
|
36
|
+
rates = pricing_for_model(model)
|
|
37
|
+
cost = (prompt_tokens / 1000.0) * rates["prompt_per_1k"] + (
|
|
38
|
+
completion_tokens / 1000.0
|
|
39
|
+
) * rates["completion_per_1k"]
|
|
40
|
+
|
|
41
|
+
self.stats["total_cost"] += cost
|
|
42
|
+
self.stats["total_prompt_tokens"] += prompt_tokens
|
|
43
|
+
self.stats["total_completion_tokens"] += completion_tokens
|
|
44
|
+
|
|
45
|
+
if model not in self.stats["models"]:
|
|
46
|
+
self.stats["models"][model] = {"cost": 0.0, "prompt_tokens": 0, "completion_tokens": 0}
|
|
47
|
+
|
|
48
|
+
self.stats["models"][model]["cost"] += cost
|
|
49
|
+
self.stats["models"][model]["prompt_tokens"] += prompt_tokens
|
|
50
|
+
self.stats["models"][model]["completion_tokens"] += completion_tokens
|
|
51
|
+
|
|
52
|
+
self._save()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Live DevCouncil dashboard helpers."""
|