argus-code 0.2.0__py3-none-any.whl
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.
- argus/__init__.py +3 -0
- argus/adapters/__init__.py +7 -0
- argus/adapters/base.py +108 -0
- argus/adapters/claude_code/__init__.py +5 -0
- argus/adapters/claude_code/adapter.py +63 -0
- argus/adapters/claude_code/discover.py +72 -0
- argus/adapters/claude_code/extract_tool_calls.py +86 -0
- argus/adapters/claude_code/extract_transcript.py +111 -0
- argus/adapters/claude_code/extract_turns.py +69 -0
- argus/adapters/claude_code/history_jsonl.py +138 -0
- argus/adapters/claude_code/ingest_file.py +137 -0
- argus/adapters/claude_code/model.py +11 -0
- argus/adapters/claude_code/schemas.py +77 -0
- argus/adapters/registry.py +30 -0
- argus/cli.py +384 -0
- argus/collector/__init__.py +0 -0
- argus/collector/aggregate.py +102 -0
- argus/collector/first_run.py +189 -0
- argus/collector/pipeline.py +140 -0
- argus/collector/rollup_subagents.py +27 -0
- argus/collector/scheduler.py +89 -0
- argus/collector/search_backfill.py +109 -0
- argus/collector/watcher.py +178 -0
- argus/dashboard-dist/_astro/charts.BIevw6Es.js +1 -0
- argus/dashboard-dist/_astro/format.DxC1NGYT.js +1 -0
- argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.CgwSARdD.js +24 -0
- argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.W18SJsr7.js +11 -0
- argus/dashboard-dist/_astro/installCanvasRenderer.D_tC6TXz.js +18 -0
- argus/dashboard-dist/_astro/models.astro_astro_type_script_index_0_lang.BHTHXYHC.js +13 -0
- argus/dashboard-dist/_astro/prompts.astro_astro_type_script_index_0_lang.DfNgiDv9.js +17 -0
- argus/dashboard-dist/_astro/session.astro_astro_type_script_index_0_lang.Dj_bfrIa.js +86 -0
- argus/dashboard-dist/_astro/settings.astro_astro_type_script_index_0_lang.d_a-uvdi.js +24 -0
- argus/dashboard-dist/_astro/tools.astro_astro_type_script_index_0_lang.Dzzau3Yt.js +12 -0
- argus/dashboard-dist/_astro/trends.astro_astro_type_script_index_0_lang.BLLeGRNa.js +5 -0
- argus/dashboard-dist/index.html +2 -0
- argus/dashboard-dist/models/index.html +1 -0
- argus/dashboard-dist/prompts/index.html +18 -0
- argus/dashboard-dist/session/index.html +2 -0
- argus/dashboard-dist/sessions/index.html +1 -0
- argus/dashboard-dist/settings/index.html +8 -0
- argus/dashboard-dist/styles/global.css +307 -0
- argus/dashboard-dist/tools/index.html +1 -0
- argus/dashboard-dist/trends/index.html +1 -0
- argus/detectors/__init__.py +6 -0
- argus/detectors/base.py +34 -0
- argus/detectors/registry.py +20 -0
- argus/detectors/tool_error_rate_spike.py +138 -0
- argus/pricing/2026-05-02.json +24 -0
- argus/pricing/__init__.py +0 -0
- argus/pricing/compute.py +46 -0
- argus/pricing/load.py +45 -0
- argus/pricing/refresh.py +91 -0
- argus/pricing/types.py +21 -0
- argus/scaffold/__init__.py +0 -0
- argus/scaffold/scaffolder.py +45 -0
- argus/scaffold/snapshot.py +73 -0
- argus/scaffold/storage.py +60 -0
- argus/schema/__init__.py +0 -0
- argus/schema/types.py +157 -0
- argus/server/__init__.py +0 -0
- argus/server/api.py +661 -0
- argus/server/app.py +97 -0
- argus/store/__init__.py +0 -0
- argus/store/db.py +103 -0
- argus/store/migrations/__init__.py +0 -0
- argus/store/migrations/inline.py +180 -0
- argus/store/repository.py +778 -0
- argus/templates/default/.claude/agents/code-reviewer.md +27 -0
- argus/templates/default/.claude/agents/security-auditor.md +28 -0
- argus/templates/default/.claude/commands/commit.md +38 -0
- argus/templates/default/.claude/commands/deploy.md +13 -0
- argus/templates/default/.claude/commands/fix-issue.md +15 -0
- argus/templates/default/.claude/commands/pr.md +38 -0
- argus/templates/default/.claude/commands/review.md +14 -0
- argus/templates/default/.claude/rules/api-conventions.md +27 -0
- argus/templates/default/.claude/rules/code-style.md +25 -0
- argus/templates/default/.claude/rules/testing.md +19 -0
- argus/templates/default/.claude/settings.json +28 -0
- argus/templates/default/.claude/skills/example/SKILL.md +11 -0
- argus/templates/default/CLAUDE.md +57 -0
- argus_code-0.2.0.dist-info/METADATA +247 -0
- argus_code-0.2.0.dist-info/RECORD +86 -0
- argus_code-0.2.0.dist-info/WHEEL +4 -0
- argus_code-0.2.0.dist-info/entry_points.txt +2 -0
- argus_code-0.2.0.dist-info/licenses/LICENSE +21 -0
- argus_code-0.2.0.dist-info/licenses/NOTICE +22 -0
argus/detectors/base.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Detector contract.
|
|
2
|
+
|
|
3
|
+
Detectors are pure: they read from a Repository and return findings.
|
|
4
|
+
Writes are performed by the scheduler, not the detector. This split keeps
|
|
5
|
+
detectors trivially unit-testable and the alerts table single-writer.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from ..schema.types import AlertSeverity
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..store.repository import Repository
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Finding:
|
|
20
|
+
"""One alert-shaped result returned by a detector."""
|
|
21
|
+
|
|
22
|
+
detector: str
|
|
23
|
+
dedup_key: str
|
|
24
|
+
severity: AlertSeverity
|
|
25
|
+
title: str
|
|
26
|
+
message: str
|
|
27
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class Detector(Protocol):
|
|
32
|
+
name: str
|
|
33
|
+
|
|
34
|
+
def detect(self, repo: "Repository", now_iso: str) -> list[Finding]: ...
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Detector registry. Mirrors argus.adapters.registry verbatim."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .base import Detector
|
|
5
|
+
|
|
6
|
+
_REGISTRY: dict[str, type[Detector]] = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(cls):
|
|
10
|
+
"""Class decorator — adds ``cls`` to the registry by ``cls.name``."""
|
|
11
|
+
_REGISTRY[cls.name] = cls
|
|
12
|
+
return cls
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def available_detectors() -> list[Detector]:
|
|
16
|
+
return [cls() for cls in _REGISTRY.values()]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def registered_detector_names() -> list[str]:
|
|
20
|
+
return list(_REGISTRY.keys())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""tool_error_rate_spike detector.
|
|
2
|
+
|
|
3
|
+
For each tool, compares the trailing-7-day error rate (`window`) against
|
|
4
|
+
the 28 days immediately before that (`baseline`). Fires a finding when:
|
|
5
|
+
|
|
6
|
+
- window_rate >= 2 * baseline_rate
|
|
7
|
+
- window_calls >= 20
|
|
8
|
+
- baseline_calls >= 20
|
|
9
|
+
|
|
10
|
+
Severity ``warning`` for ratios in [2, 5), ``critical`` for >= 5. The
|
|
11
|
+
20-call floor in each window suppresses the worst of the low-volume
|
|
12
|
+
noise; deliberate decision to *not* add an absolute-rate floor on the
|
|
13
|
+
ratio path for v1.
|
|
14
|
+
|
|
15
|
+
Special case: a tool that had zero errors across the baseline but starts
|
|
16
|
+
failing in the window (>= 5%) fires ``critical`` with a distinct title.
|
|
17
|
+
That's the most informative behavior change and the pure-ratio rule can't
|
|
18
|
+
express it (division by zero).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from datetime import datetime, timedelta, timezone
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from .base import Finding
|
|
26
|
+
from .registry import register
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ..store.repository import Repository
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_WINDOW_DAYS = 7
|
|
33
|
+
_BASELINE_DAYS = 28
|
|
34
|
+
_MIN_CALLS = 20
|
|
35
|
+
_WARNING_MULTIPLE = 2.0
|
|
36
|
+
_CRITICAL_MULTIPLE = 5.0
|
|
37
|
+
_ZERO_BASELINE_MIN_WINDOW_RATE = 0.05 # 5%
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _iso_at_offset(now_iso: str, days_ago: float) -> str:
|
|
41
|
+
base = datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
|
|
42
|
+
if base.tzinfo is None:
|
|
43
|
+
base = base.replace(tzinfo=timezone.utc)
|
|
44
|
+
return (base - timedelta(days=days_ago)).isoformat().replace("+00:00", "Z")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@register
|
|
48
|
+
class ToolErrorRateSpikeDetector:
|
|
49
|
+
name = "tool_error_rate_spike"
|
|
50
|
+
|
|
51
|
+
def detect(self, repo: "Repository", now_iso: str) -> list[Finding]:
|
|
52
|
+
window_start = _iso_at_offset(now_iso, _WINDOW_DAYS)
|
|
53
|
+
baseline_start = _iso_at_offset(now_iso, _WINDOW_DAYS + _BASELINE_DAYS)
|
|
54
|
+
|
|
55
|
+
window_rows = repo.tool_call_stats_in_range(
|
|
56
|
+
start_iso=window_start, end_iso=now_iso
|
|
57
|
+
)
|
|
58
|
+
baseline_rows = repo.tool_call_stats_in_range(
|
|
59
|
+
start_iso=baseline_start, end_iso=window_start
|
|
60
|
+
)
|
|
61
|
+
baseline_by_tool = {r["tool_name"]: r for r in baseline_rows}
|
|
62
|
+
|
|
63
|
+
findings: list[Finding] = []
|
|
64
|
+
for w in window_rows:
|
|
65
|
+
tool = w["tool_name"]
|
|
66
|
+
window_calls = int(w["calls"])
|
|
67
|
+
window_errors = int(w["errors"])
|
|
68
|
+
if window_calls < _MIN_CALLS:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
b = baseline_by_tool.get(tool)
|
|
72
|
+
if b is None:
|
|
73
|
+
continue
|
|
74
|
+
baseline_calls = int(b["calls"])
|
|
75
|
+
baseline_errors = int(b["errors"])
|
|
76
|
+
if baseline_calls < _MIN_CALLS:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
window_rate = window_errors / window_calls
|
|
80
|
+
baseline_rate = baseline_errors / baseline_calls
|
|
81
|
+
|
|
82
|
+
if baseline_rate == 0:
|
|
83
|
+
# "Broke from zero" — a previously-clean tool starting to
|
|
84
|
+
# fail. Fire critical when window_rate clears the noise
|
|
85
|
+
# floor; the call-count gates above already guarantee
|
|
86
|
+
# statistical relevance.
|
|
87
|
+
if window_rate < _ZERO_BASELINE_MIN_WINDOW_RATE:
|
|
88
|
+
continue
|
|
89
|
+
findings.append(
|
|
90
|
+
Finding(
|
|
91
|
+
detector=self.name,
|
|
92
|
+
dedup_key=tool,
|
|
93
|
+
severity="critical",
|
|
94
|
+
title=f"{tool} started failing this week (no prior errors)",
|
|
95
|
+
message=(
|
|
96
|
+
f"Last 7d: {window_rate * 100:.1f}% over {window_calls} calls. "
|
|
97
|
+
f"Prior 28d: 0 errors over {baseline_calls} calls."
|
|
98
|
+
),
|
|
99
|
+
metadata={
|
|
100
|
+
"tool_name": tool,
|
|
101
|
+
"window_rate": window_rate,
|
|
102
|
+
"baseline_rate": 0.0,
|
|
103
|
+
"window_calls": window_calls,
|
|
104
|
+
"baseline_calls": baseline_calls,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
multiple = window_rate / baseline_rate
|
|
111
|
+
if multiple < _WARNING_MULTIPLE:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
severity = "critical" if multiple >= _CRITICAL_MULTIPLE else "warning"
|
|
115
|
+
findings.append(
|
|
116
|
+
Finding(
|
|
117
|
+
detector=self.name,
|
|
118
|
+
dedup_key=tool,
|
|
119
|
+
severity=severity,
|
|
120
|
+
title=(
|
|
121
|
+
f"{tool} error rate jumped to {window_rate * 100:.1f}% "
|
|
122
|
+
f"this week (baseline {baseline_rate * 100:.1f}%)"
|
|
123
|
+
),
|
|
124
|
+
message=(
|
|
125
|
+
f"Last 7d: {window_rate * 100:.1f}% over {window_calls} calls. "
|
|
126
|
+
f"Prior 28d: {baseline_rate * 100:.1f}% over {baseline_calls} calls."
|
|
127
|
+
),
|
|
128
|
+
metadata={
|
|
129
|
+
"tool_name": tool,
|
|
130
|
+
"window_rate": window_rate,
|
|
131
|
+
"baseline_rate": baseline_rate,
|
|
132
|
+
"window_calls": window_calls,
|
|
133
|
+
"baseline_calls": baseline_calls,
|
|
134
|
+
"multiple": multiple,
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
return findings
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026-05-02",
|
|
3
|
+
"models": {
|
|
4
|
+
"claude-opus-4-7": { "input": 5, "output": 25, "cache_write_5m": 6.25, "cache_write_1h": 10, "cache_read": 0.50 },
|
|
5
|
+
"claude-opus-4-6": { "input": 5, "output": 25, "cache_write_5m": 6.25, "cache_write_1h": 10, "cache_read": 0.50 },
|
|
6
|
+
"claude-opus-4-5": { "input": 5, "output": 25, "cache_write_5m": 6.25, "cache_write_1h": 10, "cache_read": 0.50 },
|
|
7
|
+
"claude-opus-4-1": { "input": 15, "output": 75, "cache_write_5m": 18.75,"cache_write_1h": 30, "cache_read": 1.50 },
|
|
8
|
+
"claude-opus-4": { "input": 15, "output": 75, "cache_write_5m": 18.75,"cache_write_1h": 30, "cache_read": 1.50 },
|
|
9
|
+
"claude-sonnet-4-6": { "input": 3, "output": 15, "cache_write_5m": 3.75, "cache_write_1h": 6, "cache_read": 0.30 },
|
|
10
|
+
"claude-sonnet-4-5": { "input": 3, "output": 15, "cache_write_5m": 3.75, "cache_write_1h": 6, "cache_read": 0.30 },
|
|
11
|
+
"claude-sonnet-4": { "input": 3, "output": 15, "cache_write_5m": 3.75, "cache_write_1h": 6, "cache_read": 0.30 },
|
|
12
|
+
"claude-haiku-4-5": { "input": 1, "output": 5, "cache_write_5m": 1.25, "cache_write_1h": 2, "cache_read": 0.10 },
|
|
13
|
+
"claude-haiku-3-5": { "input": 0.80, "output": 4, "cache_write_5m": 1, "cache_write_1h": 1.60, "cache_read": 0.08 },
|
|
14
|
+
"claude-haiku-3": { "input": 0.25, "output": 1.25,"cache_write_5m": 0.30, "cache_write_1h": 0.50, "cache_read": 0.03 },
|
|
15
|
+
"gpt-5.5": { "input": 5, "output": 30, "cache_read": 0.50 },
|
|
16
|
+
"gpt-5.4": { "input": 2.50, "output": 15, "cache_read": 0.25 },
|
|
17
|
+
"gpt-5.4-mini": { "input": 0.75, "output": 4.50, "cache_read": 0.075 },
|
|
18
|
+
"gpt-5.4-nano": { "input": 0.20, "output": 1.25, "cache_read": 0.02 },
|
|
19
|
+
"gpt-5.3-codex": { "input": 1.75, "output": 14, "cache_read": 0.175 },
|
|
20
|
+
"gpt-5.3-chat-latest": { "input": 1.75, "output": 14, "cache_read": 0.175 },
|
|
21
|
+
"gpt-5.2": { "input": 1.75, "output": 14, "cache_read": 0.175 },
|
|
22
|
+
"gpt-5.1": { "input": 1.25, "output": 10, "cache_read": 0.125 }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
File without changes
|
argus/pricing/compute.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Per-turn cost computation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .types import PricingTable
|
|
7
|
+
|
|
8
|
+
PER_MTOK = 1_000_000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compute_turn_cost(t: Any, table: PricingTable) -> float:
|
|
12
|
+
"""Compute the USD cost for one turn given a pricing table.
|
|
13
|
+
|
|
14
|
+
``t`` is duck-typed — it must expose ``model``, ``fresh_input_tokens``,
|
|
15
|
+
``output_tokens``, ``cache_read_tokens``, ``cache_write_tokens``,
|
|
16
|
+
``cache_write_5m_tokens``, ``cache_write_1h_tokens``. Both
|
|
17
|
+
pydantic models (RawTurnEvent, Turn) and plain dicts work via
|
|
18
|
+
``getattr`` / dict access.
|
|
19
|
+
"""
|
|
20
|
+
def g(name: str, default: Any = None) -> Any:
|
|
21
|
+
if isinstance(t, dict):
|
|
22
|
+
return t.get(name, default)
|
|
23
|
+
return getattr(t, name, default)
|
|
24
|
+
|
|
25
|
+
model = g("model")
|
|
26
|
+
p = table.models.get(model)
|
|
27
|
+
if p is None:
|
|
28
|
+
return 0.0
|
|
29
|
+
|
|
30
|
+
cw5 = p.cache_write_5m if p.cache_write_5m is not None else p.input
|
|
31
|
+
cw1 = p.cache_write_1h if p.cache_write_1h is not None else p.input
|
|
32
|
+
|
|
33
|
+
cw5_tokens = g("cache_write_5m_tokens")
|
|
34
|
+
cw1_tokens = g("cache_write_1h_tokens")
|
|
35
|
+
|
|
36
|
+
if cw5_tokens is not None and cw1_tokens is not None:
|
|
37
|
+
write_tier = (cw5_tokens * cw5 + cw1_tokens * cw1) / PER_MTOK
|
|
38
|
+
else:
|
|
39
|
+
write_tier = (g("cache_write_tokens", 0) * cw5) / PER_MTOK
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
(g("fresh_input_tokens", 0) * p.input) / PER_MTOK
|
|
43
|
+
+ (g("output_tokens", 0) * p.output) / PER_MTOK
|
|
44
|
+
+ (g("cache_read_tokens", 0) * p.cache_read) / PER_MTOK
|
|
45
|
+
+ write_tier
|
|
46
|
+
)
|
argus/pricing/load.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Load the bundled pricing JSON shipped inside the wheel."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .types import PricingTable
|
|
9
|
+
|
|
10
|
+
_DEFAULT_FILENAME = "2026-05-02.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _bundled_dir() -> Path:
|
|
14
|
+
"""Path of the bundled ``pricing/`` directory.
|
|
15
|
+
|
|
16
|
+
Works in:
|
|
17
|
+
- installed wheel (data shipped alongside the package as argus/pricing/)
|
|
18
|
+
- editable install (`uv pip install -e .`)
|
|
19
|
+
- dev `uv run` from the repo (pricing/ at repo root)
|
|
20
|
+
|
|
21
|
+
The in-wheel path coincides with the source layout's ``argus/pricing``
|
|
22
|
+
subpackage (which has its own ``__init__.py``). We only trust that
|
|
23
|
+
location if the expected JSON actually lives there — otherwise we fall
|
|
24
|
+
back to the repo-root ``pricing/`` directory.
|
|
25
|
+
"""
|
|
26
|
+
# In-wheel location: only trust it if the expected file is present.
|
|
27
|
+
try:
|
|
28
|
+
traversable = resources.files("argus") / "pricing"
|
|
29
|
+
candidate = Path(str(traversable))
|
|
30
|
+
if (candidate / _DEFAULT_FILENAME).exists():
|
|
31
|
+
return candidate
|
|
32
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
# Dev fallback: repo-root pricing/.
|
|
36
|
+
# python/argus/pricing/load.py → parents[3] is the repo root.
|
|
37
|
+
here = Path(__file__).resolve()
|
|
38
|
+
return here.parents[3] / "pricing"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_pricing_table(path: Path | None = None) -> PricingTable:
|
|
42
|
+
"""Read a pricing JSON from disk and parse it."""
|
|
43
|
+
p = path or (_bundled_dir() / _DEFAULT_FILENAME)
|
|
44
|
+
raw = p.read_text(encoding="utf-8")
|
|
45
|
+
return PricingTable.model_validate(json.loads(raw))
|
argus/pricing/refresh.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Diff against current pricing and fetch the latest from LiteLLM."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from .types import ModelPricing, PricingTable
|
|
11
|
+
|
|
12
|
+
LITELLM_URL = (
|
|
13
|
+
"https://raw.githubusercontent.com/BerriAI/litellm/main/"
|
|
14
|
+
"model_prices_and_context_window.json"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PricingDiff(BaseModel):
|
|
19
|
+
added: list[str]
|
|
20
|
+
removed: list[str]
|
|
21
|
+
changed: list[str]
|
|
22
|
+
unchanged: list[str]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def diff_pricing(old: PricingTable, new: PricingTable) -> PricingDiff:
|
|
26
|
+
"""Return added / removed / changed / unchanged model-key sets."""
|
|
27
|
+
old_keys = set(old.models.keys())
|
|
28
|
+
new_keys = set(new.models.keys())
|
|
29
|
+
|
|
30
|
+
added = sorted(new_keys - old_keys)
|
|
31
|
+
removed = sorted(old_keys - new_keys)
|
|
32
|
+
changed: list[str] = []
|
|
33
|
+
unchanged: list[str] = []
|
|
34
|
+
for k in new_keys & old_keys:
|
|
35
|
+
a = old.models[k].model_dump()
|
|
36
|
+
b = new.models[k].model_dump()
|
|
37
|
+
if json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True):
|
|
38
|
+
unchanged.append(k)
|
|
39
|
+
else:
|
|
40
|
+
changed.append(k)
|
|
41
|
+
return PricingDiff(
|
|
42
|
+
added=added,
|
|
43
|
+
removed=removed,
|
|
44
|
+
changed=sorted(changed),
|
|
45
|
+
unchanged=sorted(unchanged),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch_litellm_table(
|
|
50
|
+
url: str = LITELLM_URL,
|
|
51
|
+
fetch: Callable[[str], dict[str, Any]] | None = None,
|
|
52
|
+
) -> PricingTable:
|
|
53
|
+
"""Fetch LiteLLM's per-token pricing JSON and convert to per-MTok shape.
|
|
54
|
+
|
|
55
|
+
``fetch`` is the network function; tests inject a stub so no real
|
|
56
|
+
HTTPS is made. Default is httpx.
|
|
57
|
+
"""
|
|
58
|
+
if fetch is None:
|
|
59
|
+
import httpx # local import — httpx is the runtime default
|
|
60
|
+
|
|
61
|
+
def _fetch(u: str) -> dict[str, Any]:
|
|
62
|
+
r = httpx.get(u, timeout=30.0)
|
|
63
|
+
r.raise_for_status()
|
|
64
|
+
return r.json()
|
|
65
|
+
|
|
66
|
+
fetch = _fetch
|
|
67
|
+
|
|
68
|
+
raw = fetch(url)
|
|
69
|
+
models: dict[str, ModelPricing] = {}
|
|
70
|
+
for k, v in raw.items():
|
|
71
|
+
if k == "sample_spec":
|
|
72
|
+
continue
|
|
73
|
+
if not isinstance(v, dict) or "input_cost_per_token" not in v:
|
|
74
|
+
continue
|
|
75
|
+
input_ = (v.get("input_cost_per_token") or 0) * 1_000_000
|
|
76
|
+
output = (v.get("output_cost_per_token") or 0) * 1_000_000
|
|
77
|
+
cache_read = (v.get("cache_read_input_token_cost") or 0) * 1_000_000
|
|
78
|
+
cache_write = (v.get("cache_creation_input_token_cost") or 0) * 1_000_000
|
|
79
|
+
entry: dict[str, Any] = {
|
|
80
|
+
"input": input_,
|
|
81
|
+
"output": output,
|
|
82
|
+
"cache_read": cache_read,
|
|
83
|
+
}
|
|
84
|
+
if cache_write:
|
|
85
|
+
entry["cache_write_5m"] = cache_write
|
|
86
|
+
entry["cache_write_1h"] = cache_write * 1.6
|
|
87
|
+
models[k] = ModelPricing.model_validate(entry)
|
|
88
|
+
return PricingTable(
|
|
89
|
+
version=dt.datetime.now(dt.timezone.utc).date().isoformat(),
|
|
90
|
+
models=models,
|
|
91
|
+
)
|
argus/pricing/types.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Pricing-table model — direct port of src/pricing/types.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModelPricing(BaseModel):
|
|
8
|
+
model_config = ConfigDict(extra="allow")
|
|
9
|
+
|
|
10
|
+
input: float
|
|
11
|
+
output: float
|
|
12
|
+
cache_write_5m: float | None = None
|
|
13
|
+
cache_write_1h: float | None = None
|
|
14
|
+
cache_read: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PricingTable(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="allow")
|
|
19
|
+
|
|
20
|
+
version: str
|
|
21
|
+
models: dict[str, ModelPricing] = Field(default_factory=dict)
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Copy a template directory into a project — the `argus claude init` core."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# CLAUDE.md is the user's project brain — never overwrite it, even with --force.
|
|
9
|
+
_CLAUDE_MD = "CLAUDE.md"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ScaffoldResult:
|
|
14
|
+
created: list[Path] = field(default_factory=list)
|
|
15
|
+
skipped: list[tuple[Path, str]] = field(default_factory=list) # (path, reason)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def scaffold_project(
|
|
19
|
+
template_dir: Path, dest: Path, *, force: bool = False
|
|
20
|
+
) -> ScaffoldResult:
|
|
21
|
+
"""Copy every file under ``template_dir`` into ``dest``, preserving layout.
|
|
22
|
+
|
|
23
|
+
- A template file at ``CLAUDE.md`` lands at ``dest/CLAUDE.md``; everything
|
|
24
|
+
else (all under ``.claude/``) lands at ``dest/.claude/...``.
|
|
25
|
+
- An existing destination file is skipped unless ``force`` is set.
|
|
26
|
+
- ``CLAUDE.md`` is NEVER overwritten, even with ``force``.
|
|
27
|
+
"""
|
|
28
|
+
result = ScaffoldResult()
|
|
29
|
+
for src in sorted(p for p in template_dir.rglob("*") if p.is_file()):
|
|
30
|
+
rel = src.relative_to(template_dir)
|
|
31
|
+
target = dest / rel
|
|
32
|
+
is_claude_md = rel.as_posix() == _CLAUDE_MD
|
|
33
|
+
|
|
34
|
+
if target.exists():
|
|
35
|
+
if is_claude_md:
|
|
36
|
+
result.skipped.append((target, "CLAUDE.md exists, left untouched"))
|
|
37
|
+
continue
|
|
38
|
+
if not force:
|
|
39
|
+
result.skipped.append((target, "exists"))
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
shutil.copyfile(src, target)
|
|
44
|
+
result.created.append(target)
|
|
45
|
+
return result
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Snapshot a project's .claude/ into a named user template (`template create`)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .storage import RESERVED_TEMPLATE_NAMES, user_templates_dir
|
|
8
|
+
|
|
9
|
+
# Never snapshot these — session/history/cache, or machine-local config.
|
|
10
|
+
_EXCLUDED_DIRS = {
|
|
11
|
+
"projects", "todos", "shell-snapshots", "statsig", "logs", "ide",
|
|
12
|
+
"__pycache__", ".DS_Store",
|
|
13
|
+
}
|
|
14
|
+
_EXCLUDED_TOP_FILE_SUFFIXES = (".local.json",)
|
|
15
|
+
_EXCLUDED_TOP_FILE_NAMES = {"history.jsonl"}
|
|
16
|
+
_COPY_IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def snapshot_candidates(project_dir: Path) -> list[str]:
|
|
20
|
+
"""Subfolder names under ``project_dir/.claude/`` eligible for snapshotting."""
|
|
21
|
+
claude = project_dir / ".claude"
|
|
22
|
+
if not claude.is_dir():
|
|
23
|
+
return []
|
|
24
|
+
return sorted(
|
|
25
|
+
p.name
|
|
26
|
+
for p in claude.iterdir()
|
|
27
|
+
if p.is_dir() and p.name not in _EXCLUDED_DIRS
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _safe_top_files(claude: Path) -> list[Path]:
|
|
32
|
+
out: list[Path] = []
|
|
33
|
+
for p in claude.iterdir():
|
|
34
|
+
if not p.is_file():
|
|
35
|
+
continue
|
|
36
|
+
if p.name in _EXCLUDED_TOP_FILE_NAMES:
|
|
37
|
+
continue
|
|
38
|
+
if any(p.name.endswith(s) for s in _EXCLUDED_TOP_FILE_SUFFIXES):
|
|
39
|
+
continue
|
|
40
|
+
out.append(p)
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def snapshot_template(
|
|
45
|
+
project_dir: Path, name: str, data_dir: Path, *, include_subdirs: list[str]
|
|
46
|
+
) -> Path:
|
|
47
|
+
"""Copy chosen ``.claude/`` subfolders + safe top-level files into a template.
|
|
48
|
+
|
|
49
|
+
Raises ``ValueError`` if ``name`` is reserved, the template already exists,
|
|
50
|
+
or there is no ``.claude/`` directory to snapshot. Returns the new template dir.
|
|
51
|
+
"""
|
|
52
|
+
if name in RESERVED_TEMPLATE_NAMES:
|
|
53
|
+
raise ValueError(f"'{name}' is reserved, pick another name")
|
|
54
|
+
claude = project_dir / ".claude"
|
|
55
|
+
if not claude.is_dir():
|
|
56
|
+
raise ValueError(f"no .claude/ directory found in {project_dir}")
|
|
57
|
+
|
|
58
|
+
target_root = user_templates_dir(data_dir) / name
|
|
59
|
+
if target_root.exists():
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"template '{name}' already exists; delete it or choose another name"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
dest_claude = target_root / ".claude"
|
|
65
|
+
dest_claude.mkdir(parents=True)
|
|
66
|
+
|
|
67
|
+
for f in _safe_top_files(claude):
|
|
68
|
+
shutil.copyfile(f, dest_claude / f.name)
|
|
69
|
+
for sub in include_subdirs:
|
|
70
|
+
src = claude / sub
|
|
71
|
+
if src.is_dir():
|
|
72
|
+
shutil.copytree(src, dest_claude / sub, ignore=_COPY_IGNORE)
|
|
73
|
+
return target_root
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Locate and resolve `argus claude` project-scaffolding templates.
|
|
2
|
+
|
|
3
|
+
Two tiers:
|
|
4
|
+
- bundled templates ship inside the wheel (read-only), mirroring ``pricing/``.
|
|
5
|
+
- user templates live under ``~/.argus/templates/<name>/`` (read-write).
|
|
6
|
+
|
|
7
|
+
``resolve_template`` checks the user dir first, then bundled.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
RESERVED_TEMPLATE_NAMES = {"default"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def bundled_templates_dir() -> Path:
|
|
18
|
+
"""Path of the bundled ``templates/`` dir (in-wheel, else repo-root in dev)."""
|
|
19
|
+
try:
|
|
20
|
+
traversable = resources.files("argus") / "templates"
|
|
21
|
+
candidate = Path(str(traversable))
|
|
22
|
+
if (candidate / "default" / "CLAUDE.md").exists():
|
|
23
|
+
return candidate
|
|
24
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
25
|
+
pass
|
|
26
|
+
# Dev fallback: repo-root templates/.
|
|
27
|
+
# python/argus/scaffold/storage.py -> parents[3] is the repo root.
|
|
28
|
+
return Path(__file__).resolve().parents[3] / "templates"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def user_templates_dir(data_dir: Path) -> Path:
|
|
32
|
+
"""Path of the user template store (``~/.argus/templates`` by default)."""
|
|
33
|
+
return data_dir / "templates"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _subdir_names(d: Path) -> list[str]:
|
|
37
|
+
if not d.is_dir():
|
|
38
|
+
return []
|
|
39
|
+
return sorted(p.name for p in d.iterdir() if p.is_dir())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_templates(data_dir: Path) -> list[str]:
|
|
43
|
+
"""All available template names: bundled ∪ user, sorted and de-duped."""
|
|
44
|
+
names = set(_subdir_names(bundled_templates_dir()))
|
|
45
|
+
names.update(_subdir_names(user_templates_dir(data_dir)))
|
|
46
|
+
return sorted(names)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_template(name: str, data_dir: Path) -> Path:
|
|
50
|
+
"""Return the directory for ``name``, preferring user templates.
|
|
51
|
+
|
|
52
|
+
Raises ``KeyError`` if no template with that name exists in either tier.
|
|
53
|
+
"""
|
|
54
|
+
user = user_templates_dir(data_dir) / name
|
|
55
|
+
if user.is_dir():
|
|
56
|
+
return user
|
|
57
|
+
bundled = bundled_templates_dir() / name
|
|
58
|
+
if bundled.is_dir():
|
|
59
|
+
return bundled
|
|
60
|
+
raise KeyError(name)
|
argus/schema/__init__.py
ADDED
|
File without changes
|