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 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 &amp; models</h2>{prov_rows}
147
+ <div style="height:10px"></div>{model_rows}
148
+
149
+ <h2>Agents &amp; 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentmap = agentmap.cli:main
@@ -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