agentlinter 0.1.1__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.
- agent_lint/__init__.py +4 -0
- agent_lint/__main__.py +8 -0
- agent_lint/cli.py +215 -0
- agent_lint/comparator.py +47 -0
- agent_lint/config.py +83 -0
- agent_lint/data/providers.yaml +54 -0
- agent_lint/estimator.py +130 -0
- agent_lint/exceptions.py +27 -0
- agent_lint/formatters.py +202 -0
- agent_lint/gates.py +31 -0
- agent_lint/licensing.py +174 -0
- agent_lint/linter.py +67 -0
- agent_lint/models.py +192 -0
- agent_lint/parsers/__init__.py +97 -0
- agent_lint/parsers/crewai.py +70 -0
- agent_lint/parsers/generic.py +77 -0
- agent_lint/parsers/gorgon.py +104 -0
- agent_lint/parsers/langchain.py +67 -0
- agent_lint/pricing.py +123 -0
- agent_lint/rules/__init__.py +58 -0
- agent_lint/rules/budget.py +119 -0
- agent_lint/rules/efficiency.py +158 -0
- agent_lint/rules/resilience.py +150 -0
- agent_lint/rules/security.py +133 -0
- agentlinter-0.1.1.dist-info/METADATA +155 -0
- agentlinter-0.1.1.dist-info/RECORD +30 -0
- agentlinter-0.1.1.dist-info/WHEEL +5 -0
- agentlinter-0.1.1.dist-info/entry_points.txt +2 -0
- agentlinter-0.1.1.dist-info/licenses/LICENSE +21 -0
- agentlinter-0.1.1.dist-info/top_level.txt +1 -0
agent_lint/__init__.py
ADDED
agent_lint/__main__.py
ADDED
agent_lint/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""CLI entry point for agent-lint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from agent_lint import __version__
|
|
11
|
+
from agent_lint.exceptions import AgentAuditError
|
|
12
|
+
from agent_lint.formatters import (
|
|
13
|
+
format_compare_json,
|
|
14
|
+
format_compare_table,
|
|
15
|
+
format_estimate_json,
|
|
16
|
+
format_estimate_markdown,
|
|
17
|
+
format_estimate_table,
|
|
18
|
+
format_lint_json,
|
|
19
|
+
format_lint_markdown,
|
|
20
|
+
format_lint_table,
|
|
21
|
+
)
|
|
22
|
+
from agent_lint.licensing import get_upgrade_message, has_feature
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="agent-lint",
|
|
26
|
+
help="Analyze agent workflow configs for cost estimation and anti-patterns.",
|
|
27
|
+
)
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Root callback
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback(invoke_without_command=True)
|
|
37
|
+
def main(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
version: bool = typer.Option(
|
|
40
|
+
False,
|
|
41
|
+
"--version",
|
|
42
|
+
"-V",
|
|
43
|
+
is_eager=True,
|
|
44
|
+
help="Show version and exit.",
|
|
45
|
+
),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Analyze agent workflow configs for cost estimation and anti-patterns."""
|
|
48
|
+
if version:
|
|
49
|
+
console.print(f"agent-lint {__version__}")
|
|
50
|
+
raise typer.Exit()
|
|
51
|
+
if ctx.invoked_subcommand is None:
|
|
52
|
+
console.print(ctx.get_help())
|
|
53
|
+
raise typer.Exit()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# estimate
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def estimate(
|
|
63
|
+
workflow_file: Path = typer.Argument(..., help="Workflow YAML file to analyze."),
|
|
64
|
+
provider: str | None = typer.Option(
|
|
65
|
+
None, "--provider", "-p", help="Provider (anthropic, openai, ollama)."
|
|
66
|
+
),
|
|
67
|
+
model: str | None = typer.Option(None, "--model", "-m", help="Model name."),
|
|
68
|
+
json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
69
|
+
fmt: str = typer.Option("table", "--format", "-f", help="Output format (table|json|markdown)."),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Estimate token usage and cost for a workflow."""
|
|
72
|
+
from agent_lint.estimator import estimate_workflow
|
|
73
|
+
from agent_lint.parsers import parse_workflow
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
wf = parse_workflow(workflow_file)
|
|
77
|
+
result = estimate_workflow(wf, provider=provider, model=model)
|
|
78
|
+
except AgentAuditError as exc:
|
|
79
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
80
|
+
raise typer.Exit(1) from exc
|
|
81
|
+
|
|
82
|
+
if json or fmt == "json":
|
|
83
|
+
format_estimate_json(result, console)
|
|
84
|
+
elif fmt == "markdown":
|
|
85
|
+
format_estimate_markdown(result, console)
|
|
86
|
+
else:
|
|
87
|
+
format_estimate_table(result, console)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# lint
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def lint(
|
|
97
|
+
workflow_file: Path = typer.Argument(..., help="Workflow YAML file to lint."),
|
|
98
|
+
category: str | None = typer.Option(
|
|
99
|
+
None,
|
|
100
|
+
"--category",
|
|
101
|
+
"-c",
|
|
102
|
+
help="Filter by category (budget|resilience|efficiency|security).",
|
|
103
|
+
),
|
|
104
|
+
severity: str | None = typer.Option(
|
|
105
|
+
None,
|
|
106
|
+
"--severity",
|
|
107
|
+
"-s",
|
|
108
|
+
help="Filter by severity (error|warning|info).",
|
|
109
|
+
),
|
|
110
|
+
fail_under: int | None = typer.Option(
|
|
111
|
+
None, "--fail-under", help="Exit 1 if score is below this threshold."
|
|
112
|
+
),
|
|
113
|
+
json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
114
|
+
fmt: str = typer.Option("table", "--format", "-f", help="Output format (table|json|markdown)."),
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Lint a workflow for anti-patterns and best practice violations."""
|
|
117
|
+
from agent_lint.linter import run_lint
|
|
118
|
+
from agent_lint.models import RuleCategory, Severity
|
|
119
|
+
from agent_lint.parsers import parse_workflow
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
wf = parse_workflow(workflow_file)
|
|
123
|
+
except AgentAuditError as exc:
|
|
124
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
125
|
+
raise typer.Exit(1) from exc
|
|
126
|
+
|
|
127
|
+
cat = None
|
|
128
|
+
if category:
|
|
129
|
+
try:
|
|
130
|
+
cat = RuleCategory(category.lower())
|
|
131
|
+
except ValueError:
|
|
132
|
+
console.print(f"[red]Unknown category:[/red] {category}")
|
|
133
|
+
raise typer.Exit(1) from None
|
|
134
|
+
|
|
135
|
+
sev = None
|
|
136
|
+
if severity:
|
|
137
|
+
try:
|
|
138
|
+
sev = Severity(severity.lower())
|
|
139
|
+
except ValueError:
|
|
140
|
+
console.print(f"[red]Unknown severity:[/red] {severity}")
|
|
141
|
+
raise typer.Exit(1) from None
|
|
142
|
+
|
|
143
|
+
report = run_lint(wf, category=cat, severity=sev)
|
|
144
|
+
|
|
145
|
+
if json or fmt == "json":
|
|
146
|
+
format_lint_json(report, console)
|
|
147
|
+
elif fmt == "markdown":
|
|
148
|
+
format_lint_markdown(report, console)
|
|
149
|
+
else:
|
|
150
|
+
format_lint_table(report, console)
|
|
151
|
+
|
|
152
|
+
if fail_under is not None and report.score < fail_under:
|
|
153
|
+
console.print(f"[red]Score {report.score} is below threshold {fail_under}.[/red]")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# status
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command()
|
|
163
|
+
def status() -> None:
|
|
164
|
+
"""Show license status and available features."""
|
|
165
|
+
from agent_lint.licensing import TIER_DEFINITIONS, get_license_info
|
|
166
|
+
|
|
167
|
+
info = get_license_info()
|
|
168
|
+
tier_config = TIER_DEFINITIONS[info.tier]
|
|
169
|
+
|
|
170
|
+
console.print(f"\n[bold]agent-lint {__version__}[/bold]")
|
|
171
|
+
console.print(f"[bold]Tier:[/bold] {tier_config.name} ({tier_config.price_label})")
|
|
172
|
+
|
|
173
|
+
if info.license_key:
|
|
174
|
+
masked = info.license_key[:9] + "****-****"
|
|
175
|
+
console.print(f"[bold]Key:[/bold] {masked}")
|
|
176
|
+
valid_str = "[green]valid[/green]" if info.valid else "[red]invalid[/red]"
|
|
177
|
+
console.print(f"[bold]Valid:[/bold] {valid_str}")
|
|
178
|
+
|
|
179
|
+
console.print(f"\n[bold]Features:[/bold] {', '.join(tier_config.features)}")
|
|
180
|
+
console.print()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# compare (Pro)
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def compare(
|
|
190
|
+
workflow_file: Path = typer.Argument(..., help="Workflow YAML file to analyze."),
|
|
191
|
+
providers: list[str] | None = typer.Option(
|
|
192
|
+
None, "--provider", "-p", help="Providers to compare (repeat for each)."
|
|
193
|
+
),
|
|
194
|
+
json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Compare workflow costs across providers (Pro feature)."""
|
|
197
|
+
from agent_lint.comparator import compare_providers
|
|
198
|
+
from agent_lint.parsers import parse_workflow
|
|
199
|
+
|
|
200
|
+
# Gate check.
|
|
201
|
+
if not has_feature("compare"):
|
|
202
|
+
console.print(f"[yellow]{get_upgrade_message('compare')}[/yellow]")
|
|
203
|
+
raise typer.Exit(1)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
wf = parse_workflow(workflow_file)
|
|
207
|
+
result = compare_providers(wf, providers=providers)
|
|
208
|
+
except AgentAuditError as exc:
|
|
209
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
210
|
+
raise typer.Exit(1) from exc
|
|
211
|
+
|
|
212
|
+
if json:
|
|
213
|
+
format_compare_json(result, console)
|
|
214
|
+
else:
|
|
215
|
+
format_compare_table(result, console)
|
agent_lint/comparator.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Multi-provider cost comparison engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agent_lint.estimator import estimate_workflow
|
|
6
|
+
from agent_lint.models import CompareResult, ParsedWorkflow, WorkflowEstimate
|
|
7
|
+
from agent_lint.pricing import list_providers, load_providers
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compare_providers(
|
|
11
|
+
workflow: ParsedWorkflow,
|
|
12
|
+
providers: list[str] | None = None,
|
|
13
|
+
) -> CompareResult:
|
|
14
|
+
"""Estimate workflow cost across multiple providers and compare."""
|
|
15
|
+
all_providers = load_providers()
|
|
16
|
+
|
|
17
|
+
if providers is None:
|
|
18
|
+
providers = list_providers(providers=all_providers)
|
|
19
|
+
|
|
20
|
+
estimates: list[WorkflowEstimate] = []
|
|
21
|
+
for provider in providers:
|
|
22
|
+
est = estimate_workflow(workflow, provider=provider)
|
|
23
|
+
estimates.append(est)
|
|
24
|
+
|
|
25
|
+
if not estimates:
|
|
26
|
+
return CompareResult(
|
|
27
|
+
workflow_name=workflow.name,
|
|
28
|
+
estimates=[],
|
|
29
|
+
cheapest="",
|
|
30
|
+
most_expensive="",
|
|
31
|
+
savings_pct=0.0,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
cheapest = min(estimates, key=lambda e: e.total_cost_usd)
|
|
35
|
+
most_expensive = max(estimates, key=lambda e: e.total_cost_usd)
|
|
36
|
+
|
|
37
|
+
savings = 0.0
|
|
38
|
+
if most_expensive.total_cost_usd > 0:
|
|
39
|
+
savings = round((1 - cheapest.total_cost_usd / most_expensive.total_cost_usd) * 100, 1)
|
|
40
|
+
|
|
41
|
+
return CompareResult(
|
|
42
|
+
workflow_name=workflow.name,
|
|
43
|
+
estimates=estimates,
|
|
44
|
+
cheapest=cheapest.provider,
|
|
45
|
+
most_expensive=most_expensive.provider,
|
|
46
|
+
savings_pct=savings,
|
|
47
|
+
)
|
agent_lint/config.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Configuration constants and defaults for agent-lint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
# Token defaults by agent role (when estimated_tokens not declared).
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
ROLE_TOKEN_DEFAULTS: dict[str, int] = {
|
|
11
|
+
"planner": 5000,
|
|
12
|
+
"builder": 20000,
|
|
13
|
+
"tester": 10000,
|
|
14
|
+
"reviewer": 5000,
|
|
15
|
+
"architect": 8000,
|
|
16
|
+
"documenter": 6000,
|
|
17
|
+
"analyst": 8000,
|
|
18
|
+
"reporter": 4000,
|
|
19
|
+
"visualizer": 5000,
|
|
20
|
+
"security_auditor": 12000,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Default tokens by step type when no role is specified.
|
|
24
|
+
STEP_TYPE_TOKEN_DEFAULTS: dict[str, int] = {
|
|
25
|
+
"llm": 8000,
|
|
26
|
+
"shell": 0,
|
|
27
|
+
"checkpoint": 0,
|
|
28
|
+
"mcp_tool": 0,
|
|
29
|
+
"fan_in": 0,
|
|
30
|
+
"branch": 0,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Input/output token ratio (30% input, 70% output).
|
|
34
|
+
INPUT_OUTPUT_RATIO: float = 0.3
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Gorgon step type → provider mapping.
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
STEP_TYPE_PROVIDER_MAP: dict[str, str] = {
|
|
40
|
+
"claude_code": "anthropic",
|
|
41
|
+
"openai": "openai",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Gorgon step types that are LLM-based (cost tokens).
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
LLM_STEP_TYPES: frozenset[str] = frozenset({"claude_code", "openai"})
|
|
48
|
+
|
|
49
|
+
# Non-LLM step types (zero token cost).
|
|
50
|
+
ZERO_COST_STEP_TYPES: frozenset[str] = frozenset(
|
|
51
|
+
{
|
|
52
|
+
"shell",
|
|
53
|
+
"checkpoint",
|
|
54
|
+
"mcp_tool",
|
|
55
|
+
"fan_in",
|
|
56
|
+
"branch",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Container step types (sum nested steps).
|
|
61
|
+
CONTAINER_STEP_TYPES: frozenset[str] = frozenset(
|
|
62
|
+
{
|
|
63
|
+
"parallel",
|
|
64
|
+
"fan_out",
|
|
65
|
+
"map_reduce",
|
|
66
|
+
"loop",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Lint scoring weights.
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
SEVERITY_DEDUCTIONS: dict[str, int] = {
|
|
74
|
+
"error": 10,
|
|
75
|
+
"warning": 5,
|
|
76
|
+
"info": 2,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Paths.
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
DATA_DIR: Path = Path(__file__).parent / "data"
|
|
83
|
+
PROVIDERS_FILE: Path = DATA_DIR / "providers.yaml"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Provider pricing database for agent-lint.
|
|
2
|
+
# Prices in USD per 1K tokens. Updated 2025-Q4.
|
|
3
|
+
|
|
4
|
+
providers:
|
|
5
|
+
anthropic:
|
|
6
|
+
default_model: claude-sonnet-4
|
|
7
|
+
models:
|
|
8
|
+
claude-opus-4:
|
|
9
|
+
input: 0.015
|
|
10
|
+
output: 0.075
|
|
11
|
+
context: 200000
|
|
12
|
+
claude-sonnet-4:
|
|
13
|
+
input: 0.003
|
|
14
|
+
output: 0.015
|
|
15
|
+
context: 200000
|
|
16
|
+
claude-haiku-3.5:
|
|
17
|
+
input: 0.0008
|
|
18
|
+
output: 0.004
|
|
19
|
+
context: 200000
|
|
20
|
+
|
|
21
|
+
openai:
|
|
22
|
+
default_model: gpt-4o
|
|
23
|
+
models:
|
|
24
|
+
gpt-4o:
|
|
25
|
+
input: 0.0025
|
|
26
|
+
output: 0.01
|
|
27
|
+
context: 128000
|
|
28
|
+
gpt-4o-mini:
|
|
29
|
+
input: 0.00015
|
|
30
|
+
output: 0.0006
|
|
31
|
+
context: 128000
|
|
32
|
+
o1:
|
|
33
|
+
input: 0.015
|
|
34
|
+
output: 0.06
|
|
35
|
+
context: 200000
|
|
36
|
+
|
|
37
|
+
ollama:
|
|
38
|
+
default_model: llama3.3-70b
|
|
39
|
+
models:
|
|
40
|
+
llama3.3-70b:
|
|
41
|
+
input: 0.0
|
|
42
|
+
output: 0.0
|
|
43
|
+
context: 131072
|
|
44
|
+
notes: "Self-hosted, hardware cost not included"
|
|
45
|
+
deepseek-r1:
|
|
46
|
+
input: 0.0
|
|
47
|
+
output: 0.0
|
|
48
|
+
context: 65536
|
|
49
|
+
notes: "Self-hosted"
|
|
50
|
+
|
|
51
|
+
# Map Gorgon step types to providers.
|
|
52
|
+
step_type_mapping:
|
|
53
|
+
claude_code: anthropic
|
|
54
|
+
openai: openai
|
agent_lint/estimator.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Token estimation engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agent_lint.config import (
|
|
6
|
+
INPUT_OUTPUT_RATIO,
|
|
7
|
+
ROLE_TOKEN_DEFAULTS,
|
|
8
|
+
STEP_TYPE_TOKEN_DEFAULTS,
|
|
9
|
+
)
|
|
10
|
+
from agent_lint.models import (
|
|
11
|
+
ParsedStep,
|
|
12
|
+
ParsedWorkflow,
|
|
13
|
+
StepEstimate,
|
|
14
|
+
StepType,
|
|
15
|
+
WorkflowEstimate,
|
|
16
|
+
)
|
|
17
|
+
from agent_lint.pricing import calculate_cost, get_model_pricing, load_providers
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve_tokens(step: ParsedStep) -> tuple[int, str]:
|
|
21
|
+
"""Resolve token estimate for a step. Returns (tokens, source)."""
|
|
22
|
+
# 1. Declared in YAML.
|
|
23
|
+
if step.estimated_tokens is not None:
|
|
24
|
+
return step.estimated_tokens, "declared"
|
|
25
|
+
|
|
26
|
+
# 2. Archetype default by role.
|
|
27
|
+
if step.role and step.role in ROLE_TOKEN_DEFAULTS:
|
|
28
|
+
return ROLE_TOKEN_DEFAULTS[step.role], "archetype"
|
|
29
|
+
|
|
30
|
+
# 3. Step type default.
|
|
31
|
+
default = STEP_TYPE_TOKEN_DEFAULTS.get(step.step_type.value, 0)
|
|
32
|
+
if step.step_type == StepType.LLM and default == 0:
|
|
33
|
+
default = STEP_TYPE_TOKEN_DEFAULTS.get("llm", 8000)
|
|
34
|
+
return default, "default"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _split_tokens(total: int) -> tuple[int, int]:
|
|
38
|
+
"""Split total tokens into input/output based on ratio."""
|
|
39
|
+
input_tokens = int(total * INPUT_OUTPUT_RATIO)
|
|
40
|
+
output_tokens = total - input_tokens
|
|
41
|
+
return input_tokens, output_tokens
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def estimate_step(
|
|
45
|
+
step: ParsedStep,
|
|
46
|
+
provider: str,
|
|
47
|
+
model: str,
|
|
48
|
+
) -> StepEstimate:
|
|
49
|
+
"""Estimate tokens and cost for a single step."""
|
|
50
|
+
total_tokens, source = _resolve_tokens(step)
|
|
51
|
+
|
|
52
|
+
# Container steps: sum nested step tokens.
|
|
53
|
+
if step.nested_steps:
|
|
54
|
+
nested_total = sum(_resolve_tokens(ns)[0] for ns in step.nested_steps)
|
|
55
|
+
if nested_total > total_tokens:
|
|
56
|
+
total_tokens = nested_total
|
|
57
|
+
source = "declared" if step.estimated_tokens else "archetype"
|
|
58
|
+
|
|
59
|
+
input_tokens, output_tokens = _split_tokens(total_tokens)
|
|
60
|
+
|
|
61
|
+
# Non-LLM steps have zero cost regardless.
|
|
62
|
+
if step.step_type != StepType.LLM:
|
|
63
|
+
cost = 0.0
|
|
64
|
+
else:
|
|
65
|
+
pricing = get_model_pricing(provider, model)
|
|
66
|
+
cost = calculate_cost(input_tokens, output_tokens, pricing)
|
|
67
|
+
|
|
68
|
+
return StepEstimate(
|
|
69
|
+
step_id=step.id,
|
|
70
|
+
step_type=step.step_type,
|
|
71
|
+
provider=provider,
|
|
72
|
+
model=model,
|
|
73
|
+
role=step.role,
|
|
74
|
+
estimated_tokens=total_tokens,
|
|
75
|
+
input_tokens=input_tokens,
|
|
76
|
+
output_tokens=output_tokens,
|
|
77
|
+
cost_usd=cost,
|
|
78
|
+
source=source,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def estimate_workflow(
|
|
83
|
+
workflow: ParsedWorkflow,
|
|
84
|
+
provider: str | None = None,
|
|
85
|
+
model: str | None = None,
|
|
86
|
+
) -> WorkflowEstimate:
|
|
87
|
+
"""Estimate total tokens and cost for a workflow."""
|
|
88
|
+
providers = load_providers()
|
|
89
|
+
|
|
90
|
+
# Track whether the user explicitly set the provider.
|
|
91
|
+
explicit_provider = provider is not None
|
|
92
|
+
|
|
93
|
+
# Determine default provider from first LLM step.
|
|
94
|
+
if provider is None:
|
|
95
|
+
for step in workflow.steps:
|
|
96
|
+
if step.provider:
|
|
97
|
+
provider = step.provider
|
|
98
|
+
break
|
|
99
|
+
if provider is None:
|
|
100
|
+
provider = "anthropic"
|
|
101
|
+
|
|
102
|
+
# Determine model.
|
|
103
|
+
if model is None:
|
|
104
|
+
config = providers.get(provider)
|
|
105
|
+
model = config.default_model if config else "unknown"
|
|
106
|
+
|
|
107
|
+
step_estimates: list[StepEstimate] = []
|
|
108
|
+
for step in workflow.steps:
|
|
109
|
+
# Explicit CLI provider overrides step-level providers.
|
|
110
|
+
step_provider = provider if explicit_provider else step.provider or provider
|
|
111
|
+
step_model = step.model or model
|
|
112
|
+
step_estimates.append(estimate_step(step, step_provider, step_model))
|
|
113
|
+
|
|
114
|
+
total_tokens = sum(e.estimated_tokens for e in step_estimates)
|
|
115
|
+
total_cost = sum(e.cost_usd for e in step_estimates)
|
|
116
|
+
|
|
117
|
+
budget_util: float | None = None
|
|
118
|
+
if workflow.token_budget and workflow.token_budget > 0:
|
|
119
|
+
budget_util = round((total_tokens / workflow.token_budget) * 100, 1)
|
|
120
|
+
|
|
121
|
+
return WorkflowEstimate(
|
|
122
|
+
workflow_name=workflow.name,
|
|
123
|
+
total_tokens=total_tokens,
|
|
124
|
+
total_cost_usd=round(total_cost, 6),
|
|
125
|
+
budget_declared=workflow.token_budget,
|
|
126
|
+
budget_utilization=budget_util,
|
|
127
|
+
steps=step_estimates,
|
|
128
|
+
provider=provider,
|
|
129
|
+
model=model,
|
|
130
|
+
)
|
agent_lint/exceptions.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Custom exceptions for agent-lint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgentAuditError(Exception):
|
|
7
|
+
"""Base exception for all agent-lint errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParseError(AgentAuditError):
|
|
11
|
+
"""Workflow YAML parsing failed."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EstimateError(AgentAuditError):
|
|
15
|
+
"""Cost estimation failed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LintError(AgentAuditError):
|
|
19
|
+
"""Linting failed unexpectedly."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PricingError(AgentAuditError):
|
|
23
|
+
"""Pricing data load or calculation failed."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LicenseError(AgentAuditError):
|
|
27
|
+
"""Feature requires a higher license tier."""
|