iris-security-cli 0.1.1__tar.gz → 0.1.3__tar.gz
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.
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/PKG-INFO +3 -3
- iris_security_cli-0.1.3/iris_cli/cost.py +418 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/main.py +4 -1
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/status_cmd.py +16 -1
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_security_cli.egg-info/PKG-INFO +3 -3
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_security_cli.egg-info/SOURCES.txt +1 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_security_cli.egg-info/requires.txt +2 -2
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/pyproject.toml +3 -3
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/README.md +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/__init__.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/action_plan.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/assess.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/cedar_parser.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/compiler_config.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/dlp_cmd.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/drift.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/evidence.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/explain.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/framework_suggest.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/framework_test.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/mcp_server.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/policy_cache.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/policy_diff.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/redteam.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/scan_govern.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/scan_report.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/scm.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/users.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_cli/watch.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_security_cli.egg-info/dependency_links.txt +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_security_cli.egg-info/entry_points.txt +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_security_cli.egg-info/top_level.txt +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/setup.cfg +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/tests/test_evidence.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/tests/test_framework_suggest.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/tests/test_framework_test.py +0 -0
- {iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/tests/test_policy_diff.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iris-security-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: IRIS CLI — iris scan, iris register, iris policy, iris compliance
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/gimartinb/iris-sdk
|
|
@@ -15,8 +15,8 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
|
-
Requires-Dist: iris-security-core>=0.1.
|
|
19
|
-
Requires-Dist: iris-security-sdk>=0.1.
|
|
18
|
+
Requires-Dist: iris-security-core>=0.1.2
|
|
19
|
+
Requires-Dist: iris-security-sdk>=0.1.3
|
|
20
20
|
Requires-Dist: click>=8.1
|
|
21
21
|
Requires-Dist: rich>=13.0
|
|
22
22
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""iris cost — token cost tracking, reporting, and optimization suggestions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import yaml
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from iris_core.cost.pricing import DEFAULT_PRICING, overrides_path, PricingRegistry
|
|
20
|
+
from iris_core.cost.tracker import CostSummary, CostTracker, discover_agent_trackers
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
ALERTS_PATH = Path.home() / ".iris" / "cost-alerts.yaml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _since_from_days(days: int) -> str:
|
|
28
|
+
return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _since_from_date(since: Optional[str], days: int) -> str:
|
|
32
|
+
if since:
|
|
33
|
+
return datetime.fromisoformat(since).replace(tzinfo=timezone.utc).isoformat()
|
|
34
|
+
return _since_from_days(days)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _format_usd(amount: float) -> str:
|
|
38
|
+
if amount >= 1:
|
|
39
|
+
return f"${amount:,.2f}"
|
|
40
|
+
return f"${amount:.3f}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _find_tracker(agent_name: str) -> Optional[CostTracker]:
|
|
44
|
+
for tracker in discover_agent_trackers():
|
|
45
|
+
if tracker.agent_name == agent_name or tracker.agent_id == agent_name:
|
|
46
|
+
return tracker
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_trackers(agent: Optional[str]) -> List[CostTracker]:
|
|
51
|
+
trackers = discover_agent_trackers()
|
|
52
|
+
if not agent:
|
|
53
|
+
return trackers
|
|
54
|
+
matched = [t for t in trackers if t.agent_name == agent or t.agent_id == agent]
|
|
55
|
+
if not matched:
|
|
56
|
+
console.print(f"[yellow]No cost data found for agent '{agent}'.[/yellow]")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
return matched
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _model_call_counts(tracker: CostTracker, since: str) -> Dict[str, int]:
|
|
62
|
+
counts: Dict[str, int] = {}
|
|
63
|
+
for entry in tracker.get_entries(since=since):
|
|
64
|
+
counts[entry.model] = counts.get(entry.model, 0) + 1
|
|
65
|
+
return counts
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_report_table(summary: CostSummary, days: int) -> None:
|
|
69
|
+
generated = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
70
|
+
header = (
|
|
71
|
+
f"┌─ Cost Report: {summary.agent_name} "
|
|
72
|
+
f"{'─' * max(1, 40 - len(summary.agent_name))}┐\n"
|
|
73
|
+
f"│ Period: last {days} days │ Generated: {generated}"
|
|
74
|
+
f"{' ' * max(1, 18 - len(str(days)))}│\n"
|
|
75
|
+
f"└{'─' * 58}┘"
|
|
76
|
+
)
|
|
77
|
+
console.print(header)
|
|
78
|
+
console.print("\n[bold]SUMMARY[/bold]")
|
|
79
|
+
console.print(f"Total spend: {_format_usd(summary.total_cost_usd)}")
|
|
80
|
+
console.print(f"Total calls: {summary.total_calls:,}")
|
|
81
|
+
console.print(f"Avg cost per call: {_format_usd(summary.avg_cost_per_call)}")
|
|
82
|
+
console.print(f"Estimated monthly: {_format_usd(summary.estimated_monthly_cost)}")
|
|
83
|
+
|
|
84
|
+
if summary.cost_by_model:
|
|
85
|
+
console.print("\n[bold]BY MODEL[/bold]")
|
|
86
|
+
tracker = _find_tracker(summary.agent_name)
|
|
87
|
+
since = summary.period_start
|
|
88
|
+
call_counts = _model_call_counts(tracker, since) if tracker else {}
|
|
89
|
+
total = summary.total_cost_usd or 1.0
|
|
90
|
+
for model, cost in sorted(summary.cost_by_model.items(), key=lambda x: -x[1]):
|
|
91
|
+
pct = int(cost / total * 100)
|
|
92
|
+
calls = call_counts.get(model, 0)
|
|
93
|
+
console.print(
|
|
94
|
+
f"{model:<20} {_format_usd(cost):>8} {pct:>3}% ({calls:,} calls)"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if summary.cost_by_tool:
|
|
98
|
+
console.print("\n[bold]BY TOOL[/bold]")
|
|
99
|
+
for tool, cost in sorted(summary.cost_by_tool.items(), key=lambda x: -x[1]):
|
|
100
|
+
entries = [
|
|
101
|
+
e for e in (_find_tracker(summary.agent_name) or CostTracker("", "")).get_entries(since=summary.period_start)
|
|
102
|
+
if e.tool_name == tool
|
|
103
|
+
]
|
|
104
|
+
calls = len(entries) if entries else 0
|
|
105
|
+
per_call = cost / calls if calls else 0.0
|
|
106
|
+
console.print(
|
|
107
|
+
f"{tool:<20} {_format_usd(cost):>8} ({calls:,} calls) "
|
|
108
|
+
f"{_format_usd(per_call)}/call"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if summary.anomalies:
|
|
112
|
+
console.print("\n[bold]ANOMALIES[/bold]")
|
|
113
|
+
for anomaly in summary.anomalies[:10]:
|
|
114
|
+
ts = anomaly.call.timestamp[:16].replace("T", " ")
|
|
115
|
+
console.print(
|
|
116
|
+
f"⚠ {ts} {_format_usd(anomaly.call.cost_usd)} call — "
|
|
117
|
+
f"{anomaly.call.tool_name}()\n"
|
|
118
|
+
f" {anomaly.description}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _render_summary_table(summaries: List[CostSummary], days: int) -> None:
|
|
123
|
+
total_org = sum(s.total_cost_usd for s in summaries)
|
|
124
|
+
header = (
|
|
125
|
+
f"┌─ IRIS Cost Summary — All Agents {'─' * 24}┐\n"
|
|
126
|
+
f"│ Period: last {days} days │ Total org spend: {_format_usd(total_org):<12}│\n"
|
|
127
|
+
f"└{'─' * 58}┘"
|
|
128
|
+
)
|
|
129
|
+
console.print(header)
|
|
130
|
+
console.print("")
|
|
131
|
+
|
|
132
|
+
table = Table(show_header=True, header_style="bold")
|
|
133
|
+
table.add_column("Agent")
|
|
134
|
+
table.add_column("Spend", justify="right")
|
|
135
|
+
table.add_column("Calls", justify="right")
|
|
136
|
+
table.add_column("Avg/call", justify="right")
|
|
137
|
+
table.add_column("Trend")
|
|
138
|
+
|
|
139
|
+
for summary in sorted(summaries, key=lambda s: -s.total_cost_usd):
|
|
140
|
+
trend = summary.cost_trend
|
|
141
|
+
if trend == "INCREASING":
|
|
142
|
+
trend_display = "↑ increasing"
|
|
143
|
+
elif trend == "DECREASING":
|
|
144
|
+
trend_display = "↓ decreasing"
|
|
145
|
+
else:
|
|
146
|
+
trend_display = "→ stable"
|
|
147
|
+
table.add_row(
|
|
148
|
+
summary.agent_name,
|
|
149
|
+
_format_usd(summary.total_cost_usd),
|
|
150
|
+
f"{summary.total_calls:,}",
|
|
151
|
+
_format_usd(summary.avg_cost_per_call),
|
|
152
|
+
trend_display,
|
|
153
|
+
)
|
|
154
|
+
console.print(table)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _suggest_optimizations(summary: CostSummary, since: str) -> List[str]:
|
|
158
|
+
suggestions: List[str] = []
|
|
159
|
+
tracker = _find_tracker(summary.agent_name)
|
|
160
|
+
if not tracker:
|
|
161
|
+
return suggestions
|
|
162
|
+
|
|
163
|
+
downgrade_map = {
|
|
164
|
+
"gpt-4o": "gpt-4o-mini",
|
|
165
|
+
"claude-sonnet-4-6": "claude-haiku-4-5",
|
|
166
|
+
"claude-opus-4-6": "claude-sonnet-4-6",
|
|
167
|
+
"gemini-2.0-pro": "gemini-2.0-flash",
|
|
168
|
+
"gemini-1.5-pro": "gemini-1.5-flash",
|
|
169
|
+
}
|
|
170
|
+
registry = PricingRegistry()
|
|
171
|
+
idx = 1
|
|
172
|
+
total_saving = 0.0
|
|
173
|
+
|
|
174
|
+
for tool, tool_cost in sorted(summary.cost_by_tool.items(), key=lambda x: -x[1]):
|
|
175
|
+
tool_entries = [e for e in tracker.get_entries(since=since) if e.tool_name == tool]
|
|
176
|
+
if not tool_entries:
|
|
177
|
+
continue
|
|
178
|
+
current_model = max(
|
|
179
|
+
{(e.model, e.cost_usd) for e in tool_entries},
|
|
180
|
+
key=lambda x: x[1],
|
|
181
|
+
)[0]
|
|
182
|
+
suggested_model = downgrade_map.get(current_model)
|
|
183
|
+
if not suggested_model:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
calls = len(tool_entries)
|
|
187
|
+
current_per_call = tool_cost / calls
|
|
188
|
+
provider = tool_entries[0].provider
|
|
189
|
+
avg_input = sum(e.input_tokens for e in tool_entries) // calls
|
|
190
|
+
avg_output = sum(e.output_tokens for e in tool_entries) // calls
|
|
191
|
+
suggested_cost = registry.calculate_cost(provider, suggested_model, avg_input, avg_output)
|
|
192
|
+
suggested_total = suggested_cost * calls
|
|
193
|
+
saving = tool_cost - suggested_total
|
|
194
|
+
if saving <= 0:
|
|
195
|
+
continue
|
|
196
|
+
total_saving += saving
|
|
197
|
+
suggestions.append(
|
|
198
|
+
f"{idx}. Switch {tool} to {suggested_model}\n"
|
|
199
|
+
f" Current: {current_model} at {_format_usd(current_per_call)}/call "
|
|
200
|
+
f"({calls:,} calls = {_format_usd(tool_cost)})\n"
|
|
201
|
+
f" Suggested: {suggested_model} at {_format_usd(suggested_cost)}/call "
|
|
202
|
+
f"({calls:,} calls = {_format_usd(suggested_total)})\n"
|
|
203
|
+
f" Estimated saving: {_format_usd(saving)}/month "
|
|
204
|
+
f"({int(saving / tool_cost * 100)}% reduction)\n"
|
|
205
|
+
f" Risk: Lower capability model. Test accuracy before switching."
|
|
206
|
+
)
|
|
207
|
+
idx += 1
|
|
208
|
+
|
|
209
|
+
large_prompt_entries = [
|
|
210
|
+
e for e in tracker.get_entries(since=since) if e.input_tokens > 10_000
|
|
211
|
+
]
|
|
212
|
+
if large_prompt_entries:
|
|
213
|
+
avg_tokens = int(
|
|
214
|
+
sum(e.input_tokens for e in large_prompt_entries) / len(large_prompt_entries)
|
|
215
|
+
)
|
|
216
|
+
excess_cost = sum(e.cost_usd for e in large_prompt_entries) * 0.3
|
|
217
|
+
total_saving += excess_cost
|
|
218
|
+
suggestions.append(
|
|
219
|
+
f"{idx}. The {min(3, len(large_prompt_entries))} most expensive calls include "
|
|
220
|
+
f"large prompts (avg {avg_tokens:,} tokens). Consider summarizing first.\n"
|
|
221
|
+
f" Estimated saving: {_format_usd(excess_cost)}/month"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if suggestions:
|
|
225
|
+
pct = int(total_saving / max(summary.estimated_monthly_cost, 0.01) * 100)
|
|
226
|
+
suggestions.append(
|
|
227
|
+
f"\nTotal estimated saving: {_format_usd(total_saving)}/month ({pct}% reduction)"
|
|
228
|
+
)
|
|
229
|
+
return suggestions
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_alert_config() -> dict:
|
|
233
|
+
if not ALERTS_PATH.exists():
|
|
234
|
+
return {}
|
|
235
|
+
return yaml.safe_load(ALERTS_PATH.read_text()) or {}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def save_alert_config(config: dict) -> None:
|
|
239
|
+
ALERTS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
ALERTS_PATH.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@click.group()
|
|
244
|
+
def cost():
|
|
245
|
+
"""Token cost tracking and reporting across all IRIS integrations."""
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@cost.command("report")
|
|
250
|
+
@click.option("--agent", default=None, help="Agent name (default: all agents)")
|
|
251
|
+
@click.option("--days", default=30, type=int, help="Report period in days")
|
|
252
|
+
@click.option("--format", "output_format", default="table", type=click.Choice(["table", "json", "csv"]))
|
|
253
|
+
@click.option("--since", default=None, help="ISO date string for period start")
|
|
254
|
+
def cost_report(agent: Optional[str], days: int, output_format: str, since: Optional[str]) -> None:
|
|
255
|
+
"""Show a detailed cost report for one or all agents."""
|
|
256
|
+
since_iso = _since_from_date(since, days)
|
|
257
|
+
trackers = _resolve_trackers(agent)
|
|
258
|
+
summaries = [t.get_summary(since=since_iso) for t in trackers]
|
|
259
|
+
|
|
260
|
+
if output_format == "json":
|
|
261
|
+
payload = [
|
|
262
|
+
{
|
|
263
|
+
"agent_name": s.agent_name,
|
|
264
|
+
"total_cost_usd": s.total_cost_usd,
|
|
265
|
+
"total_calls": s.total_calls,
|
|
266
|
+
"avg_cost_per_call": s.avg_cost_per_call,
|
|
267
|
+
"estimated_monthly_cost": s.estimated_monthly_cost,
|
|
268
|
+
"cost_by_model": s.cost_by_model,
|
|
269
|
+
"cost_by_tool": s.cost_by_tool,
|
|
270
|
+
"cost_trend": s.cost_trend,
|
|
271
|
+
}
|
|
272
|
+
for s in summaries
|
|
273
|
+
]
|
|
274
|
+
click.echo(json.dumps(payload, indent=2))
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
if output_format == "csv":
|
|
278
|
+
buffer = io.StringIO()
|
|
279
|
+
writer = csv.writer(buffer)
|
|
280
|
+
writer.writerow(
|
|
281
|
+
["agent", "total_cost_usd", "total_calls", "avg_cost_per_call", "estimated_monthly"]
|
|
282
|
+
)
|
|
283
|
+
for s in summaries:
|
|
284
|
+
writer.writerow(
|
|
285
|
+
[s.agent_name, s.total_cost_usd, s.total_calls, s.avg_cost_per_call, s.estimated_monthly_cost]
|
|
286
|
+
)
|
|
287
|
+
click.echo(buffer.getvalue())
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
for summary in summaries:
|
|
291
|
+
_render_report_table(summary, days)
|
|
292
|
+
if len(summaries) > 1:
|
|
293
|
+
console.print("")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@cost.command("summary")
|
|
297
|
+
@click.option("--days", default=30, type=int, help="Report period in days")
|
|
298
|
+
@click.option("--since", default=None, help="ISO date string for period start")
|
|
299
|
+
def cost_summary(days: int, since: Optional[str]) -> None:
|
|
300
|
+
"""CFO report — cost across all agents sorted by spend."""
|
|
301
|
+
since_iso = _since_from_date(since, days)
|
|
302
|
+
trackers = discover_agent_trackers()
|
|
303
|
+
if not trackers:
|
|
304
|
+
console.print("[yellow]No cost data recorded yet.[/yellow]")
|
|
305
|
+
console.print("Cost tracking starts automatically when governed LLM calls are made.")
|
|
306
|
+
return
|
|
307
|
+
summaries = [t.get_summary(since=since_iso) for t in trackers]
|
|
308
|
+
_render_summary_table(summaries, days)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@cost.command("alert")
|
|
312
|
+
@click.option("--agent", default=None, help="Agent name to monitor")
|
|
313
|
+
@click.option("--threshold", type=float, default=None, help="Alert if single call exceeds USD")
|
|
314
|
+
@click.option("--monthly-budget", type=float, default=None, help="Alert if monthly spend exceeds USD")
|
|
315
|
+
def cost_alert(agent: Optional[str], threshold: Optional[float], monthly_budget: Optional[float]) -> None:
|
|
316
|
+
"""Configure cost alerts (terminal delivery on free tier)."""
|
|
317
|
+
config = load_alert_config()
|
|
318
|
+
if agent:
|
|
319
|
+
agents = config.setdefault("agents", {})
|
|
320
|
+
agent_cfg = agents.setdefault(agent, {})
|
|
321
|
+
if threshold is not None:
|
|
322
|
+
agent_cfg["single_call_threshold_usd"] = threshold
|
|
323
|
+
if monthly_budget is not None:
|
|
324
|
+
agent_cfg["monthly_budget_usd"] = monthly_budget
|
|
325
|
+
save_alert_config(config)
|
|
326
|
+
console.print(f"[green]✓ Alert config saved for {agent}[/green]")
|
|
327
|
+
console.print(f"Config: {ALERTS_PATH}")
|
|
328
|
+
|
|
329
|
+
config = load_alert_config()
|
|
330
|
+
if not config.get("agents"):
|
|
331
|
+
console.print("[yellow]No alert rules configured.[/yellow]")
|
|
332
|
+
console.print("Example: iris cost alert --agent my-agent --threshold 1.00 --monthly-budget 50")
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
since_iso = _since_from_days(30)
|
|
336
|
+
for agent_name, rules in config.get("agents", {}).items():
|
|
337
|
+
tracker = _find_tracker(agent_name)
|
|
338
|
+
if not tracker:
|
|
339
|
+
continue
|
|
340
|
+
summary = tracker.get_summary(since=since_iso)
|
|
341
|
+
if rules.get("monthly_budget_usd") and summary.estimated_monthly_cost > rules["monthly_budget_usd"]:
|
|
342
|
+
console.print(
|
|
343
|
+
f"[red]⚠ BUDGET ALERT[/red] {agent_name}: "
|
|
344
|
+
f"estimated monthly {_format_usd(summary.estimated_monthly_cost)} "
|
|
345
|
+
f"exceeds budget {_format_usd(rules['monthly_budget_usd'])}"
|
|
346
|
+
)
|
|
347
|
+
call_threshold = rules.get("single_call_threshold_usd")
|
|
348
|
+
if call_threshold:
|
|
349
|
+
for entry in tracker.get_entries(since=since_iso):
|
|
350
|
+
if entry.cost_usd > call_threshold:
|
|
351
|
+
console.print(
|
|
352
|
+
f"[red]⚠ CALL ALERT[/red] {agent_name}: "
|
|
353
|
+
f"{entry.tool_name}() cost {_format_usd(entry.cost_usd)} "
|
|
354
|
+
f"at {entry.timestamp[:16]}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@cost.command("optimize")
|
|
359
|
+
@click.option("--agent", required=True, help="Agent name to analyze")
|
|
360
|
+
@click.option("--days", default=30, type=int, help="Analysis period in days")
|
|
361
|
+
def cost_optimize(agent: str, days: int) -> None:
|
|
362
|
+
"""Suggest cost optimizations without modifying any code or config."""
|
|
363
|
+
since_iso = _since_from_days(days)
|
|
364
|
+
tracker = _find_tracker(agent)
|
|
365
|
+
if not tracker:
|
|
366
|
+
console.print(f"[yellow]No cost data found for agent '{agent}'.[/yellow]")
|
|
367
|
+
sys.exit(1)
|
|
368
|
+
|
|
369
|
+
summary = tracker.get_summary(since=since_iso)
|
|
370
|
+
console.print(f"\n[bold]IRIS Cost Optimization — {summary.agent_name}[/bold]\n")
|
|
371
|
+
suggestions = _suggest_optimizations(summary, since_iso)
|
|
372
|
+
if not suggestions:
|
|
373
|
+
console.print("[green]No optimization opportunities identified.[/green]")
|
|
374
|
+
return
|
|
375
|
+
for block in suggestions:
|
|
376
|
+
console.print(block)
|
|
377
|
+
console.print("")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@cost.command("pricing")
|
|
381
|
+
@click.option("--provider", default=None, help="Filter pricing by provider")
|
|
382
|
+
@click.option("--update", is_flag=True, help="Show pricing override instructions")
|
|
383
|
+
def cost_pricing(provider: Optional[str], update: bool) -> None:
|
|
384
|
+
"""Show current LLM pricing table and custom override options."""
|
|
385
|
+
registry = PricingRegistry()
|
|
386
|
+
pricing = registry.all_pricing()
|
|
387
|
+
|
|
388
|
+
if update:
|
|
389
|
+
console.print(
|
|
390
|
+
Panel(
|
|
391
|
+
"Custom pricing overrides are stored in:\n"
|
|
392
|
+
f" {overrides_path()}\n\n"
|
|
393
|
+
"Format:\n"
|
|
394
|
+
" pricing:\n"
|
|
395
|
+
' "openai/gpt-4o":\n'
|
|
396
|
+
" input_per_1m: 2.50\n"
|
|
397
|
+
" output_per_1m: 10.00\n\n"
|
|
398
|
+
"Overrides always take precedence over the built-in table.",
|
|
399
|
+
title="Pricing Overrides",
|
|
400
|
+
style="blue",
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
table = Table(title="IRIS LLM Pricing (per 1M tokens)", show_header=True, header_style="bold")
|
|
406
|
+
table.add_column("Model")
|
|
407
|
+
table.add_column("Input", justify="right")
|
|
408
|
+
table.add_column("Output", justify="right")
|
|
409
|
+
|
|
410
|
+
combined = {**DEFAULT_PRICING, **pricing}
|
|
411
|
+
for model_key in sorted(combined):
|
|
412
|
+
if provider and not model_key.startswith(f"{provider.lower()}/"):
|
|
413
|
+
continue
|
|
414
|
+
input_price, output_price = combined[model_key]
|
|
415
|
+
table.add_row(model_key, f"${input_price:.3f}", f"${output_price:.3f}")
|
|
416
|
+
|
|
417
|
+
console.print(table)
|
|
418
|
+
console.print(f"\n[dim]Overrides: {overrides_path()}[/dim]")
|
|
@@ -25,7 +25,7 @@ console = Console()
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
@click.group()
|
|
28
|
-
@click.version_option(version="0.1.
|
|
28
|
+
@click.version_option(version="0.1.3", prog_name="iris")
|
|
29
29
|
def cli():
|
|
30
30
|
"""
|
|
31
31
|
IRIS — AI Agent Governance Platform
|
|
@@ -555,6 +555,9 @@ cli.add_command(red_team)
|
|
|
555
555
|
from iris_cli.users import users
|
|
556
556
|
cli.add_command(users)
|
|
557
557
|
|
|
558
|
+
from iris_cli.cost import cost
|
|
559
|
+
cli.add_command(cost)
|
|
560
|
+
|
|
558
561
|
|
|
559
562
|
@compliance.command("check")
|
|
560
563
|
@click.option("--agent", default=None, help="Specific agent to check (or all)")
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import List, Optional
|
|
7
8
|
|
|
@@ -12,6 +13,7 @@ from rich.panel import Panel
|
|
|
12
13
|
from iris import AgentPassport
|
|
13
14
|
from iris_cli.action_plan import compliance_score, progress_bar
|
|
14
15
|
from iris_core.compliance.registry import ComplianceRegistry
|
|
16
|
+
from iris_core.cost.tracker import discover_agent_trackers
|
|
15
17
|
|
|
16
18
|
console = Console()
|
|
17
19
|
|
|
@@ -49,6 +51,16 @@ def _status_label(score: float, violations: bool) -> str:
|
|
|
49
51
|
return "NOT REGISTERED"
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
def _monthly_cost_by_agent(days: int = 30) -> dict[str, float]:
|
|
55
|
+
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
56
|
+
costs: dict[str, float] = {}
|
|
57
|
+
for tracker in discover_agent_trackers():
|
|
58
|
+
summary = tracker.get_summary(since=since)
|
|
59
|
+
costs[tracker.agent_name] = summary.estimated_monthly_cost
|
|
60
|
+
costs[tracker.agent_id] = summary.estimated_monthly_cost
|
|
61
|
+
return costs
|
|
62
|
+
|
|
63
|
+
|
|
52
64
|
@click.command("status")
|
|
53
65
|
@click.option("--agent", default=None, help="Show status for a specific agent")
|
|
54
66
|
@click.option("--dir", "governance_dir", type=click.Path(path_type=Path), default=None)
|
|
@@ -81,6 +93,7 @@ def status_cmd(agent: Optional[str], governance_dir: Optional[Path], include_dem
|
|
|
81
93
|
registry = ComplianceRegistry()
|
|
82
94
|
next_global: Optional[str] = None
|
|
83
95
|
lowest_score = 2.0
|
|
96
|
+
monthly_costs = _monthly_cost_by_agent()
|
|
84
97
|
|
|
85
98
|
for name in sorted(all_agents):
|
|
86
99
|
agent_dir = all_agents[name]
|
|
@@ -95,8 +108,10 @@ def status_cmd(agent: Optional[str], governance_dir: Optional[Path], include_dem
|
|
|
95
108
|
next_global = _next_action(passport, agent_dir)
|
|
96
109
|
|
|
97
110
|
color = "green" if score >= 1.0 else "yellow" if score >= 0.4 else "red"
|
|
111
|
+
cost = monthly_costs.get(name) or monthly_costs.get(passport.agent_id)
|
|
112
|
+
cost_str = f" ${cost:.2f}/mo" if cost else ""
|
|
98
113
|
lines.append(
|
|
99
|
-
f" [{color}]{name:<24}[/{color}] {bar} {pct:>3}% {label}"
|
|
114
|
+
f" [{color}]{name:<24}[/{color}] {bar} {pct:>3}% {label}{cost_str}"
|
|
100
115
|
)
|
|
101
116
|
|
|
102
117
|
lines.append("")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iris-security-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: IRIS CLI — iris scan, iris register, iris policy, iris compliance
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/gimartinb/iris-sdk
|
|
@@ -15,8 +15,8 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
|
-
Requires-Dist: iris-security-core>=0.1.
|
|
19
|
-
Requires-Dist: iris-security-sdk>=0.1.
|
|
18
|
+
Requires-Dist: iris-security-core>=0.1.2
|
|
19
|
+
Requires-Dist: iris-security-sdk>=0.1.3
|
|
20
20
|
Requires-Dist: click>=8.1
|
|
21
21
|
Requires-Dist: rich>=13.0
|
|
22
22
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "iris-security-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "IRIS CLI — iris scan, iris register, iris policy, iris compliance"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
@@ -20,8 +20,8 @@ classifiers = [
|
|
|
20
20
|
"Programming Language :: Python :: 3.12",
|
|
21
21
|
]
|
|
22
22
|
dependencies = [
|
|
23
|
-
"iris-security-core>=0.1.
|
|
24
|
-
"iris-security-sdk>=0.1.
|
|
23
|
+
"iris-security-core>=0.1.2",
|
|
24
|
+
"iris-security-sdk>=0.1.3",
|
|
25
25
|
"click>=8.1",
|
|
26
26
|
"rich>=13.0",
|
|
27
27
|
"pyyaml>=6.0",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_security_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_security_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{iris_security_cli-0.1.1 → iris_security_cli-0.1.3}/iris_security_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|