agentmap-scan 0.1.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.
- agentmap/__init__.py +13 -0
- agentmap/agents.py +166 -0
- agentmap/cli.py +102 -0
- agentmap/report.py +167 -0
- agentmap/scanner.py +221 -0
- agentmap/signatures.py +324 -0
- agentmap_scan-0.1.0.dist-info/METADATA +77 -0
- agentmap_scan-0.1.0.dist-info/RECORD +12 -0
- agentmap_scan-0.1.0.dist-info/WHEEL +5 -0
- agentmap_scan-0.1.0.dist-info/entry_points.txt +2 -0
- agentmap_scan-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentmap_scan-0.1.0.dist-info/top_level.txt +1 -0
agentmap/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""agentmap — map every AI API call in a codebase, offline. No LLM, no keys."""
|
|
2
|
+
from .scanner import (
|
|
3
|
+
Finding, scan, call_sites, providers_found, routing,
|
|
4
|
+
hardcoded_sites, apply_autofix,
|
|
5
|
+
)
|
|
6
|
+
from .agents import Agent, extract_agents, purpose_by_file
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Finding", "scan", "call_sites", "providers_found", "routing",
|
|
10
|
+
"hardcoded_sites", "apply_autofix",
|
|
11
|
+
"Agent", "extract_agents", "purpose_by_file",
|
|
12
|
+
]
|
|
13
|
+
__version__ = "0.1.0"
|
agentmap/agents.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code-only agent + responsibility extraction — no LLM, no API key.
|
|
3
|
+
|
|
4
|
+
For each source file we find system-prompt strings and the nearest agent name,
|
|
5
|
+
then tidy the prompt to its first sentence. This is a heuristic, not a parser.
|
|
6
|
+
|
|
7
|
+
ponytail: regex heuristic with a known ceiling — covers raw OpenAI/Anthropic
|
|
8
|
+
message dicts, `system=`/`system_prompt=`/`instructions=` assignments,
|
|
9
|
+
CrewAI `Agent(role=...)`, `brevitas.agent("name")`, and plain enclosing
|
|
10
|
+
functions/classes. It will miss graph-based frameworks (LangGraph node defs,
|
|
11
|
+
AutoGen). Upgrade path: add a per-framework matcher here as users report misses.
|
|
12
|
+
|
|
13
|
+
python -m agentmap.agents # runs the self-check
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .scanner import _walk # reuse the same file-walk (skips deps/binaries/docs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── system-prompt matchers ─────────────────────────────────────────────────────
|
|
25
|
+
# Each yields the prompt text via group 'p'. Ordered most-specific first.
|
|
26
|
+
_QUOTE = r"""(?:"{3}(?P<p3>.*?)"{3}|'{3}(?P<p3b>.*?)'{3}|"(?P<p1>(?:\\.|[^"\\])*)"|'(?P<p1b>(?:\\.|[^'\\])*)')"""
|
|
27
|
+
|
|
28
|
+
_PROMPT_PATTERNS = [
|
|
29
|
+
# {"role": "system", "content": "..."}
|
|
30
|
+
re.compile(r"""role["']?\s*:\s*["']system["']\s*,\s*["']?content["']?\s*:\s*""" + _QUOTE,
|
|
31
|
+
re.IGNORECASE | re.DOTALL),
|
|
32
|
+
# system= / system_prompt= / instructions= / role=, with an optional name
|
|
33
|
+
# prefix so `copywriter_system = "..."` / `EDITOR_PROMPT = "..."` also match.
|
|
34
|
+
re.compile(r"""\b\w*(?:system_prompt|system|instructions|_prompt)\s*[:=]\s*""" + _QUOTE,
|
|
35
|
+
re.IGNORECASE | re.DOTALL),
|
|
36
|
+
re.compile(r"""\brole\s*[:=]\s*""" + _QUOTE, re.IGNORECASE | re.DOTALL), # CrewAI Agent(role=)
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# ── agent-name matchers (searched upward from the prompt) ───────────────────────
|
|
40
|
+
_NAME_PATTERNS = [
|
|
41
|
+
re.compile(r"""\.agent\(\s*["'](\w+)["']"""), # brevitas.agent("x")
|
|
42
|
+
re.compile(r"""Agent\([^)]*\bname\s*=\s*["'](\w+)["']"""), # Agent(name="x")
|
|
43
|
+
re.compile(r"""\bagent(?:_name)?\s*=\s*["'](\w+)["']"""), # agent="x"
|
|
44
|
+
re.compile(r"""\b(\w+?)_(?:system|sys|prompt|instructions)\b\s*[:=]""", re.IGNORECASE), # copywriter_system=
|
|
45
|
+
re.compile(r"""\bdef\s+(\w+)\s*\("""), # enclosing def
|
|
46
|
+
re.compile(r"""\bclass\s+(\w+)\b"""), # enclosing class
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Bare message-role values that leak from {"role": "user"} etc. — not agents.
|
|
51
|
+
_MESSAGE_ROLES = {"system", "user", "assistant", "tool", "function", "developer", "model", "ai"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_test_file(path: Path) -> bool:
|
|
55
|
+
n = path.name.lower()
|
|
56
|
+
return (n.startswith("test_") or n.endswith("_test.py")
|
|
57
|
+
or "test" in path.parts or "tests" in path.parts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Agent:
|
|
62
|
+
name: str
|
|
63
|
+
role: str # tidied first sentence of the system prompt
|
|
64
|
+
file: str
|
|
65
|
+
line: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _first_sentence(text: str) -> str:
|
|
69
|
+
t = re.sub(r"\s+", " ", (text or "").replace("\\n", " ")).strip()
|
|
70
|
+
# split on sentence end or the first newline-ish boundary
|
|
71
|
+
m = re.search(r"(.+?[.!?])(?:\s|$)", t)
|
|
72
|
+
s = m.group(1) if m else t
|
|
73
|
+
return s.strip()[:120]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _prompt_text(m: re.Match) -> str:
|
|
77
|
+
gd = m.groupdict()
|
|
78
|
+
for g in ("p3", "p3b", "p1", "p1b"):
|
|
79
|
+
if gd.get(g):
|
|
80
|
+
return gd[g]
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def extract_agents(root: str | Path) -> list[Agent]:
|
|
85
|
+
"""Find (agent name → responsibility) pairs across a codebase, code-only."""
|
|
86
|
+
agents: list[Agent] = []
|
|
87
|
+
seen: set[tuple[str, str]] = set() # (file, name) dedup
|
|
88
|
+
|
|
89
|
+
for path in _walk(Path(root)):
|
|
90
|
+
if _is_test_file(path):
|
|
91
|
+
continue # test fixtures aren't real agents
|
|
92
|
+
try:
|
|
93
|
+
text = path.read_text(encoding="utf-8", errors="strict")
|
|
94
|
+
except (UnicodeDecodeError, OSError):
|
|
95
|
+
continue
|
|
96
|
+
lines = text.splitlines()
|
|
97
|
+
|
|
98
|
+
for pat in _PROMPT_PATTERNS:
|
|
99
|
+
for m in pat.finditer(text):
|
|
100
|
+
prompt = _prompt_text(m)
|
|
101
|
+
if not prompt or len(prompt.strip()) < 8:
|
|
102
|
+
continue
|
|
103
|
+
if prompt.strip().lower() in _MESSAGE_ROLES:
|
|
104
|
+
continue # a message-role value, not a system prompt
|
|
105
|
+
line_no = text.count("\n", 0, m.start()) + 1
|
|
106
|
+
name = _nearest_name(lines, line_no)
|
|
107
|
+
key = (str(path), name)
|
|
108
|
+
if key in seen:
|
|
109
|
+
continue
|
|
110
|
+
seen.add(key)
|
|
111
|
+
agents.append(Agent(name=name, role=_first_sentence(prompt),
|
|
112
|
+
file=str(path), line=line_no))
|
|
113
|
+
return agents
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Generic tokens that are never a useful agent name (they're the prompt keyword itself).
|
|
117
|
+
_GENERIC_NAMES = {"system", "agent", "prompt", "instructions", "role", "messages",
|
|
118
|
+
"content", "user", "assistant", "sys", "self", "cls"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _nearest_name(lines: list[str], line_no: int, look_back: int = 25) -> str:
|
|
122
|
+
"""First non-generic agent-name signal at/above line_no; else the enclosing def/class."""
|
|
123
|
+
start = max(0, line_no - 1)
|
|
124
|
+
for i in range(start, max(-1, start - look_back), -1):
|
|
125
|
+
for pat in _NAME_PATTERNS:
|
|
126
|
+
m = pat.search(lines[i]) if i < len(lines) else None
|
|
127
|
+
if m and m.group(1).lower() not in _GENERIC_NAMES:
|
|
128
|
+
return m.group(1)
|
|
129
|
+
return "agent"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def purpose_by_file(agents: list[Agent]) -> list[tuple[str, str]]:
|
|
133
|
+
"""One purpose line per file = its first agent's role. [(file, role), …]."""
|
|
134
|
+
out: dict[str, str] = {}
|
|
135
|
+
for a in agents:
|
|
136
|
+
out.setdefault(a.file, a.role)
|
|
137
|
+
return sorted(out.items())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── self-check ──────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def _selfcheck() -> None:
|
|
143
|
+
import tempfile
|
|
144
|
+
fixture = '''
|
|
145
|
+
def researcher(client):
|
|
146
|
+
messages = [
|
|
147
|
+
{"role": "system", "content": "You are a market research analyst. Analyze competitors."},
|
|
148
|
+
{"role": "user", "content": user_input},
|
|
149
|
+
]
|
|
150
|
+
return client.chat.completions.create(model="gpt-4o", messages=messages)
|
|
151
|
+
|
|
152
|
+
copywriter_system = "You are a senior copywriter. Write punchy ad copy that converts."
|
|
153
|
+
'''
|
|
154
|
+
with tempfile.TemporaryDirectory() as d:
|
|
155
|
+
(Path(d) / "agents.py").write_text(fixture)
|
|
156
|
+
found = extract_agents(d)
|
|
157
|
+
by_name = {a.name: a.role for a in found}
|
|
158
|
+
assert "researcher" in by_name, by_name
|
|
159
|
+
assert by_name["researcher"].startswith("You are a market research analyst"), by_name
|
|
160
|
+
assert not by_name["researcher"].endswith("competitors."), "should stop at first sentence"
|
|
161
|
+
assert any("copywriter" in n for n in by_name), by_name
|
|
162
|
+
print("ok: agents", {k: v[:40] for k, v in by_name.items()})
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
_selfcheck()
|
agentmap/cli.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentmap CLI.
|
|
3
|
+
|
|
4
|
+
agentmap [PATH] scan a repo → open a webpage of its AI calls (default)
|
|
5
|
+
agentmap install [PATH] write routing env vars; --auto rewrites hardcoded URLs
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from .scanner import scan, call_sites, routing, hardcoded_sites, apply_autofix
|
|
15
|
+
from .agents import extract_agents, purpose_by_file
|
|
16
|
+
from .report import render_and_open
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DefaultGroup(click.Group):
|
|
20
|
+
"""Group that runs `scan` when the first arg isn't a known subcommand.
|
|
21
|
+
|
|
22
|
+
Lets `agentmap PATH --no-open` work (PATH is not a command → falls to scan)
|
|
23
|
+
while `agentmap install …` still resolves normally.
|
|
24
|
+
"""
|
|
25
|
+
def resolve_command(self, ctx, args):
|
|
26
|
+
try:
|
|
27
|
+
return super().resolve_command(ctx, args)
|
|
28
|
+
except click.UsageError:
|
|
29
|
+
return "scan", self.get_command(ctx, "scan"), args
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.group(cls=DefaultGroup, invoke_without_command=True)
|
|
33
|
+
@click.pass_context
|
|
34
|
+
def main(ctx: click.Context) -> None:
|
|
35
|
+
"""Map every AI API call in a codebase. Bare `agentmap` scans the current dir."""
|
|
36
|
+
if ctx.invoked_subcommand is None:
|
|
37
|
+
ctx.invoke(scan_cmd)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@main.command(name="scan")
|
|
41
|
+
@click.argument("path", default=".")
|
|
42
|
+
@click.option("--target", default="http://localhost:4242", show_default=True,
|
|
43
|
+
help="Gateway URL to route calls through")
|
|
44
|
+
@click.option("--open/--no-open", "open_", default=True, help="Open the report in a browser")
|
|
45
|
+
def scan_cmd(path: str, target: str, open_: bool) -> None:
|
|
46
|
+
"""Scan PATH and open a webpage of its AI calls, models, and agents."""
|
|
47
|
+
findings = scan(path)
|
|
48
|
+
calls = call_sites(findings)
|
|
49
|
+
plan = routing(findings, proxy=target)
|
|
50
|
+
agents = extract_agents(path)
|
|
51
|
+
purposes = purpose_by_file(agents)
|
|
52
|
+
|
|
53
|
+
counts = Counter(f.provider for f in calls)
|
|
54
|
+
click.echo(f"\n{len(calls)} AI call sites · {len(counts)} providers · {len(agents)} agents")
|
|
55
|
+
for pid, n in counts.most_common():
|
|
56
|
+
tag = "routed" if pid in plan["auto"] else "manual"
|
|
57
|
+
click.echo(f" {n:>4} {pid:<16} ({tag})")
|
|
58
|
+
if agents:
|
|
59
|
+
click.echo("\nagents:")
|
|
60
|
+
for a in agents:
|
|
61
|
+
click.echo(f" {a.name:<16} {a.role}")
|
|
62
|
+
|
|
63
|
+
out = render_and_open(path, findings, plan, agents, purposes, open_browser=open_)
|
|
64
|
+
click.echo(f"\n✓ Report: {out}" + ("" if open_ else " (drop --no-open to launch it)"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@main.command()
|
|
68
|
+
@click.argument("path", default=".")
|
|
69
|
+
@click.option("--target", default="http://localhost:4242", show_default=True, help="Gateway URL")
|
|
70
|
+
@click.option("--auto", is_flag=True, help="Rewrite hardcoded provider URLs in place (edits your files)")
|
|
71
|
+
@click.option("--env-file", default=".env.agentmap", show_default=True, help="Where to write routing env vars")
|
|
72
|
+
def install(path: str, target: str, auto: bool, env_file: str) -> None:
|
|
73
|
+
"""Route PATH's calls through the gateway: write env vars + handle hardcoded URLs."""
|
|
74
|
+
findings = scan(path)
|
|
75
|
+
plan = routing(findings, proxy=target)
|
|
76
|
+
hard = hardcoded_sites(findings)
|
|
77
|
+
if not plan["env"] and not hard:
|
|
78
|
+
click.echo("Nothing to route — no OpenAI/Anthropic-compatible calls found.")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
lines = ["# agentmap routing — `source` this before running your app\n"]
|
|
82
|
+
lines += [f"export {k}={v}\n" for k, v in plan["env"].items()]
|
|
83
|
+
Path(env_file).write_text("".join(lines))
|
|
84
|
+
click.echo(f"\n✓ Wrote {env_file} → source {env_file}")
|
|
85
|
+
for k, v in plan["env"].items():
|
|
86
|
+
click.echo(f" {k}={v}")
|
|
87
|
+
if plan["manual"]:
|
|
88
|
+
click.echo(f"\nManual (own SDK, point base_url at {target}): {', '.join(plan['manual'])}")
|
|
89
|
+
|
|
90
|
+
if auto:
|
|
91
|
+
edits = apply_autofix(findings, proxy=target)
|
|
92
|
+
click.echo(f"\n✓ Rewrote {len(edits)} hardcoded URL(s)")
|
|
93
|
+
for pth, ln, new in edits[:20]:
|
|
94
|
+
click.echo(f" {pth}:{ln} {new}")
|
|
95
|
+
elif hard:
|
|
96
|
+
click.echo(f"\n{len(hard)} hardcoded URL(s) — env vars can't override these. Edit or re-run with --auto:")
|
|
97
|
+
for f in hard[:20]:
|
|
98
|
+
click.echo(f" {f.path}:{f.line} {f.snippet}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|
agentmap/report.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimalist static HTML report: calls-by-file, providers & models, agents &
|
|
3
|
+
responsibilities, and the routing plan. No backend, no JS — one self-contained file.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import html as _html
|
|
8
|
+
import tempfile
|
|
9
|
+
import webbrowser
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .scanner import call_sites, hardcoded_sites, providers_found
|
|
14
|
+
from .signatures import MODEL, PROVIDERS_BY_ID
|
|
15
|
+
|
|
16
|
+
_CSS = """
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
body { margin:0; font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
19
|
+
background:#0b0f14; color:#d7dee8; }
|
|
20
|
+
.wrap { max-width:1000px; margin:0 auto; padding:32px 20px 64px; }
|
|
21
|
+
h1 { font-size:20px; margin:0 0 4px; color:#fff; }
|
|
22
|
+
.sub { color:#7c8794; margin:0 0 24px; }
|
|
23
|
+
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin:0 0 28px; }
|
|
24
|
+
.card { background:#131a23; border:1px solid #1f2a37; border-radius:10px; padding:16px; }
|
|
25
|
+
.card .n { font-size:26px; font-weight:700; color:#fff; }
|
|
26
|
+
.card .l { color:#7c8794; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
|
27
|
+
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.05em; color:#7c8794;
|
|
28
|
+
border-bottom:1px solid #1f2a37; padding-bottom:8px; margin:32px 0 14px; }
|
|
29
|
+
.row { display:flex; align-items:center; gap:10px; margin:6px 0; }
|
|
30
|
+
.row .name { width:150px; color:#d7dee8; }
|
|
31
|
+
.bar { height:16px; background:#2563eb; border-radius:3px; min-width:2px; }
|
|
32
|
+
.bar.manual { background:#f59e0b; }
|
|
33
|
+
.row .c { color:#7c8794; width:70px; }
|
|
34
|
+
.tag { font-size:11px; padding:1px 7px; border-radius:99px; }
|
|
35
|
+
.tag.auto { background:#14351f; color:#4ade80; }
|
|
36
|
+
.tag.manual { background:#3a2a10; color:#f59e0b; }
|
|
37
|
+
pre { background:#0f151d; border:1px solid #1f2a37; border-radius:8px; padding:12px 14px;
|
|
38
|
+
overflow-x:auto; color:#9fd0ff; }
|
|
39
|
+
code.f { color:#7c8794; }
|
|
40
|
+
.file { margin:10px 0 14px; }
|
|
41
|
+
.fp { color:#fff; font-weight:600; margin-bottom:2px; word-break:break-all; }
|
|
42
|
+
.agent { margin:8px 0; }
|
|
43
|
+
.agent .an { color:#fff; font-weight:600; }
|
|
44
|
+
.agent .ar { color:#9fb3c8; }
|
|
45
|
+
.agent .loc { color:#556; font-size:12px; }
|
|
46
|
+
.hard { color:#f87171; }
|
|
47
|
+
.empty { color:#7c8794; font-style:italic; }
|
|
48
|
+
.foot { margin-top:40px; color:#7c8794; font-size:13px; border-top:1px solid #1f2a37; padding-top:16px; }
|
|
49
|
+
a { color:#60a5fa; }
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _esc(s) -> str:
|
|
54
|
+
return _html.escape(str(s))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _group_by_file(calls):
|
|
58
|
+
"""[(path, [(line, provider, snippet), …]), …] ordered by call count desc."""
|
|
59
|
+
from collections import defaultdict
|
|
60
|
+
d = defaultdict(list)
|
|
61
|
+
for f in calls:
|
|
62
|
+
d[f.path].append((f.line, f.provider, f.snippet))
|
|
63
|
+
for v in d.values():
|
|
64
|
+
v.sort()
|
|
65
|
+
return sorted(d.items(), key=lambda kv: len(kv[1]), reverse=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _models(findings):
|
|
69
|
+
"""[(provider_name, model, count), …] from MODEL-kind findings, most common first."""
|
|
70
|
+
c = Counter((PROVIDERS_BY_ID[f.provider].name, f.snippet_model) for f in _model_findings(findings))
|
|
71
|
+
return [(prov, model, n) for (prov, model), n in c.most_common()]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _model_findings(findings):
|
|
75
|
+
"""Attach the matched model literal to each MODEL finding."""
|
|
76
|
+
import re
|
|
77
|
+
for f in findings:
|
|
78
|
+
if f.kind != MODEL:
|
|
79
|
+
continue
|
|
80
|
+
spec = PROVIDERS_BY_ID[f.provider]
|
|
81
|
+
pat = next((p for p in spec.patterns if p.kind == MODEL), None)
|
|
82
|
+
if not pat:
|
|
83
|
+
continue
|
|
84
|
+
m = pat.regex.search(f.snippet)
|
|
85
|
+
f.snippet_model = m.group(0) if m else "?"
|
|
86
|
+
yield f
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_html(path, findings, plan, agents, purposes) -> str:
|
|
90
|
+
calls = call_sites(findings)
|
|
91
|
+
counts = Counter(f.provider for f in calls)
|
|
92
|
+
hard = hardcoded_sites(findings)
|
|
93
|
+
mx = max(counts.values()) if counts else 1
|
|
94
|
+
|
|
95
|
+
prov_rows = "".join(
|
|
96
|
+
f'<div class="row"><span class="name">{_esc(pid)}</span>'
|
|
97
|
+
f'<span class="bar {"" if pid in plan["auto"] else "manual"}" style="width:{int(n / mx * 300)}px"></span>'
|
|
98
|
+
f'<span class="c">{n}</span>'
|
|
99
|
+
f'<span class="tag {"auto" if pid in plan["auto"] else "manual"}">{"routed" if pid in plan["auto"] else "manual"}</span></div>'
|
|
100
|
+
for pid, n in counts.most_common()) or '<div class="empty">no AI calls found</div>'
|
|
101
|
+
|
|
102
|
+
model_rows = "".join(
|
|
103
|
+
f'<div class="row"><span class="name">{_esc(prov)}</span>'
|
|
104
|
+
f'<code class="f">{_esc(model)}</code><span class="c">({n})</span></div>'
|
|
105
|
+
for prov, model, n in _models(findings)) or '<div class="empty">no model names detected</div>'
|
|
106
|
+
|
|
107
|
+
files_html = "".join(
|
|
108
|
+
f'<div class="file"><div class="fp">{_esc(fpath)}</div>' + "".join(
|
|
109
|
+
f'<div class="row"><span class="c" style="width:52px">L{ln}</span>'
|
|
110
|
+
f'<span class="tag {"auto" if pid in plan["auto"] else "manual"}">{_esc(pid)}</span> '
|
|
111
|
+
f'<code class="f">{_esc(snip[:100])}</code></div>'
|
|
112
|
+
for ln, pid, snip in items) + '</div>'
|
|
113
|
+
for fpath, items in _group_by_file(calls)) or '<div class="empty">no AI calls found</div>'
|
|
114
|
+
|
|
115
|
+
agents_html = "".join(
|
|
116
|
+
f'<div class="agent"><span class="an">{_esc(a.name)}</span> — '
|
|
117
|
+
f'<span class="ar">{_esc(a.role)}</span> '
|
|
118
|
+
f'<span class="loc">{_esc(a.file)}:{a.line}</span></div>'
|
|
119
|
+
for a in agents) or '<div class="empty">no agents detected (no system prompts found)</div>'
|
|
120
|
+
|
|
121
|
+
purpose_html = "".join(
|
|
122
|
+
f'<div class="row"><span class="name" style="width:220px">{_esc(Path(f).name)}</span>'
|
|
123
|
+
f'<span class="ar">{_esc(role)}</span></div>'
|
|
124
|
+
for f, role in purposes) or '<div class="empty">—</div>'
|
|
125
|
+
|
|
126
|
+
env_block = "\n".join(f"export {k}={v}" for k, v in plan["env"].items()) \
|
|
127
|
+
or "# no OpenAI/Anthropic-compatible calls found"
|
|
128
|
+
manual = f'<p class="sub">Manual (own SDK, edit base_url): {_esc(", ".join(plan["manual"]))}</p>' if plan["manual"] else ""
|
|
129
|
+
hard_html = ("".join(
|
|
130
|
+
f'<div class="row"><span class="hard">{_esc(h.path)}:{h.line}</span> '
|
|
131
|
+
f'<code class="f">{_esc(h.snippet[:90])}</code></div>' for h in hard[:15])
|
|
132
|
+
or '<div class="empty">none — every call routes via env vars</div>')
|
|
133
|
+
|
|
134
|
+
return f"""<!doctype html><html><head><meta charset="utf-8">
|
|
135
|
+
<title>agentmap — {_esc(path)}</title><style>{_CSS}</style></head><body><div class="wrap">
|
|
136
|
+
<h1>agentmap</h1><p class="sub">AI API calls in <code>{_esc(path)}</code></p>
|
|
137
|
+
<div class="cards">
|
|
138
|
+
<div class="card"><div class="n">{sum(counts.values())}</div><div class="l">AI call sites</div></div>
|
|
139
|
+
<div class="card"><div class="n">{len(counts)}</div><div class="l">providers</div></div>
|
|
140
|
+
<div class="card"><div class="n">{len(agents)}</div><div class="l">agents</div></div>
|
|
141
|
+
<div class="card"><div class="n">{len(hard)}</div><div class="l">hardcoded URLs</div></div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<h2>Calls by file</h2>{files_html}
|
|
145
|
+
|
|
146
|
+
<h2>Providers & models</h2>{prov_rows}
|
|
147
|
+
<div style="height:10px"></div>{model_rows}
|
|
148
|
+
|
|
149
|
+
<h2>Agents & responsibilities</h2>{agents_html}
|
|
150
|
+
<div style="height:14px"></div>
|
|
151
|
+
<h2>Purpose per file</h2>{purpose_html}
|
|
152
|
+
|
|
153
|
+
<h2>Routing — set these to send calls through your gateway</h2>
|
|
154
|
+
{manual}<pre>{_esc(env_block)}</pre>
|
|
155
|
+
<h2>Hardcoded URLs (need --auto or a manual edit)</h2>{hard_html}
|
|
156
|
+
|
|
157
|
+
<div class="foot">Generated by <a href="https://github.com/">agentmap</a> ·
|
|
158
|
+
route these calls through <b>Brevitas</b> to compress context and cut token cost.</div>
|
|
159
|
+
</div></body></html>"""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def render_and_open(path, findings, plan, agents, purposes, open_browser=True) -> Path:
|
|
163
|
+
out = Path(tempfile.gettempdir()) / "agentmap_report.html"
|
|
164
|
+
out.write_text(build_html(str(path), findings, plan, agents, purposes), encoding="utf-8")
|
|
165
|
+
if open_browser:
|
|
166
|
+
webbrowser.open(f"file://{out}")
|
|
167
|
+
return out
|
agentmap/scanner.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repo scanner: walk a codebase, flag every LLM/AI API call site (any language),
|
|
3
|
+
and derive the one-click routing config that sends them through a gateway.
|
|
4
|
+
|
|
5
|
+
Detection is regex over source lines using the provider registry in
|
|
6
|
+
`signatures.py` — endpoint hosts + call methods catch calls in Go, Rust, PHP,
|
|
7
|
+
Ruby, Java, shell/curl, etc., not just the Python/JS SDKs.
|
|
8
|
+
|
|
9
|
+
python -m agentmap.scanner # runs the self-check
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .signatures import ENDPOINT, CALL, PROVIDERS_BY_ID, iter_patterns
|
|
18
|
+
|
|
19
|
+
# Dirs we never scan — vendored deps, VCS, build output. ponytail: hardcoded skip
|
|
20
|
+
# list beats parsing .gitignore; add gitignore support when a repo layout needs it.
|
|
21
|
+
_SKIP_DIRS = {
|
|
22
|
+
".git", "node_modules", "dist", "build", ".next", ".venv", "venv",
|
|
23
|
+
"__pycache__", ".pytest_cache", ".mypy_cache", "vendor", "target",
|
|
24
|
+
".vercel", "site-packages", ".idea", ".vscode",
|
|
25
|
+
}
|
|
26
|
+
# Binary / non-source extensions to skip outright.
|
|
27
|
+
_SKIP_EXT = {
|
|
28
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg", ".pdf", ".zip",
|
|
29
|
+
".gz", ".tar", ".woff", ".woff2", ".ttf", ".eot", ".mp4", ".mp3", ".lock",
|
|
30
|
+
".map", ".min.js", ".wasm", ".so", ".dylib", ".bin", ".db",
|
|
31
|
+
".md", ".rst", # prose docs — never a runtime call site, only noise
|
|
32
|
+
}
|
|
33
|
+
_MAX_BYTES = 1_000_000 # skip files larger than 1 MB
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Finding:
|
|
38
|
+
path: str
|
|
39
|
+
line: int
|
|
40
|
+
provider: str # provider id
|
|
41
|
+
provider_name: str
|
|
42
|
+
kind: str # endpoint | call | import | model
|
|
43
|
+
snippet: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def scan(root: str | Path) -> list[Finding]:
|
|
47
|
+
"""Scan `root` recursively and return one Finding per (file, line, provider)."""
|
|
48
|
+
root = Path(root)
|
|
49
|
+
patterns = list(iter_patterns())
|
|
50
|
+
findings: list[Finding] = []
|
|
51
|
+
for path in _walk(root):
|
|
52
|
+
try:
|
|
53
|
+
text = path.read_text(encoding="utf-8", errors="strict")
|
|
54
|
+
except (UnicodeDecodeError, OSError):
|
|
55
|
+
continue # binary or unreadable
|
|
56
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
57
|
+
line = raw.strip()
|
|
58
|
+
if not line:
|
|
59
|
+
continue
|
|
60
|
+
seen: set[str] = set() # dedup per provider per line
|
|
61
|
+
for provider, pat in patterns:
|
|
62
|
+
if provider.id in seen:
|
|
63
|
+
continue
|
|
64
|
+
m = pat.regex.search(raw)
|
|
65
|
+
if m:
|
|
66
|
+
seen.add(provider.id)
|
|
67
|
+
findings.append(Finding(
|
|
68
|
+
path=str(path), line=lineno, provider=provider.id,
|
|
69
|
+
provider_name=provider.name, kind=pat.kind,
|
|
70
|
+
snippet=line[:160],
|
|
71
|
+
))
|
|
72
|
+
return findings
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _walk(root: Path):
|
|
76
|
+
for path in root.rglob("*"):
|
|
77
|
+
if not path.is_file():
|
|
78
|
+
continue
|
|
79
|
+
if any(part in _SKIP_DIRS for part in path.parts):
|
|
80
|
+
continue
|
|
81
|
+
if path.suffix.lower() in _SKIP_EXT or path.name.endswith(".min.js"):
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
if path.stat().st_size > _MAX_BYTES:
|
|
85
|
+
continue
|
|
86
|
+
except OSError:
|
|
87
|
+
continue
|
|
88
|
+
yield path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def call_sites(findings: list[Finding]) -> list[Finding]:
|
|
92
|
+
"""Findings that are actual call sites (endpoint or call method), highest signal."""
|
|
93
|
+
return [f for f in findings if f.kind in (ENDPOINT, CALL)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def providers_found(findings: list[Finding]) -> list[str]:
|
|
97
|
+
"""Provider ids present, ordered by first appearance."""
|
|
98
|
+
out: list[str] = []
|
|
99
|
+
for f in findings:
|
|
100
|
+
if f.provider not in out:
|
|
101
|
+
out.append(f.provider)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def routing(findings: list[Finding], proxy: str = "http://localhost:4242") -> dict:
|
|
106
|
+
"""
|
|
107
|
+
One-click routing plan. OpenAI- and Anthropic-SDK calls redirect with a
|
|
108
|
+
single env var each (the proxy serves both). OpenAI-compatible providers
|
|
109
|
+
(deepseek, groq, …) ride the same OPENAI_BASE_URL when called through the
|
|
110
|
+
OpenAI SDK. Everyone else needs a manual base_url change.
|
|
111
|
+
"""
|
|
112
|
+
env: dict[str, str] = {}
|
|
113
|
+
auto: list[str] = []
|
|
114
|
+
manual: list[str] = []
|
|
115
|
+
for pid in providers_found(findings):
|
|
116
|
+
spec = PROVIDERS_BY_ID[pid]
|
|
117
|
+
if pid == "anthropic":
|
|
118
|
+
env["ANTHROPIC_BASE_URL"] = proxy
|
|
119
|
+
auto.append(pid)
|
|
120
|
+
elif pid == "openai" or spec.openai_compatible:
|
|
121
|
+
env["OPENAI_BASE_URL"] = f"{proxy}/openai"
|
|
122
|
+
auto.append(pid)
|
|
123
|
+
else:
|
|
124
|
+
manual.append(pid) # google, cohere, bedrock, replicate, hf, langchain, azure
|
|
125
|
+
return {"env": env, "auto": auto, "manual": manual, "proxy": proxy}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _route_target(spec, proxy: str) -> str | None:
|
|
129
|
+
"""Proxy base URL a hardcoded site should point at, or None if not auto-routable."""
|
|
130
|
+
if spec.id == "anthropic":
|
|
131
|
+
return proxy # SDK appends /v1/messages
|
|
132
|
+
if spec.id == "openai" or spec.openai_compatible:
|
|
133
|
+
return f"{proxy}/openai" # SDK appends /chat/completions
|
|
134
|
+
return None # google/cohere/bedrock/hf/… → manual
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def hardcoded_sites(findings: list[Finding]) -> list[Finding]:
|
|
138
|
+
"""
|
|
139
|
+
ENDPOINT findings for auto-routable providers = literal provider URLs in
|
|
140
|
+
source (e.g. base_url="https://api.openai.com/v1"). These bypass the env-var
|
|
141
|
+
redirect, so they're what `--auto` rewrites (or we flag for a manual edit).
|
|
142
|
+
"""
|
|
143
|
+
return [f for f in findings
|
|
144
|
+
if f.kind == ENDPOINT and _route_target(PROVIDERS_BY_ID[f.provider], "") is not None]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def apply_autofix(findings: list[Finding], proxy: str = "http://localhost:4242") -> list[tuple[str, int, str]]:
|
|
148
|
+
"""
|
|
149
|
+
Rewrite each hardcoded provider URL to the Brevitas proxy, in place.
|
|
150
|
+
Only touches the exact flagged line and only the `https://<host>…` literal on
|
|
151
|
+
it — never comments elsewhere. Returns (path, line, new_line) for each edit.
|
|
152
|
+
Skips .md so docs aren't rewritten.
|
|
153
|
+
"""
|
|
154
|
+
edits: list[tuple[str, int, str]] = []
|
|
155
|
+
by_file: dict[str, list[Finding]] = {}
|
|
156
|
+
for f in hardcoded_sites(findings):
|
|
157
|
+
by_file.setdefault(f.path, []).append(f)
|
|
158
|
+
|
|
159
|
+
for path, fs in by_file.items():
|
|
160
|
+
p = Path(path)
|
|
161
|
+
if p.suffix.lower() == ".md":
|
|
162
|
+
continue
|
|
163
|
+
lines = p.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
164
|
+
changed = False
|
|
165
|
+
for f in fs:
|
|
166
|
+
spec = PROVIDERS_BY_ID[f.provider]
|
|
167
|
+
target = _route_target(spec, proxy)
|
|
168
|
+
endpoint_pat = next((pt for pt in spec.patterns if pt.kind == ENDPOINT), None)
|
|
169
|
+
if target is None or endpoint_pat is None or f.line > len(lines):
|
|
170
|
+
continue
|
|
171
|
+
url_re = re.compile(r"https?://" + endpoint_pat.regex.pattern + r"""[^\s'"`)]*""",
|
|
172
|
+
re.IGNORECASE)
|
|
173
|
+
before = lines[f.line - 1]
|
|
174
|
+
after = url_re.sub(target, before)
|
|
175
|
+
if after != before:
|
|
176
|
+
lines[f.line - 1] = after
|
|
177
|
+
edits.append((path, f.line, after.strip()))
|
|
178
|
+
changed = True
|
|
179
|
+
if changed:
|
|
180
|
+
p.write_text("".join(lines), encoding="utf-8")
|
|
181
|
+
return edits
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── self-check ────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
def _selfcheck() -> None:
|
|
187
|
+
import tempfile
|
|
188
|
+
fixtures = {
|
|
189
|
+
"app.py": "resp = client.chat.completions.create(model='gpt-4o', messages=m)\n",
|
|
190
|
+
"bot.ts": "const r = await anthropic.messages.create({ model: 'claude-3-5' })\n",
|
|
191
|
+
"main.go": 'req, _ := http.NewRequest("POST", "https://api.deepseek.com/v1/chat/completions", body)\n',
|
|
192
|
+
"svc.rb": 'uri = URI("https://generativelanguage.googleapis.com/v1/models")\n',
|
|
193
|
+
"hard.py": 'client = OpenAI(base_url="https://api.openai.com/v1", api_key=k)\n',
|
|
194
|
+
"readme.md": "just prose, no api calls here\n",
|
|
195
|
+
}
|
|
196
|
+
with tempfile.TemporaryDirectory() as d:
|
|
197
|
+
for name, body in fixtures.items():
|
|
198
|
+
(Path(d) / name).write_text(body)
|
|
199
|
+
f = scan(d)
|
|
200
|
+
found = providers_found(f)
|
|
201
|
+
assert "openai" in found, found
|
|
202
|
+
assert "anthropic" in found, found
|
|
203
|
+
assert "deepseek" in found, found # Go, host-only match
|
|
204
|
+
assert "google_gemini" in found, found # Ruby, host-only match
|
|
205
|
+
plan = routing(f)
|
|
206
|
+
assert plan["env"]["OPENAI_BASE_URL"].endswith("/openai"), plan
|
|
207
|
+
assert plan["env"]["ANTHROPIC_BASE_URL"] == "http://localhost:4242", plan
|
|
208
|
+
assert "google_gemini" in plan["manual"], plan # no env redirect for Gemini
|
|
209
|
+
|
|
210
|
+
# hardcoded URL detection + --auto rewrite
|
|
211
|
+
assert any(h.path.endswith("hard.py") for h in hardcoded_sites(f)), "missed hardcoded"
|
|
212
|
+
edits = apply_autofix(f)
|
|
213
|
+
hardpy = (Path(d) / "hard.py").read_text()
|
|
214
|
+
assert "api.openai.com" not in hardpy, hardpy # literal rewritten
|
|
215
|
+
assert "http://localhost:4242/openai" in hardpy, hardpy # → proxy
|
|
216
|
+
assert edits, edits
|
|
217
|
+
print("ok: detected", found, "| autofixed", len(edits))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
_selfcheck()
|
agentmap/signatures.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Polyglot signature registry for detecting LLM / AI API calls in source code.
|
|
3
|
+
|
|
4
|
+
The scanner is deliberately *not* built on per-language AST parsing. Instead it
|
|
5
|
+
matches on provider-identifying signals that appear in the source of **any**
|
|
6
|
+
language:
|
|
7
|
+
|
|
8
|
+
1. HTTP endpoint hosts — `api.openai.com`, `api.anthropic.com`, … These catch
|
|
9
|
+
calls made from Go, Rust, PHP, Ruby, Java, shell/curl,
|
|
10
|
+
etc., not just the official Python/JS SDKs.
|
|
11
|
+
2. SDK import / package — `import openai`, `@anthropic-ai/sdk`, `litellm`, …
|
|
12
|
+
3. Call-method signatures — `.chat.completions.create`, `.messages.create`,
|
|
13
|
+
`generateContent(`, `invoke_model(`, …
|
|
14
|
+
4. Model-id literals — `gpt-4o`, `claude-…`, `gemini-…` (supporting evidence,
|
|
15
|
+
also used to estimate cost).
|
|
16
|
+
|
|
17
|
+
Each match carries a `kind` and a `confidence`. A file/line is *flagged as a call
|
|
18
|
+
site* when it has at least one high-confidence signal (endpoint or call method).
|
|
19
|
+
Imports and model IDs are corroborating signals that raise confidence and help
|
|
20
|
+
routing/estimation.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
HIGH = "high"
|
|
29
|
+
MEDIUM = "medium"
|
|
30
|
+
LOW = "low"
|
|
31
|
+
|
|
32
|
+
# Signal kinds
|
|
33
|
+
ENDPOINT = "endpoint" # a provider HTTP host — language-agnostic, strong
|
|
34
|
+
CALL = "call" # an SDK call method — strong
|
|
35
|
+
IMPORT = "import" # an SDK import / package reference — medium
|
|
36
|
+
MODEL = "model" # a model-id literal — supporting
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Pattern:
|
|
41
|
+
"""A single compiled signature pattern."""
|
|
42
|
+
regex: re.Pattern
|
|
43
|
+
kind: str
|
|
44
|
+
confidence: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ProviderSpec:
|
|
49
|
+
"""Everything the scanner + installer need to know about one provider."""
|
|
50
|
+
id: str
|
|
51
|
+
name: str
|
|
52
|
+
patterns: tuple[Pattern, ...]
|
|
53
|
+
# OpenAI-compatible providers can be routed by pointing the OpenAI SDK's
|
|
54
|
+
# base URL at the Brevitas proxy. `env_base_url` is the env var the official
|
|
55
|
+
# SDK honors (if any); `honors_env` means it can be routed with zero code
|
|
56
|
+
# changes. `openai_compatible` means the /openai proxy path applies.
|
|
57
|
+
env_base_url: str | None = None
|
|
58
|
+
honors_env: bool = False
|
|
59
|
+
openai_compatible: bool = False
|
|
60
|
+
# Approx blended $/1M tokens (input+output), for a *rough* spend estimate.
|
|
61
|
+
# These are order-of-magnitude anchors — override with --price if it matters.
|
|
62
|
+
approx_price_per_mtok: float = 5.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _p(pattern: str, kind: str, confidence: str, *, flags: int = re.IGNORECASE) -> Pattern:
|
|
66
|
+
return Pattern(re.compile(pattern, flags), kind, confidence)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── Provider registry ─────────────────────────────────────────────────────────
|
|
70
|
+
# Ordered roughly by specificity; the scanner tries every provider on every line.
|
|
71
|
+
|
|
72
|
+
PROVIDERS: tuple[ProviderSpec, ...] = (
|
|
73
|
+
ProviderSpec(
|
|
74
|
+
id="openai",
|
|
75
|
+
name="OpenAI",
|
|
76
|
+
env_base_url="OPENAI_BASE_URL",
|
|
77
|
+
honors_env=True,
|
|
78
|
+
openai_compatible=True,
|
|
79
|
+
approx_price_per_mtok=5.0,
|
|
80
|
+
patterns=(
|
|
81
|
+
_p(r"api\.openai\.com", ENDPOINT, HIGH),
|
|
82
|
+
_p(r"\.chat\.completions\.create\b", CALL, HIGH),
|
|
83
|
+
_p(r"\.responses\.create\b", CALL, HIGH),
|
|
84
|
+
_p(r"\.completions\.create\b", CALL, MEDIUM),
|
|
85
|
+
_p(r"\.embeddings\.create\b", CALL, MEDIUM),
|
|
86
|
+
_p(r"(?:^|[^.\w])import\s+openai\b|from\s+openai\s+import|require\(['\"]openai['\"]\)|@ai-sdk/openai", IMPORT, MEDIUM),
|
|
87
|
+
_p(r"\b(?:gpt-4o|gpt-4\.1|gpt-4|gpt-3\.5|o1|o3|o4|text-embedding-3)\b", MODEL, LOW),
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
ProviderSpec(
|
|
91
|
+
id="azure_openai",
|
|
92
|
+
name="Azure OpenAI",
|
|
93
|
+
env_base_url="AZURE_OPENAI_ENDPOINT",
|
|
94
|
+
honors_env=False,
|
|
95
|
+
openai_compatible=True,
|
|
96
|
+
approx_price_per_mtok=5.0,
|
|
97
|
+
patterns=(
|
|
98
|
+
_p(r"[a-z0-9.-]*\.openai\.azure\.com", ENDPOINT, HIGH),
|
|
99
|
+
_p(r"AzureOpenAI\b", CALL, HIGH),
|
|
100
|
+
_p(r"azure[_-]?openai", IMPORT, MEDIUM),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
ProviderSpec(
|
|
104
|
+
id="anthropic",
|
|
105
|
+
name="Anthropic",
|
|
106
|
+
env_base_url="ANTHROPIC_BASE_URL",
|
|
107
|
+
honors_env=True,
|
|
108
|
+
openai_compatible=False,
|
|
109
|
+
approx_price_per_mtok=9.0,
|
|
110
|
+
patterns=(
|
|
111
|
+
_p(r"api\.anthropic\.com", ENDPOINT, HIGH),
|
|
112
|
+
_p(r"\.messages\.create\b", CALL, HIGH),
|
|
113
|
+
_p(r"\.messages\.stream\b", CALL, HIGH),
|
|
114
|
+
_p(r"from\s+anthropic\s+import|(?:^|[^.\w])import\s+anthropic\b|@anthropic-ai/sdk|require\(['\"]@anthropic-ai/sdk['\"]\)", IMPORT, MEDIUM),
|
|
115
|
+
_p(r"\bclaude-[a-z0-9.\-]+\b", MODEL, LOW),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
ProviderSpec(
|
|
119
|
+
id="google_gemini",
|
|
120
|
+
name="Google Gemini / Vertex",
|
|
121
|
+
env_base_url=None,
|
|
122
|
+
honors_env=False,
|
|
123
|
+
openai_compatible=False,
|
|
124
|
+
approx_price_per_mtok=3.0,
|
|
125
|
+
patterns=(
|
|
126
|
+
_p(r"generativelanguage\.googleapis\.com", ENDPOINT, HIGH),
|
|
127
|
+
_p(r"[a-z0-9-]*aiplatform\.googleapis\.com", ENDPOINT, HIGH),
|
|
128
|
+
_p(r"\.generate_content\b|\.generateContent\b", CALL, HIGH),
|
|
129
|
+
_p(r"google\.generativeai|google\.genai|from\s+google\s+import\s+genai|@google/generative-ai|google-genai|vertexai", IMPORT, MEDIUM),
|
|
130
|
+
_p(r"\bgemini-[a-z0-9.\-]+\b", MODEL, LOW),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
ProviderSpec(
|
|
134
|
+
id="deepseek",
|
|
135
|
+
name="DeepSeek",
|
|
136
|
+
env_base_url=None,
|
|
137
|
+
honors_env=False,
|
|
138
|
+
openai_compatible=True,
|
|
139
|
+
approx_price_per_mtok=0.5,
|
|
140
|
+
patterns=(
|
|
141
|
+
_p(r"api\.deepseek\.com", ENDPOINT, HIGH),
|
|
142
|
+
_p(r"\bdeepseek-(?:chat|reasoner|[a-z0-9.\-]+)\b", MODEL, LOW),
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
ProviderSpec(
|
|
146
|
+
id="groq",
|
|
147
|
+
name="Groq",
|
|
148
|
+
env_base_url=None,
|
|
149
|
+
honors_env=False,
|
|
150
|
+
openai_compatible=True,
|
|
151
|
+
approx_price_per_mtok=0.3,
|
|
152
|
+
patterns=(
|
|
153
|
+
_p(r"api\.groq\.com", ENDPOINT, HIGH),
|
|
154
|
+
_p(r"from\s+groq\s+import|(?:^|[^.\w])import\s+groq\b|require\(['\"]groq-sdk['\"]\)", IMPORT, MEDIUM),
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
ProviderSpec(
|
|
158
|
+
id="xai",
|
|
159
|
+
name="xAI Grok",
|
|
160
|
+
env_base_url=None,
|
|
161
|
+
honors_env=False,
|
|
162
|
+
openai_compatible=True,
|
|
163
|
+
approx_price_per_mtok=5.0,
|
|
164
|
+
patterns=(
|
|
165
|
+
_p(r"api\.x\.ai", ENDPOINT, HIGH),
|
|
166
|
+
_p(r"\bgrok-[a-z0-9.\-]+\b", MODEL, LOW),
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
ProviderSpec(
|
|
170
|
+
id="mistral",
|
|
171
|
+
name="Mistral",
|
|
172
|
+
env_base_url=None,
|
|
173
|
+
honors_env=False,
|
|
174
|
+
openai_compatible=True,
|
|
175
|
+
approx_price_per_mtok=2.0,
|
|
176
|
+
patterns=(
|
|
177
|
+
_p(r"api\.mistral\.ai", ENDPOINT, HIGH),
|
|
178
|
+
_p(r"from\s+mistralai|(?:^|[^.\w])import\s+mistralai\b|@mistralai/mistralai", IMPORT, MEDIUM),
|
|
179
|
+
_p(r"\b(?:mistral-[a-z0-9.\-]+|mixtral-[a-z0-9x.\-]+|codestral-[a-z0-9.\-]+)\b", MODEL, LOW),
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
ProviderSpec(
|
|
183
|
+
id="cohere",
|
|
184
|
+
name="Cohere",
|
|
185
|
+
env_base_url=None,
|
|
186
|
+
honors_env=False,
|
|
187
|
+
openai_compatible=False,
|
|
188
|
+
approx_price_per_mtok=2.0,
|
|
189
|
+
patterns=(
|
|
190
|
+
_p(r"api\.cohere\.(?:ai|com)", ENDPOINT, HIGH),
|
|
191
|
+
_p(r"from\s+cohere\s+import|(?:^|[^.\w])import\s+cohere\b|require\(['\"]cohere-ai['\"]\)", IMPORT, MEDIUM),
|
|
192
|
+
_p(r"\bcommand-[a-z0-9.\-]+\b", MODEL, LOW),
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
ProviderSpec(
|
|
196
|
+
id="litellm",
|
|
197
|
+
name="LiteLLM (router)",
|
|
198
|
+
env_base_url=None,
|
|
199
|
+
honors_env=False,
|
|
200
|
+
openai_compatible=True,
|
|
201
|
+
approx_price_per_mtok=5.0,
|
|
202
|
+
patterns=(
|
|
203
|
+
_p(r"(?:^|[^.\w])import\s+litellm\b|from\s+litellm\s+import", IMPORT, HIGH),
|
|
204
|
+
_p(r"litellm\.(?:completion|acompletion|embedding)\b", CALL, HIGH),
|
|
205
|
+
),
|
|
206
|
+
),
|
|
207
|
+
ProviderSpec(
|
|
208
|
+
id="langchain",
|
|
209
|
+
name="LangChain",
|
|
210
|
+
env_base_url=None,
|
|
211
|
+
honors_env=False,
|
|
212
|
+
openai_compatible=False,
|
|
213
|
+
approx_price_per_mtok=5.0,
|
|
214
|
+
patterns=(
|
|
215
|
+
_p(r"from\s+langchain|(?:^|[^.\w])import\s+langchain\b|@langchain/", IMPORT, MEDIUM),
|
|
216
|
+
_p(r"\bChat(?:OpenAI|Anthropic|Google[A-Za-z]*|Mistral[A-Za-z]*|Groq|Cohere)\b", CALL, MEDIUM),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
ProviderSpec(
|
|
220
|
+
id="bedrock",
|
|
221
|
+
name="AWS Bedrock",
|
|
222
|
+
env_base_url=None,
|
|
223
|
+
honors_env=False,
|
|
224
|
+
openai_compatible=False,
|
|
225
|
+
approx_price_per_mtok=8.0,
|
|
226
|
+
patterns=(
|
|
227
|
+
_p(r"bedrock[a-z-]*\.[a-z0-9-]+\.amazonaws\.com", ENDPOINT, HIGH),
|
|
228
|
+
_p(r"\binvoke_model\b|\bInvokeModel\b|\.converse\(", CALL, MEDIUM),
|
|
229
|
+
_p(r"['\"]bedrock-runtime['\"]|['\"]bedrock['\"]", IMPORT, MEDIUM),
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
ProviderSpec(
|
|
233
|
+
id="together",
|
|
234
|
+
name="Together AI",
|
|
235
|
+
env_base_url=None,
|
|
236
|
+
honors_env=False,
|
|
237
|
+
openai_compatible=True,
|
|
238
|
+
approx_price_per_mtok=0.9,
|
|
239
|
+
patterns=(
|
|
240
|
+
_p(r"api\.together\.(?:xyz|ai)", ENDPOINT, HIGH),
|
|
241
|
+
_p(r"from\s+together\s+import|(?:^|[^.\w])import\s+together\b", IMPORT, MEDIUM),
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
ProviderSpec(
|
|
245
|
+
id="fireworks",
|
|
246
|
+
name="Fireworks AI",
|
|
247
|
+
env_base_url=None,
|
|
248
|
+
honors_env=False,
|
|
249
|
+
openai_compatible=True,
|
|
250
|
+
approx_price_per_mtok=0.9,
|
|
251
|
+
patterns=(
|
|
252
|
+
_p(r"api\.fireworks\.ai", ENDPOINT, HIGH),
|
|
253
|
+
_p(r"from\s+fireworks|(?:^|[^.\w])import\s+fireworks\b", IMPORT, MEDIUM),
|
|
254
|
+
),
|
|
255
|
+
),
|
|
256
|
+
ProviderSpec(
|
|
257
|
+
id="openrouter",
|
|
258
|
+
name="OpenRouter",
|
|
259
|
+
env_base_url=None,
|
|
260
|
+
honors_env=False,
|
|
261
|
+
openai_compatible=True,
|
|
262
|
+
approx_price_per_mtok=5.0,
|
|
263
|
+
patterns=(
|
|
264
|
+
_p(r"openrouter\.ai/api", ENDPOINT, HIGH),
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
ProviderSpec(
|
|
268
|
+
id="perplexity",
|
|
269
|
+
name="Perplexity",
|
|
270
|
+
env_base_url=None,
|
|
271
|
+
honors_env=False,
|
|
272
|
+
openai_compatible=True,
|
|
273
|
+
approx_price_per_mtok=1.0,
|
|
274
|
+
patterns=(
|
|
275
|
+
_p(r"api\.perplexity\.ai", ENDPOINT, HIGH),
|
|
276
|
+
),
|
|
277
|
+
),
|
|
278
|
+
ProviderSpec(
|
|
279
|
+
id="replicate",
|
|
280
|
+
name="Replicate",
|
|
281
|
+
env_base_url=None,
|
|
282
|
+
honors_env=False,
|
|
283
|
+
openai_compatible=False,
|
|
284
|
+
approx_price_per_mtok=3.0,
|
|
285
|
+
patterns=(
|
|
286
|
+
_p(r"api\.replicate\.com", ENDPOINT, HIGH),
|
|
287
|
+
_p(r"from\s+replicate|(?:^|[^.\w])import\s+replicate\b|require\(['\"]replicate['\"]\)", IMPORT, MEDIUM),
|
|
288
|
+
),
|
|
289
|
+
),
|
|
290
|
+
ProviderSpec(
|
|
291
|
+
id="huggingface",
|
|
292
|
+
name="Hugging Face Inference",
|
|
293
|
+
env_base_url=None,
|
|
294
|
+
honors_env=False,
|
|
295
|
+
openai_compatible=False,
|
|
296
|
+
approx_price_per_mtok=1.0,
|
|
297
|
+
patterns=(
|
|
298
|
+
_p(r"api-inference\.huggingface\.co|router\.huggingface\.co", ENDPOINT, HIGH),
|
|
299
|
+
_p(r"InferenceClient\b|from\s+huggingface_hub", IMPORT, MEDIUM),
|
|
300
|
+
),
|
|
301
|
+
),
|
|
302
|
+
ProviderSpec(
|
|
303
|
+
id="ollama",
|
|
304
|
+
name="Ollama (local)",
|
|
305
|
+
env_base_url="OLLAMA_HOST",
|
|
306
|
+
honors_env=False,
|
|
307
|
+
openai_compatible=True,
|
|
308
|
+
approx_price_per_mtok=0.0,
|
|
309
|
+
patterns=(
|
|
310
|
+
_p(r"localhost:11434|127\.0\.0\.1:11434|/api/(?:generate|chat)\b", ENDPOINT, MEDIUM),
|
|
311
|
+
_p(r"from\s+ollama|(?:^|[^.\w])import\s+ollama\b|require\(['\"]ollama['\"]\)", IMPORT, MEDIUM),
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
PROVIDERS_BY_ID: dict[str, ProviderSpec] = {p.id: p for p in PROVIDERS}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def iter_patterns():
|
|
321
|
+
"""Yield (provider, pattern) for every pattern in the registry."""
|
|
322
|
+
for provider in PROVIDERS:
|
|
323
|
+
for pattern in provider.patterns:
|
|
324
|
+
yield provider, pattern
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentmap-scan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Map every AI API call in a codebase — offline, no LLM, no keys. Opens a webpage of your providers, models, and agents.
|
|
5
|
+
Author: Brevitas
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/brevitas-ai/agentmap
|
|
8
|
+
Keywords: llm,openai,anthropic,scanner,agents,observability
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: click>=8.1.0
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# agentmap
|
|
16
|
+
|
|
17
|
+
Map every AI API call in a codebase — **offline, no LLM, no API keys**. Run one
|
|
18
|
+
command and get a webpage showing which files call which providers, what models
|
|
19
|
+
you use, and what each agent is responsible for.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install agentmap-scan
|
|
23
|
+
cd your-project
|
|
24
|
+
agentmap
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A browser page opens with four sections:
|
|
28
|
+
|
|
29
|
+
- **Calls by file** — every AI API call site, `file:line`, provider-tagged.
|
|
30
|
+
- **Providers & models** — the distinct providers and model names in use, with counts.
|
|
31
|
+
- **Agents & responsibilities** — each agent's name and what its system prompt says it does.
|
|
32
|
+
- **Routing** — the env vars to send those calls through a gateway.
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
Pure regex over your source — it matches provider **endpoint hosts**
|
|
37
|
+
(`api.openai.com`, `api.anthropic.com`, …) and **SDK call signatures**
|
|
38
|
+
(`.chat.completions.create`, `.messages.create`, …), so it finds calls in
|
|
39
|
+
Python, JS/TS, Go, Rust, Ruby, PHP, Java, shell/curl — not just the official
|
|
40
|
+
SDKs. Agent responsibilities come straight from the system prompts in your code,
|
|
41
|
+
trimmed to one line. Nothing leaves your machine.
|
|
42
|
+
|
|
43
|
+
Detects 20 providers: OpenAI, Anthropic, Google Gemini/Vertex, Azure OpenAI,
|
|
44
|
+
DeepSeek, Groq, xAI, Mistral, Cohere, Bedrock, Together, Fireworks, OpenRouter,
|
|
45
|
+
Perplexity, Replicate, Hugging Face, Ollama, LiteLLM, LangChain.
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
agentmap [PATH] # scan + open the report (default: current dir)
|
|
51
|
+
agentmap --no-open PATH # scan, write the HTML, don't launch a browser
|
|
52
|
+
agentmap install PATH # write .env.agentmap routing env vars
|
|
53
|
+
agentmap install PATH --auto # also rewrite hardcoded provider base_urls in place
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Routing → save money
|
|
57
|
+
|
|
58
|
+
`agentmap install` writes the env vars that point your OpenAI/Anthropic-compatible
|
|
59
|
+
calls at a gateway. Point them at [**Brevitas**](https://github.com/brevitas-ai)
|
|
60
|
+
to compress context losslessly and cut your token bill:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
agentmap install . --target http://localhost:4242
|
|
64
|
+
source .env.agentmap
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Limitations (honest)
|
|
68
|
+
|
|
69
|
+
Agent detection is a heuristic. It reliably catches raw OpenAI/Anthropic message
|
|
70
|
+
dicts, `system=`/`system_prompt=`/`instructions=` assignments (incl. `X_system`,
|
|
71
|
+
`X_PROMPT` names), CrewAI `Agent(role=…)`, and `.agent("name")` calls. It does
|
|
72
|
+
**not** yet understand graph frameworks (LangGraph node graphs, AutoGen). PRs
|
|
73
|
+
adding per-framework matchers welcome.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agentmap/__init__.py,sha256=0zLemHaGtDpctFtaMrEmjhS9q5jmeVZ_WV0n3n6dUjg,453
|
|
2
|
+
agentmap/agents.py,sha256=7DMI-9SzA52GTOsYdP7WmRCv8yxfkvkge6Vzxt7onjo,7011
|
|
3
|
+
agentmap/cli.py,sha256=EutexJS1iJBySbj2Wi25GjUNQMX3ttk1884s5N6YsLg,4135
|
|
4
|
+
agentmap/report.py,sha256=XjMuNvMKaz7Q1MSOSBSWH8g03X6HziZt4ZDIJQTlCf0,7610
|
|
5
|
+
agentmap/scanner.py,sha256=IgzMNhLIVewVZVfTbrPh1Pyb_x21YUlf3VLPRLUQV-8,9142
|
|
6
|
+
agentmap/signatures.py,sha256=Rf2ZC3Lr8gcp7l32NnKIBXv1q1oNisfSThDcq2uXehE,11552
|
|
7
|
+
agentmap_scan-0.1.0.dist-info/licenses/LICENSE,sha256=O-52TMTEqIm01Mx06mqRnAabhYPyowUuidSeMv9OLPY,1065
|
|
8
|
+
agentmap_scan-0.1.0.dist-info/METADATA,sha256=K0pyVp3Xwclty5GcNSgKq--3SDvUl8HpNlrd7B-y6SI,2875
|
|
9
|
+
agentmap_scan-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
agentmap_scan-0.1.0.dist-info/entry_points.txt,sha256=8HUXamWqkeDIcnWUAtXXfkg5YOaJOJCdFXU2IW-YTuc,47
|
|
11
|
+
agentmap_scan-0.1.0.dist-info/top_level.txt,sha256=-StwAqR4ZaGiBDM6U9khs1t_9jPgTdUBhFJ4YSbz2N4,9
|
|
12
|
+
agentmap_scan-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brevitas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentmap
|