proxyagent 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.
proxyagent/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """proxyagent — run any agent (Claude, Codex, custom) on any machine, with no API
2
+ key on the machine. A secure, self-hosted proxy for models *and* tools.
3
+
4
+ # on the proxy host (holds the real keys):
5
+ import proxyagent
6
+ proxyagent.serve() # or: $ proxyagent serve
7
+
8
+ # on any remote machine (holds only a throwaway token):
9
+ proxyagent.run("claude-code", goal="build the app",
10
+ proxy="https://proxy.you.com", token="pa_…")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ from .harness import run # noqa: F401 (the headline SDK call)
18
+
19
+ __version__ = "0.1.0"
20
+ __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
+
22
+
23
+ def create_app(config=None):
24
+ """The ASGI app, for embedding behind your own server."""
25
+ from .server import create_app as _c
26
+ return _c(config)
27
+
28
+
29
+ def serve(host: str = "127.0.0.1", port: int = 8080, config=None):
30
+ """Run the proxy + dashboard."""
31
+ import uvicorn
32
+ from .config import Config
33
+ cfg = config or Config.load()
34
+ uvicorn.run(create_app(cfg), host=host, port=port, log_level="warning")
35
+
36
+
37
+ def Config(): # noqa: N802 — convenience re-export
38
+ from .config import Config as _C
39
+ return _C.load()
40
+
41
+
42
+ class Admin:
43
+ """Programmatic admin client — mint/list/revoke tokens against a running proxy."""
44
+
45
+ def __init__(self, proxy: str, admin_token: str):
46
+ import httpx
47
+ self._c = httpx.Client(base_url=proxy.rstrip("/"),
48
+ headers={"x-admin-token": admin_token}, timeout=30)
49
+
50
+ def mint(self, label: str = "machine", scope: Optional[list] = None,
51
+ ttl_seconds: Optional[int] = None, rate_limit: int = 0) -> str:
52
+ r = self._c.post("/admin/tokens", json={
53
+ "label": label, "scope": scope or ["*"], "ttl_seconds": ttl_seconds,
54
+ "rate_limit": rate_limit})
55
+ r.raise_for_status()
56
+ return r.json()["token"]
57
+
58
+ def tokens(self) -> list:
59
+ return self._c.get("/admin/tokens").json()["tokens"]
60
+
61
+ def revoke(self, token_id: str) -> None:
62
+ self._c.delete(f"/admin/tokens/{token_id}").raise_for_status()
63
+
64
+ def logs(self, limit: int = 100) -> list:
65
+ return self._c.get("/admin/logs", params={"limit": limit}).json()["logs"]
proxyagent/cli.py ADDED
@@ -0,0 +1,145 @@
1
+ """proxyagent CLI — serve the proxy, mint tokens, run harnesses, watch usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ import httpx
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ app = typer.Typer(help="Run any agent on any machine — with no API key on the machine.", no_args_is_help=True)
15
+ console = Console()
16
+ err = Console(stderr=True)
17
+
18
+
19
+ def _admin_client(proxy: str, admin: Optional[str]) -> httpx.Client:
20
+ admin = admin or os.environ.get("PROXYAGENT_ADMIN_TOKEN")
21
+ if not admin:
22
+ err.print("[red]Need an admin token[/red] (--admin or PROXYAGENT_ADMIN_TOKEN). "
23
+ "It's printed when you run [bold]proxyagent serve[/bold].")
24
+ raise typer.Exit(1)
25
+ return httpx.Client(base_url=proxy.rstrip("/"), headers={"x-admin-token": admin}, timeout=30)
26
+
27
+
28
+ @app.command()
29
+ def serve(host: str = "127.0.0.1", port: int = 8080):
30
+ """Run the proxy server + dashboard."""
31
+ import uvicorn
32
+
33
+ from .config import Config
34
+ from .server import create_app
35
+
36
+ config = Config.load()
37
+ if config.admin_token_plain:
38
+ console.print(Panel.fit(
39
+ f"[green]✓ proxyagent[/green]\n\n[bold]Admin token[/bold] (shown once)\n"
40
+ f" [yellow]{config.admin_token_plain}[/yellow]\n\n"
41
+ f"[dim]Save it — you need it for the dashboard + `proxyagent token`.[/dim]",
42
+ border_style="green"))
43
+ console.print(f"[dim]Dashboard:[/dim] http://{host}:{port} "
44
+ f"[dim]providers:[/dim] {', '.join(config.configured_providers()) or 'none — set ANTHROPIC_API_KEY / OPENAI_API_KEY'}")
45
+ uvicorn.run(create_app(config), host=host, port=port, log_level="warning")
46
+
47
+
48
+ @app.command("run")
49
+ def run_harness(
50
+ harness: str = typer.Argument(..., help="claude-code | codex | <custom>"),
51
+ goal: str = typer.Option(..., "--goal", "-g", help="What the agent should do."),
52
+ proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
53
+ token: Optional[str] = typer.Option(None, "--token", help="Machine token (or PROXYAGENT_TOKEN)."),
54
+ command: Optional[str] = typer.Option(None, "--command", help="Custom harness command template."),
55
+ cwd: Optional[str] = typer.Option(None, "--cwd"),
56
+ ):
57
+ """Run a harness on THIS machine, pointed at the proxy (no real key needed here)."""
58
+ from . import harness as H
59
+
60
+ tok = token or os.environ.get("PROXYAGENT_TOKEN")
61
+ if not tok:
62
+ err.print("[red]Need a machine token[/red] (--token or PROXYAGENT_TOKEN).")
63
+ raise typer.Exit(1)
64
+ console.print(f"[dim]→ {harness} via {proxy} (no key on this machine)[/dim]")
65
+ code = H.run(harness, goal, proxy_url=proxy, token=tok, cwd=cwd, command=command)
66
+ raise typer.Exit(code)
67
+
68
+
69
+ token_app = typer.Typer(help="Mint / list / revoke machine tokens.")
70
+ app.add_typer(token_app, name="token")
71
+
72
+
73
+ @token_app.command("new")
74
+ def token_new(
75
+ label: str = typer.Argument("machine"),
76
+ scope: list[str] = typer.Option(["*"], "--scope", help="Allowed provider:model globs, e.g. anthropic:claude-*"),
77
+ ttl: Optional[int] = typer.Option(None, "--ttl", help="Seconds until expiry."),
78
+ rate: int = typer.Option(0, "--rate", help="Max requests/min (0 = unlimited)."),
79
+ proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
80
+ admin: Optional[str] = typer.Option(None, "--admin"),
81
+ ):
82
+ """Mint a machine token — give it to a remote machine; it holds no real key."""
83
+ with _admin_client(proxy, admin) as c:
84
+ r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
85
+ "ttl_seconds": ttl, "rate_limit": rate})
86
+ if r.status_code >= 400:
87
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
88
+ d = r.json()
89
+ console.print(Panel.fit(
90
+ f"[green]✓ machine token[/green] [dim]({label})[/dim]\n\n [yellow]{d['token']}[/yellow]\n\n"
91
+ f"[dim]scope: {', '.join(scope)} · shown once[/dim]", border_style="green"))
92
+
93
+
94
+ @token_app.command("ls")
95
+ def token_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
96
+ admin: Optional[str] = typer.Option(None, "--admin")):
97
+ """List machine tokens."""
98
+ with _admin_client(proxy, admin) as c:
99
+ r = c.get("/admin/tokens")
100
+ if r.status_code >= 400:
101
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
102
+ rows = r.json()["tokens"]
103
+ if not rows:
104
+ console.print("[dim]No tokens.[/dim]"); return
105
+ t = Table(title="Machine tokens")
106
+ for col in ("ID", "Label", "Token", "Scope", "Status"):
107
+ t.add_column(col)
108
+ for k in rows:
109
+ t.add_row(k["id"], k["label"], k["masked"], ", ".join(k["scope"]),
110
+ "[red]revoked[/red]" if k["revoked"] else "[green]active[/green]")
111
+ console.print(t)
112
+
113
+
114
+ @token_app.command("revoke")
115
+ def token_revoke(token_id: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
116
+ admin: Optional[str] = typer.Option(None, "--admin")):
117
+ """Revoke a token by id."""
118
+ with _admin_client(proxy, admin) as c:
119
+ r = c.delete(f"/admin/tokens/{token_id}")
120
+ if r.status_code >= 400:
121
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
122
+ console.print(f"[green]✓[/green] revoked {token_id}")
123
+
124
+
125
+ @app.command()
126
+ def logs(limit: int = 50, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
127
+ admin: Optional[str] = typer.Option(None, "--admin")):
128
+ """Recent proxied requests (audit log)."""
129
+ with _admin_client(proxy, admin) as c:
130
+ r = c.get("/admin/logs", params={"limit": limit})
131
+ if r.status_code >= 400:
132
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
133
+ rows = r.json()["logs"]
134
+ t = Table(title="Requests")
135
+ for col in ("Token", "Provider", "Model", "Status", "In", "Out", "ms"):
136
+ t.add_column(col)
137
+ for g in rows:
138
+ t.add_row(g.get("token_label") or "", g.get("provider") or "", (g.get("model") or "")[:28],
139
+ str(g.get("status") or ""), str(g.get("prompt_tokens") or "-"),
140
+ str(g.get("completion_tokens") or "-"), str(g.get("latency_ms") or ""))
141
+ console.print(t)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ app()
proxyagent/config.py ADDED
@@ -0,0 +1,82 @@
1
+ """Configuration — provider upstreams, real credentials (env only), paths, admin auth.
2
+
3
+ Real keys are read from the environment and never persisted. The proxy is the ONLY
4
+ place they live.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from .security import hash_token, new_token, ADMIN_PREFIX
14
+
15
+ HOME = Path(os.environ.get("PROXYAGENT_HOME", Path.home() / ".proxyagent"))
16
+
17
+
18
+ @dataclass
19
+ class Provider:
20
+ name: str
21
+ base_url: str # upstream API root
22
+ key_env: str # env var holding the REAL key
23
+ auth_style: str # "bearer" (OpenAI) | "x-api-key" (Anthropic)
24
+ extra_headers: dict = field(default_factory=dict)
25
+
26
+ @property
27
+ def key(self) -> str | None:
28
+ return os.environ.get(self.key_env)
29
+
30
+ def auth_headers(self) -> dict:
31
+ key = self.key
32
+ if not key:
33
+ return {}
34
+ if self.auth_style == "x-api-key":
35
+ return {"x-api-key": key, **self.extra_headers}
36
+ return {"Authorization": f"Bearer {key}", **self.extra_headers}
37
+
38
+
39
+ # Built-in upstreams. base_url overridable via env (e.g. Azure, self-hosted, gateways).
40
+ def _provider(name, default_base, key_env, style, extra=None) -> Provider:
41
+ base = os.environ.get(f"PROXYAGENT_{name.upper()}_BASE_URL", default_base)
42
+ return Provider(name, base.rstrip("/"), key_env, style, extra or {})
43
+
44
+
45
+ PROVIDERS: dict[str, Provider] = {
46
+ "anthropic": _provider(
47
+ "anthropic", "https://api.anthropic.com", "ANTHROPIC_API_KEY", "x-api-key",
48
+ {"anthropic-version": os.environ.get("ANTHROPIC_VERSION", "2023-06-01")},
49
+ ),
50
+ "openai": _provider("openai", "https://api.openai.com", "OPENAI_API_KEY", "bearer"),
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class Config:
56
+ home: Path = HOME
57
+ db_path: str = ""
58
+ admin_token_hash: str = ""
59
+ admin_token_plain: str | None = None # only set when freshly generated
60
+ request_timeout: float = 600.0
61
+
62
+ @classmethod
63
+ def load(cls) -> "Config":
64
+ HOME.mkdir(parents=True, exist_ok=True)
65
+ cfg = cls(db_path=str(HOME / "proxyagent.db"))
66
+ # Admin token: from env, or a persisted one, or freshly generated (shown once).
67
+ env_admin = os.environ.get("PROXYAGENT_ADMIN_TOKEN")
68
+ admin_file = HOME / "admin_token"
69
+ if env_admin:
70
+ cfg.admin_token_hash = hash_token(env_admin)
71
+ elif admin_file.exists():
72
+ cfg.admin_token_hash = admin_file.read_text().strip()
73
+ else:
74
+ plain = new_token(ADMIN_PREFIX)
75
+ cfg.admin_token_hash = hash_token(plain)
76
+ admin_file.write_text(cfg.admin_token_hash)
77
+ admin_file.chmod(0o600)
78
+ cfg.admin_token_plain = plain
79
+ return cfg
80
+
81
+ def configured_providers(self) -> list[str]:
82
+ return [n for n, p in PROVIDERS.items() if p.key]
proxyagent/harness.py ADDED
@@ -0,0 +1,73 @@
1
+ """Run an agent harness on a machine, pointed at the proxy — so the machine holds
2
+ only the throwaway proxy token, never a real key.
3
+
4
+ Almost every harness honours `*_BASE_URL`, so the shim is tiny: set the base URL to
5
+ the proxy, set the "api key" to the machine token, and launch the harness unmodified.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shlex
12
+ import subprocess
13
+ from dataclasses import dataclass, field
14
+ from typing import Callable
15
+
16
+
17
+ @dataclass
18
+ class Harness:
19
+ name: str
20
+ # build argv from a goal (headless / non-interactive invocation)
21
+ launch: Callable[[str], list[str]]
22
+ check: list[str] = field(default_factory=list) # how to detect it's installed
23
+ install_hint: str = ""
24
+
25
+ def env(self, proxy_url: str, token: str) -> dict:
26
+ base = proxy_url.rstrip("/")
27
+ return {
28
+ "ANTHROPIC_BASE_URL": f"{base}/anthropic",
29
+ "ANTHROPIC_API_KEY": token,
30
+ "OPENAI_BASE_URL": f"{base}/openai/v1",
31
+ "OPENAI_API_BASE": f"{base}/openai/v1",
32
+ "OPENAI_API_KEY": token,
33
+ }
34
+
35
+
36
+ HARNESSES: dict[str, Harness] = {
37
+ "claude-code": Harness(
38
+ name="claude-code",
39
+ launch=lambda goal: ["claude", "-p", goal, "--permission-mode", "bypassPermissions"],
40
+ check=["claude", "--version"],
41
+ install_hint="npm i -g @anthropic-ai/claude-code",
42
+ ),
43
+ "codex": Harness(
44
+ name="codex",
45
+ launch=lambda goal: ["codex", "exec", goal],
46
+ check=["codex", "--version"],
47
+ install_hint="npm i -g @openai/codex",
48
+ ),
49
+ }
50
+
51
+
52
+ def register_custom(name: str, command: str) -> Harness:
53
+ """A custom harness: `command` is a template; {goal} is substituted."""
54
+ def _launch(goal: str) -> list[str]:
55
+ return shlex.split(command.replace("{goal}", goal)) if "{goal}" in command \
56
+ else shlex.split(command) + [goal]
57
+ h = Harness(name=name, launch=_launch)
58
+ HARNESSES[name] = h
59
+ return h
60
+
61
+
62
+ def run(harness: str, goal: str, *, proxy_url: str, token: str,
63
+ cwd: str | None = None, extra_env: dict | None = None,
64
+ command: str | None = None) -> int:
65
+ """Run a harness against a goal, pointed at the proxy. Streams to stdout.
66
+ Returns the exit code."""
67
+ h = HARNESSES.get(harness) or (register_custom(harness, command) if command else None)
68
+ if h is None:
69
+ raise ValueError(f"unknown harness {harness!r} (pass command=... for a custom one)")
70
+ env = {**os.environ, **h.env(proxy_url, token), **(extra_env or {})}
71
+ argv = h.launch(goal)
72
+ proc = subprocess.run(argv, cwd=cwd, env=env)
73
+ return proc.returncode
@@ -0,0 +1,98 @@
1
+ """Upstream forwarding + scope enforcement.
2
+
3
+ The proxy receives a request authed by a machine token, swaps in the REAL provider
4
+ key, and forwards it upstream — streaming straight through. The machine never sees
5
+ the real key; the proxy logs usage for every call.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import json
12
+
13
+ import httpx
14
+
15
+ from .config import Config, PROVIDERS
16
+ from .store import Store, now_ms
17
+
18
+ # Map our public path → (provider, upstream path).
19
+ ROUTES = {
20
+ "anthropic": ("anthropic", "/v1/messages"),
21
+ "openai": ("openai", "/v1/chat/completions"),
22
+ }
23
+
24
+
25
+ def scope_allows(scope: list[str], provider: str, model: str) -> bool:
26
+ """A scope entry is a glob over 'provider:model', e.g. 'anthropic:claude-*', or '*'."""
27
+ target = f"{provider}:{model or '*'}"
28
+ for entry in scope:
29
+ if entry == "*" or fnmatch.fnmatch(target, entry) or fnmatch.fnmatch(provider, entry):
30
+ return True
31
+ return False
32
+
33
+
34
+ def _extract_usage(provider: str, payload: dict) -> tuple[int | None, int | None]:
35
+ u = payload.get("usage") or {}
36
+ if provider == "anthropic":
37
+ return u.get("input_tokens"), u.get("output_tokens")
38
+ return u.get("prompt_tokens"), u.get("completion_tokens")
39
+
40
+
41
+ async def forward(
42
+ config: Config, provider_name: str, upstream_path: str, body: dict,
43
+ *, streaming: bool, token: dict, store: Store, tools_used: list[str] | None = None,
44
+ ):
45
+ """Forward a request upstream. Returns (status, headers, body_iter_or_dict, log_after)."""
46
+ provider = PROVIDERS[provider_name]
47
+ if not provider.key:
48
+ return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy"}, None
49
+
50
+ url = provider.base_url + upstream_path
51
+ headers = {"content-type": "application/json", **provider.auth_headers()}
52
+ model = body.get("model", "")
53
+ t0 = now_ms()
54
+
55
+ def _log(status, ptok, ctok, err=None):
56
+ store.log_request(
57
+ token_id=token["id"], token_label=token.get("label"), provider=provider_name,
58
+ model=model, status=status, prompt_tokens=ptok, completion_tokens=ctok,
59
+ latency_ms=now_ms() - t0, streamed=1 if streaming else 0,
60
+ tools_used=json.dumps(tools_used or []), error=err,
61
+ )
62
+
63
+ if streaming:
64
+ async def _gen():
65
+ ptok = ctok = None
66
+ status = 200
67
+ try:
68
+ async with httpx.AsyncClient(timeout=config.request_timeout) as client:
69
+ async with client.stream("POST", url, headers=headers, json=body) as resp:
70
+ status = resp.status_code
71
+ async for chunk in resp.aiter_raw():
72
+ # Best-effort usage capture from the final SSE event.
73
+ text = chunk.decode("utf-8", "ignore")
74
+ if '"output_tokens"' in text or '"completion_tokens"' in text:
75
+ try:
76
+ for line in text.splitlines():
77
+ if line.startswith("data:"):
78
+ d = json.loads(line[5:].strip())
79
+ usage = d.get("usage") or (d.get("message") or {}).get("usage") or {}
80
+ ptok = usage.get("input_tokens") or usage.get("prompt_tokens") or ptok
81
+ ctok = usage.get("output_tokens") or usage.get("completion_tokens") or ctok
82
+ except Exception:
83
+ pass
84
+ yield chunk
85
+ finally:
86
+ _log(status, ptok, ctok)
87
+ return 200, {"content-type": "text/event-stream"}, _gen(), None
88
+
89
+ # Non-streaming.
90
+ async with httpx.AsyncClient(timeout=config.request_timeout) as client:
91
+ resp = await client.post(url, headers=headers, json=body)
92
+ try:
93
+ payload = resp.json()
94
+ except Exception:
95
+ payload = {"error": resp.text}
96
+ ptok, ctok = _extract_usage(provider_name, payload if isinstance(payload, dict) else {})
97
+ _log(resp.status_code, ptok, ctok, None if resp.is_success else str(payload)[:300])
98
+ return resp.status_code, {"content-type": "application/json"}, payload, None
proxyagent/security.py ADDED
@@ -0,0 +1,59 @@
1
+ """Security primitives — token minting, hashing, constant-time checks, redaction.
2
+
3
+ Design rules (the whole point of this project):
4
+ * Real provider/tool keys live ONLY on the server, in memory/config — never in the
5
+ DB, never in logs, never returned over the API.
6
+ * Machine tokens are stored as salted SHA-256 hashes. The plaintext is shown ONCE,
7
+ at creation. A leaked DB reveals no usable token.
8
+ * All token comparisons are constant-time.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import secrets
16
+
17
+ TOKEN_PREFIX = "pa_"
18
+ ADMIN_PREFIX = "pa_admin_"
19
+
20
+
21
+ def new_token(prefix: str = TOKEN_PREFIX, *, nbytes: int = 32) -> str:
22
+ """A high-entropy, URL-safe token. Shown to the caller exactly once."""
23
+ return prefix + secrets.token_urlsafe(nbytes)
24
+
25
+
26
+ def hash_token(token: str) -> str:
27
+ """Stable SHA-256 hex of a token — what we persist + compare against."""
28
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
29
+
30
+
31
+ def token_matches(token: str, stored_hash: str) -> bool:
32
+ """Constant-time check of a presented token against a stored hash."""
33
+ return hmac.compare_digest(hash_token(token), stored_hash)
34
+
35
+
36
+ def constant_time_eq(a: str, b: str) -> bool:
37
+ return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
38
+
39
+
40
+ # ------------------------------------------------------------------ #
41
+ # Redaction — so secrets never reach logs or error bodies.
42
+ # ------------------------------------------------------------------ #
43
+
44
+ _SENSITIVE_HEADERS = {"authorization", "x-api-key", "api-key", "proxy-authorization"}
45
+
46
+
47
+ def redact_headers(headers: dict) -> dict:
48
+ out = {}
49
+ for k, v in headers.items():
50
+ out[k] = "***" if k.lower() in _SENSITIVE_HEADERS else v
51
+ return out
52
+
53
+
54
+ def mask(value: str | None, keep: int = 4) -> str:
55
+ """Mask a secret for display: pa_abcd… → pa_abcd…(masked)."""
56
+ if not value:
57
+ return ""
58
+ head = value[: keep + len(TOKEN_PREFIX)] if value.startswith(TOKEN_PREFIX) else value[:keep]
59
+ return f"{head}…"
proxyagent/server.py ADDED
@@ -0,0 +1,186 @@
1
+ """The proxy server — FastAPI.
2
+
3
+ Public (machine-token) endpoints mirror the provider APIs so harnesses just point
4
+ their base URL here. Admin endpoints (admin-token) manage tokens, view usage/logs,
5
+ and list tools. The static dashboard is served at /.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from fastapi import FastAPI, Header, HTTPException, Request
14
+ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
15
+ from pydantic import BaseModel
16
+
17
+ from .config import Config, PROVIDERS
18
+ from .providers import ROUTES, forward, scope_allows
19
+ from .security import token_matches
20
+ from .store import Store, now_ms
21
+ from .tools import ToolRegistry
22
+
23
+ UI_DIR = Path(__file__).resolve().parent / "ui"
24
+
25
+
26
+ class TokenBody(BaseModel):
27
+ label: str = "machine"
28
+ scope: list[str] = ["*"]
29
+ ttl_seconds: int | None = None
30
+ rate_limit: int = 0
31
+
32
+
33
+ def create_app(config: Config | None = None) -> FastAPI:
34
+ config = config or Config.load()
35
+ store = Store(config.db_path)
36
+ tools = ToolRegistry(config)
37
+ app = FastAPI(title="proxyagent", version="0.1.0")
38
+ app.state.store = store
39
+
40
+ # ------------------------------------------------------------------ #
41
+ # Auth helpers
42
+ # ------------------------------------------------------------------ #
43
+ def _bearer(authorization: str | None, x_api_key: str | None) -> str | None:
44
+ if authorization and authorization.lower().startswith("bearer "):
45
+ return authorization[7:].strip()
46
+ return x_api_key
47
+
48
+ def auth_machine(authorization, x_api_key) -> dict:
49
+ from .security import hash_token
50
+ tok = _bearer(authorization, x_api_key)
51
+ if not tok:
52
+ raise HTTPException(401, "missing token")
53
+ row = store.get_token_by_hash(hash_token(tok))
54
+ if not row or row["revoked"]:
55
+ raise HTTPException(401, "invalid or revoked token")
56
+ if row["expires_ms"] and row["expires_ms"] < now_ms():
57
+ raise HTTPException(401, "token expired")
58
+ if row["rate_limit"] and store.recent_request_count(row["id"]) >= row["rate_limit"]:
59
+ raise HTTPException(429, "rate limit exceeded")
60
+ store.touch_token(row["id"])
61
+ return row
62
+
63
+ def require_admin(authorization, x_admin_token) -> None:
64
+ tok = None
65
+ if authorization and authorization.lower().startswith("bearer "):
66
+ tok = authorization[7:].strip()
67
+ tok = tok or x_admin_token
68
+ if not tok or not token_matches(tok, config.admin_token_hash):
69
+ raise HTTPException(401, "admin auth required")
70
+
71
+ import json as _json
72
+ from .store import Store as _S # noqa
73
+
74
+ # ------------------------------------------------------------------ #
75
+ # Provider proxy endpoints
76
+ # ------------------------------------------------------------------ #
77
+ async def _proxy(provider_key: str, request: Request, authorization, x_api_key):
78
+ token = auth_machine(authorization, x_api_key)
79
+ provider_name, upstream_path = ROUTES[provider_key]
80
+ body = await request.json()
81
+ model = body.get("model", "")
82
+ scope = _json.loads(token["scope_json"])
83
+ if not scope_allows(scope, provider_name, model):
84
+ raise HTTPException(403, f"token scope does not allow {provider_name}:{model}")
85
+
86
+ used_tools: list[str] = []
87
+ if request.headers.get("x-proxyagent-tools", "").lower() in ("1", "on", "true"):
88
+ body = tools.inject(body, provider_name)
89
+ used_tools = tools.names()
90
+
91
+ streaming = bool(body.get("stream"))
92
+ status, headers, payload, _ = await forward(
93
+ config, provider_name, upstream_path, body,
94
+ streaming=streaming, token=token, store=store, tools_used=used_tools,
95
+ )
96
+ if streaming:
97
+ return StreamingResponse(payload, media_type="text/event-stream")
98
+ return JSONResponse(payload, status_code=status)
99
+
100
+ @app.post("/anthropic/v1/messages")
101
+ async def anthropic(request: Request, authorization: str | None = Header(None),
102
+ x_api_key: str | None = Header(None)):
103
+ return await _proxy("anthropic", request, authorization, x_api_key)
104
+
105
+ @app.post("/openai/v1/chat/completions")
106
+ async def openai(request: Request, authorization: str | None = Header(None),
107
+ x_api_key: str | None = Header(None)):
108
+ return await _proxy("openai", request, authorization, x_api_key)
109
+
110
+ # ------------------------------------------------------------------ #
111
+ # Tools — execute a proxied tool (creds stay here)
112
+ # ------------------------------------------------------------------ #
113
+ @app.get("/v1/tools")
114
+ async def list_tools(authorization: str | None = Header(None),
115
+ x_api_key: str | None = Header(None)):
116
+ auth_machine(authorization, x_api_key)
117
+ return {"tools": tools.list()}
118
+
119
+ @app.post("/v1/tools/{name}/execute")
120
+ async def exec_tool(name: str, request: Request, authorization: str | None = Header(None),
121
+ x_api_key: str | None = Header(None)):
122
+ auth_machine(authorization, x_api_key)
123
+ args = await request.json()
124
+ if not tools.manages(name):
125
+ raise HTTPException(404, f"unknown tool '{name}'")
126
+ return {"result": await tools.execute(name, args)}
127
+
128
+ # ------------------------------------------------------------------ #
129
+ # Admin API
130
+ # ------------------------------------------------------------------ #
131
+ @app.post("/admin/tokens")
132
+ async def create_token(body: TokenBody, authorization: str | None = Header(None),
133
+ x_admin_token: str | None = Header(None)):
134
+ require_admin(authorization, x_admin_token)
135
+ plain, row = store.create_token(body.label, body.scope,
136
+ ttl_seconds=body.ttl_seconds, rate_limit=body.rate_limit)
137
+ return {"token": plain, "id": row["id"], "label": row["label"],
138
+ "scope": body.scope, "note": "shown once — store it now"}
139
+
140
+ @app.get("/admin/tokens")
141
+ async def list_tokens_ep(authorization: str | None = Header(None),
142
+ x_admin_token: str | None = Header(None)):
143
+ require_admin(authorization, x_admin_token)
144
+ out = []
145
+ for t in store.list_tokens():
146
+ out.append({"id": t["id"], "label": t["label"], "masked": t["masked"],
147
+ "scope": _json.loads(t["scope_json"]), "revoked": bool(t["revoked"]),
148
+ "rate_limit": t["rate_limit"], "expires_ms": t["expires_ms"],
149
+ "last_used_ms": t["last_used_ms"]})
150
+ return {"tokens": out}
151
+
152
+ @app.delete("/admin/tokens/{tid}")
153
+ async def revoke_token_ep(tid: str, authorization: str | None = Header(None),
154
+ x_admin_token: str | None = Header(None)):
155
+ require_admin(authorization, x_admin_token)
156
+ if not store.revoke_token(tid):
157
+ raise HTTPException(404, "no such token")
158
+ return {"ok": True}
159
+
160
+ @app.get("/admin/logs")
161
+ async def logs_ep(limit: int = 200, authorization: str | None = Header(None),
162
+ x_admin_token: str | None = Header(None)):
163
+ require_admin(authorization, x_admin_token)
164
+ return {"logs": store.list_logs(limit)}
165
+
166
+ @app.get("/admin/usage")
167
+ async def usage_ep(authorization: str | None = Header(None),
168
+ x_admin_token: str | None = Header(None)):
169
+ require_admin(authorization, x_admin_token)
170
+ return {"usage": store.usage_summary(),
171
+ "providers": config.configured_providers(),
172
+ "tools": tools.list()}
173
+
174
+ @app.get("/healthz")
175
+ async def healthz():
176
+ return {"ok": True, "providers": config.configured_providers(), "tools": tools.names()}
177
+
178
+ # ------------------------------------------------------------------ #
179
+ # Dashboard
180
+ # ------------------------------------------------------------------ #
181
+ @app.get("/", response_class=HTMLResponse)
182
+ async def ui():
183
+ idx = UI_DIR / "index.html"
184
+ return HTMLResponse(idx.read_text() if idx.exists() else "<h1>proxyagent</h1>")
185
+
186
+ return app
proxyagent/store.py ADDED
@@ -0,0 +1,148 @@
1
+ """Persistence — machine tokens (hashed) + a full request/usage audit log.
2
+
3
+ SQLite, guarded by a lock so it's safe across the server's worker threads.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sqlite3
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from pathlib import Path
14
+
15
+ from .security import hash_token, new_token, mask
16
+
17
+ _SCHEMA = """
18
+ CREATE TABLE IF NOT EXISTS tokens (
19
+ id TEXT PRIMARY KEY,
20
+ hash TEXT NOT NULL UNIQUE,
21
+ label TEXT,
22
+ scope_json TEXT NOT NULL DEFAULT '["*"]', -- allowed "provider:model" globs
23
+ rate_limit INTEGER NOT NULL DEFAULT 0, -- max requests/min (0 = unlimited)
24
+ created_ms INTEGER,
25
+ expires_ms INTEGER, -- NULL = never
26
+ revoked INTEGER NOT NULL DEFAULT 0,
27
+ last_used_ms INTEGER,
28
+ masked TEXT
29
+ );
30
+ CREATE TABLE IF NOT EXISTS logs (
31
+ id TEXT PRIMARY KEY,
32
+ ts_ms INTEGER,
33
+ token_id TEXT,
34
+ token_label TEXT,
35
+ provider TEXT,
36
+ model TEXT,
37
+ status INTEGER,
38
+ prompt_tokens INTEGER,
39
+ completion_tokens INTEGER,
40
+ latency_ms INTEGER,
41
+ streamed INTEGER,
42
+ tools_used TEXT,
43
+ error TEXT
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts_ms DESC);
46
+ CREATE INDEX IF NOT EXISTS idx_logs_token ON logs (token_id);
47
+ """
48
+
49
+
50
+ def now_ms() -> int:
51
+ return int(time.time() * 1000)
52
+
53
+
54
+ class Store:
55
+ def __init__(self, path: str | Path = ":memory:"):
56
+ self._lock = threading.RLock()
57
+ self._conn = sqlite3.connect(str(path), check_same_thread=False)
58
+ self._conn.row_factory = sqlite3.Row
59
+ self._conn.executescript(_SCHEMA)
60
+ self._conn.commit()
61
+
62
+ # -- tokens ------------------------------------------------------------ #
63
+
64
+ def create_token(self, label: str, scope: list[str], *, ttl_seconds: int | None = None,
65
+ rate_limit: int = 0) -> tuple[str, dict]:
66
+ """Mint a token. Returns (plaintext_once, row). Plaintext is never stored."""
67
+ plain = new_token()
68
+ tid = "tok_" + uuid.uuid4().hex[:12]
69
+ expires = now_ms() + ttl_seconds * 1000 if ttl_seconds else None
70
+ with self._lock:
71
+ self._conn.execute(
72
+ """INSERT INTO tokens (id, hash, label, scope_json, rate_limit, created_ms,
73
+ expires_ms, masked)
74
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
75
+ (tid, hash_token(plain), label, json.dumps(scope), rate_limit, now_ms(),
76
+ expires, mask(plain)),
77
+ )
78
+ self._conn.commit()
79
+ return plain, self.get_token(tid)
80
+
81
+ def get_token(self, tid: str) -> dict | None:
82
+ with self._lock:
83
+ r = self._conn.execute("SELECT * FROM tokens WHERE id=?", (tid,)).fetchone()
84
+ return dict(r) if r else None
85
+
86
+ def get_token_by_hash(self, h: str) -> dict | None:
87
+ with self._lock:
88
+ r = self._conn.execute("SELECT * FROM tokens WHERE hash=?", (h,)).fetchone()
89
+ return dict(r) if r else None
90
+
91
+ def list_tokens(self) -> list[dict]:
92
+ with self._lock:
93
+ rows = self._conn.execute("SELECT * FROM tokens ORDER BY created_ms DESC").fetchall()
94
+ return [dict(r) for r in rows]
95
+
96
+ def revoke_token(self, tid: str) -> bool:
97
+ with self._lock:
98
+ cur = self._conn.execute("UPDATE tokens SET revoked=1 WHERE id=?", (tid,))
99
+ self._conn.commit()
100
+ return cur.rowcount > 0
101
+
102
+ def touch_token(self, tid: str) -> None:
103
+ with self._lock:
104
+ self._conn.execute("UPDATE tokens SET last_used_ms=? WHERE id=?", (now_ms(), tid))
105
+ self._conn.commit()
106
+
107
+ def recent_request_count(self, tid: str, window_ms: int = 60_000) -> int:
108
+ with self._lock:
109
+ r = self._conn.execute(
110
+ "SELECT COUNT(*) c FROM logs WHERE token_id=? AND ts_ms>=?",
111
+ (tid, now_ms() - window_ms),
112
+ ).fetchone()
113
+ return r["c"]
114
+
115
+ # -- logs / usage ------------------------------------------------------ #
116
+
117
+ def log_request(self, **kw) -> None:
118
+ kw.setdefault("id", "log_" + uuid.uuid4().hex[:12])
119
+ kw.setdefault("ts_ms", now_ms())
120
+ cols = ["id", "ts_ms", "token_id", "token_label", "provider", "model", "status",
121
+ "prompt_tokens", "completion_tokens", "latency_ms", "streamed", "tools_used", "error"]
122
+ vals = [kw.get(c) for c in cols]
123
+ with self._lock:
124
+ self._conn.execute(
125
+ f"INSERT INTO logs ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})", vals
126
+ )
127
+ self._conn.commit()
128
+
129
+ def list_logs(self, limit: int = 200) -> list[dict]:
130
+ with self._lock:
131
+ rows = self._conn.execute(
132
+ "SELECT * FROM logs ORDER BY ts_ms DESC LIMIT ?", (limit,)
133
+ ).fetchall()
134
+ return [dict(r) for r in rows]
135
+
136
+ def usage_summary(self) -> dict:
137
+ with self._lock:
138
+ r = self._conn.execute(
139
+ """SELECT COUNT(*) requests,
140
+ COALESCE(SUM(prompt_tokens),0) prompt_tokens,
141
+ COALESCE(SUM(completion_tokens),0) completion_tokens
142
+ FROM logs"""
143
+ ).fetchone()
144
+ return dict(r)
145
+
146
+ def close(self) -> None:
147
+ with self._lock:
148
+ self._conn.close()
proxyagent/tools.py ADDED
@@ -0,0 +1,144 @@
1
+ """Proxied tools — give agents governed tools (web search, custom HTTP tools) whose
2
+ credentials live ONLY on the proxy.
3
+
4
+ The proxy can:
5
+ * inject tool definitions into a model request (so the agent can call them), and
6
+ * execute the tool server-side when the model asks for it — so the agent never
7
+ holds the tool's API key (same security model as the model keys).
8
+
9
+ Built-in: `web_search`. Custom tools are registered as HTTP webhooks in config; their
10
+ auth headers stay on the proxy.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from dataclasses import dataclass
18
+ from typing import Awaitable, Callable
19
+
20
+ import httpx
21
+
22
+
23
+ @dataclass
24
+ class Tool:
25
+ name: str
26
+ description: str
27
+ input_schema: dict # JSON Schema for the tool input
28
+ executor: Callable[[dict], Awaitable[str]]
29
+
30
+ def anthropic_def(self) -> dict:
31
+ return {"name": self.name, "description": self.description, "input_schema": self.input_schema}
32
+
33
+ def openai_def(self) -> dict:
34
+ return {"type": "function", "function": {
35
+ "name": self.name, "description": self.description, "parameters": self.input_schema}}
36
+
37
+
38
+ # ------------------------------------------------------------------ #
39
+ # Built-in: web search (Tavily if configured, else DuckDuckGo fallback).
40
+ # The search key lives on the proxy — agents never see it.
41
+ # ------------------------------------------------------------------ #
42
+
43
+ async def _web_search(args: dict) -> str:
44
+ query = str(args.get("query", "")).strip()
45
+ if not query:
46
+ return "error: empty query"
47
+ tavily = os.environ.get("TAVILY_API_KEY")
48
+ try:
49
+ async with httpx.AsyncClient(timeout=20) as client:
50
+ if tavily:
51
+ r = await client.post("https://api.tavily.com/search", json={
52
+ "api_key": tavily, "query": query, "max_results": 5})
53
+ data = r.json()
54
+ hits = [f"- {h['title']}: {h['url']}\n {h.get('content','')[:300]}"
55
+ for h in data.get("results", [])]
56
+ return ("\n".join(hits)) or "no results"
57
+ # Keyless fallback: DuckDuckGo Instant Answer.
58
+ r = await client.get("https://api.duckduckgo.com/", params={
59
+ "q": query, "format": "json", "no_html": 1})
60
+ data = r.json()
61
+ out = []
62
+ if data.get("AbstractText"):
63
+ out.append(data["AbstractText"])
64
+ for t in (data.get("RelatedTopics") or [])[:5]:
65
+ if isinstance(t, dict) and t.get("Text"):
66
+ out.append(f"- {t['Text']} ({t.get('FirstURL','')})")
67
+ return "\n".join(out) or "no results (set TAVILY_API_KEY for full web search)"
68
+ except Exception as exc: # noqa: BLE001
69
+ return f"search error: {exc}"
70
+
71
+
72
+ WEB_SEARCH = Tool(
73
+ name="web_search",
74
+ description="Search the web and return the top results. Use for current info.",
75
+ input_schema={"type": "object", "properties": {
76
+ "query": {"type": "string", "description": "The search query"}}, "required": ["query"]},
77
+ executor=_web_search,
78
+ )
79
+
80
+
81
+ def _http_tool(spec: dict) -> Tool:
82
+ """A custom tool that POSTs the model's input to a URL you control. The tool's
83
+ auth headers live here on the proxy, not on the agent."""
84
+ url = spec["url"]
85
+ headers = spec.get("headers", {})
86
+
87
+ async def _run(args: dict) -> str:
88
+ async with httpx.AsyncClient(timeout=30) as client:
89
+ r = await client.post(url, headers=headers, json=args)
90
+ return r.text[:4000]
91
+
92
+ return Tool(spec["name"], spec.get("description", ""),
93
+ spec.get("input_schema", {"type": "object", "properties": {}}), _run)
94
+
95
+
96
+ class ToolRegistry:
97
+ def __init__(self, config=None):
98
+ self._tools: dict[str, Tool] = {}
99
+ # web_search is on by default unless explicitly disabled.
100
+ if os.environ.get("PROXYAGENT_DISABLE_WEB_SEARCH") != "1":
101
+ self.register(WEB_SEARCH)
102
+ # Custom tools from PROXYAGENT_TOOLS (JSON list of {name,url,headers,...}).
103
+ raw = os.environ.get("PROXYAGENT_TOOLS")
104
+ if raw:
105
+ try:
106
+ for spec in json.loads(raw):
107
+ self.register(_http_tool(spec))
108
+ except Exception:
109
+ pass
110
+
111
+ def register(self, tool: Tool) -> None:
112
+ self._tools[tool.name] = tool
113
+
114
+ def names(self) -> list[str]:
115
+ return list(self._tools)
116
+
117
+ def list(self) -> list[dict]:
118
+ return [{"name": t.name, "description": t.description} for t in self._tools.values()]
119
+
120
+ def inject(self, body: dict, provider: str) -> dict:
121
+ """Add registered tools to a request in the provider's format (non-destructive)."""
122
+ if not self._tools:
123
+ return body
124
+ existing = body.get("tools") or []
125
+ names = {self._tool_name(t, provider) for t in existing}
126
+ for t in self._tools.values():
127
+ d = t.anthropic_def() if provider == "anthropic" else t.openai_def()
128
+ if self._tool_name(d, provider) not in names:
129
+ existing.append(d)
130
+ body["tools"] = existing
131
+ return body
132
+
133
+ @staticmethod
134
+ def _tool_name(t: dict, provider: str) -> str:
135
+ return t.get("name") if provider == "anthropic" else (t.get("function") or {}).get("name", t.get("name"))
136
+
137
+ async def execute(self, name: str, args: dict) -> str:
138
+ tool = self._tools.get(name)
139
+ if not tool:
140
+ return f"error: unknown tool '{name}'"
141
+ return await tool.executor(args)
142
+
143
+ def manages(self, name: str) -> bool:
144
+ return name in self._tools
@@ -0,0 +1,123 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>proxyagent</title>
7
+ <style>
8
+ :root { --bg:#0b0c0e; --panel:#15171a; --line:#23262b; --txt:#e7e9ec; --dim:#8a9099; --grn:#34d39e; --red:#f87171; --yel:#fbbf24; }
9
+ * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto; }
10
+ a { color:var(--grn); } code,.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
11
+ header { display:flex; align-items:center; justify-content:space-between; padding:18px 28px; border-bottom:1px solid var(--line); }
12
+ .brand { display:flex; align-items:center; gap:10px; font-weight:600; font-size:16px; }
13
+ .dot { width:9px; height:9px; border-radius:50%; background:var(--grn); box-shadow:0 0 10px 1px rgba(52,211,158,.5); }
14
+ main { max-width:1100px; margin:0 auto; padding:28px; }
15
+ .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:14px; margin-bottom:26px; }
16
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; }
17
+ .stat .n { font-size:30px; font-weight:600; } .stat .l { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.08em; margin-top:4px; }
18
+ h2 { font-size:13px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim); margin:26px 0 12px; }
19
+ table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--line); font-size:13px; }
20
+ th { color:var(--dim); font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
21
+ input,button,select { font:inherit; border-radius:9px; border:1px solid var(--line); background:#0e1013; color:var(--txt); padding:8px 11px; }
22
+ button { background:var(--grn); color:#04130d; border:none; font-weight:600; cursor:pointer; } button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); }
23
+ button:hover { filter:brightness(1.08); } .row { display:flex; gap:9px; flex-wrap:wrap; align-items:center; }
24
+ .pill { padding:2px 9px; border-radius:99px; font-size:11px; } .pill.ok { background:rgba(52,211,158,.12); color:var(--grn); } .pill.no { background:rgba(248,113,113,.12); color:var(--red); }
25
+ .gate { max-width:420px; margin:90px auto; text-align:center; } .gate input { width:100%; margin:14px 0; }
26
+ .tok { background:#0e1013; border:1px dashed var(--grn); padding:12px; border-radius:10px; word-break:break-all; margin-top:12px; }
27
+ .hide { display:none; }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <div id="gate" class="gate">
32
+ <div class="brand" style="justify-content:center"><span class="dot"></span> proxyagent</div>
33
+ <p style="color:var(--dim)">Paste your admin token (printed by <code>proxyagent serve</code>).</p>
34
+ <input id="admintok" type="password" placeholder="pa_admin_…" />
35
+ <button onclick="saveAdmin()">Open dashboard</button>
36
+ <p id="gateerr" style="color:var(--red)"></p>
37
+ </div>
38
+
39
+ <div id="app" class="hide">
40
+ <header>
41
+ <div class="brand"><span class="dot"></span> proxyagent</div>
42
+ <div class="row"><span id="provs" style="color:var(--dim)"></span><button class="ghost" onclick="logout()">Sign out</button></div>
43
+ </header>
44
+ <main>
45
+ <div class="grid">
46
+ <div class="card stat"><div class="n" id="s_req">0</div><div class="l">Requests</div></div>
47
+ <div class="card stat"><div class="n" id="s_in">0</div><div class="l">Input tokens</div></div>
48
+ <div class="card stat"><div class="n" id="s_out">0</div><div class="l">Output tokens</div></div>
49
+ <div class="card stat"><div class="n" id="s_tools">0</div><div class="l">Proxied tools</div></div>
50
+ </div>
51
+
52
+ <h2>Mint a machine token</h2>
53
+ <div class="card">
54
+ <div class="row">
55
+ <input id="t_label" placeholder="label (e.g. macbook-01)" />
56
+ <input id="t_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1" />
57
+ <input id="t_ttl" type="number" placeholder="ttl (s)" style="width:110px" />
58
+ <button onclick="mint()">Mint token</button>
59
+ </div>
60
+ <div id="minted" class="tok hide"></div>
61
+ <p style="color:var(--dim);margin:10px 0 0">The machine holds only this token — never a real key. Revoke anytime.</p>
62
+ </div>
63
+
64
+ <h2>Machine tokens</h2>
65
+ <div class="card"><table><thead><tr><th>ID</th><th>Label</th><th>Token</th><th>Scope</th><th>Status</th><th></th></tr></thead><tbody id="toks"></tbody></table></div>
66
+
67
+ <h2>Recent requests <span style="color:var(--dim);font-weight:400;text-transform:none;letter-spacing:0">· live</span></h2>
68
+ <div class="card"><table><thead><tr><th>Time</th><th>Token</th><th>Provider</th><th>Model</th><th>Status</th><th>In</th><th>Out</th><th>ms</th></tr></thead><tbody id="logs"></tbody></table></div>
69
+ </main>
70
+ </div>
71
+
72
+ <script>
73
+ const A = () => localStorage.getItem("pa_admin");
74
+ const H = () => ({ "x-admin-token": A(), "content-type": "application/json" });
75
+ async function api(path, opts={}) { const r = await fetch(path, { ...opts, headers: { ...H(), ...(opts.headers||{}) } }); if (r.status===401) { logout(); throw new Error("unauthorized"); } return r; }
76
+
77
+ function saveAdmin() { const v = document.getElementById("admintok").value.trim(); if (!v) return; localStorage.setItem("pa_admin", v); boot(); }
78
+ function logout() { localStorage.removeItem("pa_admin"); document.getElementById("app").classList.add("hide"); document.getElementById("gate").classList.remove("hide"); }
79
+
80
+ async function boot() {
81
+ try {
82
+ const u = await (await api("/admin/usage")).json();
83
+ document.getElementById("gate").classList.add("hide");
84
+ document.getElementById("app").classList.remove("hide");
85
+ document.getElementById("s_req").textContent = u.usage.requests;
86
+ document.getElementById("s_in").textContent = u.usage.prompt_tokens;
87
+ document.getElementById("s_out").textContent = u.usage.completion_tokens;
88
+ document.getElementById("s_tools").textContent = (u.tools||[]).length;
89
+ document.getElementById("provs").textContent = "providers: " + ((u.providers||[]).join(", ") || "none");
90
+ refreshTokens(); refreshLogs();
91
+ } catch (e) { document.getElementById("gateerr").textContent = "Invalid admin token."; }
92
+ }
93
+
94
+ async function refreshTokens() {
95
+ const d = await (await api("/admin/tokens")).json();
96
+ document.getElementById("toks").innerHTML = d.tokens.map(t => `
97
+ <tr><td class="mono">${t.id}</td><td>${t.label||""}</td><td class="mono">${t.masked||""}</td>
98
+ <td class="mono">${(t.scope||[]).join(", ")}</td>
99
+ <td>${t.revoked?'<span class="pill no">revoked</span>':'<span class="pill ok">active</span>'}</td>
100
+ <td>${t.revoked?"":`<button class="ghost" onclick="revoke('${t.id}')">revoke</button>`}</td></tr>`).join("");
101
+ }
102
+ async function refreshLogs() {
103
+ const d = await (await api("/admin/logs?limit=60")).json();
104
+ document.getElementById("logs").innerHTML = d.logs.map(g => `
105
+ <tr><td class="mono">${new Date(g.ts_ms).toLocaleTimeString()}</td><td>${g.token_label||""}</td>
106
+ <td>${g.provider||""}</td><td class="mono">${(g.model||"").slice(0,26)}</td>
107
+ <td>${g.status||""}</td><td>${g.prompt_tokens??"-"}</td><td>${g.completion_tokens??"-"}</td><td>${g.latency_ms||""}</td></tr>`).join("");
108
+ }
109
+ async function mint() {
110
+ const body = { label: val("t_label")||"machine", scope: (val("t_scope")||"*").split(",").map(s=>s.trim()), ttl_seconds: parseInt(val("t_ttl"))||null };
111
+ const d = await (await api("/admin/tokens", { method:"POST", body: JSON.stringify(body) })).json();
112
+ const el = document.getElementById("minted"); el.classList.remove("hide");
113
+ el.innerHTML = `<b>Token (shown once):</b><br/><span class="mono">${d.token}</span>`;
114
+ refreshTokens(); boot();
115
+ }
116
+ async function revoke(id) { await api("/admin/tokens/"+id, { method:"DELETE" }); refreshTokens(); }
117
+ function val(id){ return document.getElementById(id).value.trim(); }
118
+
119
+ if (A()) boot();
120
+ setInterval(() => { if (A() && !document.getElementById("app").classList.contains("hide")) { refreshLogs(); } }, 4000);
121
+ </script>
122
+ </body>
123
+ </html>
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: proxyagent
3
+ Version: 0.1.0
4
+ Summary: Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools.
5
+ Project-URL: Homepage, https://github.com/teddyoweh/proxyagent
6
+ Author-email: Spawn Labs <teddy@spawnlabs.ai>
7
+ License-Expression: Apache-2.0
8
+ Keywords: agents,claude,codex,gateway,llm,proxy,security,tools
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: fastapi>=0.110
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: pydantic>=2.0
18
+ Requires-Dist: rich>=13.0
19
+ Requires-Dist: typer>=0.12
20
+ Requires-Dist: uvicorn[standard]>=0.27
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ <div align="center">
27
+
28
+ # proxyagent
29
+
30
+ **Run any agent — Claude, Codex, custom — on any machine, with _no API key on the machine._**
31
+
32
+ A secure, self-hosted proxy for models **and** tools. Your keys live in one hardened place; every machine holds only a scoped, revocable token.
33
+
34
+ </div>
35
+
36
+ ---
37
+
38
+ Agents need model access (and tool access) to do anything. Today that means scattering
39
+ real API keys across every machine an agent runs on — a security nightmare. `proxyagent`
40
+ fixes it: stand up **one** proxy that holds the real credentials, and point every agent at
41
+ it. The machine gets a throwaway token; the real key never leaves the proxy.
42
+
43
+ ```
44
+ remote machine proxy (you host) upstream
45
+ ┌────────────────┐ token only ┌──────────────────┐ real key ┌───────────┐
46
+ │ claude / codex │ ───────────► │ proxyagent serve │ ─────────► │ Anthropic │
47
+ │ (no real key) │ ◄─────────── │ scope·log·tools │ ◄───────── │ OpenAI │
48
+ └────────────────┘ stream └──────────────────┘ └───────────┘
49
+ ```
50
+
51
+ ## How it works
52
+ Every harness honours `*_BASE_URL`, so the shim is trivial: point the base URL at the
53
+ proxy and use the **machine token** as the "api key." The proxy authenticates the token,
54
+ checks its scope, **swaps in the real key**, forwards upstream, and logs the call. The
55
+ machine never sees a real credential.
56
+
57
+ ## Quickstart
58
+
59
+ **1. Run the proxy** (on a box you control — it holds the real keys):
60
+ ```bash
61
+ pip install proxyagent
62
+ export ANTHROPIC_API_KEY=sk-ant-… # and/or OPENAI_API_KEY=sk-…
63
+ proxyagent serve # prints an admin token + a dashboard at :8080
64
+ ```
65
+
66
+ **2. Mint a machine token** (scoped + revocable):
67
+ ```bash
68
+ proxyagent token new macbook-01 --scope "anthropic:claude-*" --admin pa_admin_…
69
+ ```
70
+
71
+ **3. Run any agent on any machine — no real key there:**
72
+ ```bash
73
+ PROXYAGENT_TOKEN=pa_… proxyagent run claude-code \
74
+ --goal "build a SwiftUI todo app" --proxy https://proxy.you.com
75
+ # or: proxyagent run codex --goal "fix the failing tests" --token pa_…
76
+ ```
77
+
78
+ Or use any harness directly — just set the env and the proxy does the rest:
79
+ ```bash
80
+ export ANTHROPIC_BASE_URL=https://proxy.you.com/anthropic
81
+ export ANTHROPIC_API_KEY=pa_… # the machine token, not the real key
82
+ claude -p "ship it"
83
+ ```
84
+
85
+ ## The dashboard
86
+ `proxyagent serve` ships a dashboard at `/` — mint/revoke tokens, watch live usage and a
87
+ full request audit log, see configured providers + proxied tools. Paste the admin token to
88
+ open it.
89
+
90
+ ## Proxied tools — the same trick, for tools
91
+ The proxy can also hold your **tool** keys and hand agents governed tools — so an agent gets
92
+ web search (and custom tools) without ever holding the tool's credential.
93
+
94
+ ```bash
95
+ export TAVILY_API_KEY=tvly-… # web_search uses this; agents never see it
96
+ export PROXYAGENT_TOOLS='[{"name":"crm","url":"https://hooks.you.com/crm","headers":{"Authorization":"Bearer …"}}]'
97
+ # then send requests with header x-proxyagent-tools: on → tool defs are injected;
98
+ # the proxy executes calls to managed tools server-side (keys stay here).
99
+ ```
100
+
101
+ ## Security model
102
+ - **Real keys never leave the proxy** — read from env, never persisted, never logged, never returned.
103
+ - **Machine tokens are stored hashed** (SHA-256); plaintext shown once. A stolen DB yields nothing usable.
104
+ - **Scoped** (`provider:model` globs), **expiring** (TTL), **revocable**, **rate-limited**.
105
+ - **Constant-time** token comparison; sensitive headers redacted from logs.
106
+ - Admin API + dashboard gated by a separate admin token. Run it behind TLS.
107
+
108
+ ## SDK
109
+ ```python
110
+ import proxyagent
111
+
112
+ # host the proxy (embed in your own service):
113
+ app = proxyagent.create_app() # ASGI app
114
+
115
+ # mint tokens programmatically:
116
+ admin = proxyagent.Admin("https://proxy.you.com", "pa_admin_…")
117
+ token = admin.mint("ci-runner", scope=["anthropic:claude-*"], ttl_seconds=3600)
118
+
119
+ # run a harness on this machine, no key here:
120
+ proxyagent.run("claude-code", goal="build the app",
121
+ proxy="https://proxy.you.com", token=token)
122
+ ```
123
+
124
+ ## Supported harnesses
125
+ `claude-code`, `codex`, and any **custom** command (`--command "my-agent {goal}"`). Adding one
126
+ is a few lines — it just needs to respect `*_BASE_URL`.
127
+
128
+ ## License
129
+ Apache-2.0
@@ -0,0 +1,14 @@
1
+ proxyagent/__init__.py,sha256=grcI_bPMZCUvI2GB5tPrPoG29sc0Tgbi_69kNI5Bq3E,2289
2
+ proxyagent/cli.py,sha256=fKhTiqkRynVsnTcQvLFMLX37_1wvQl-YysH9eiAdDVQ,6217
3
+ proxyagent/config.py,sha256=RJNm6zATUAou8OblJeMnNOoKTWJWjexDIfqB_ju478I,2841
4
+ proxyagent/harness.py,sha256=C5pE0mbSi_WBGXznYh0rDEwcODkFYZJ-L_EmvKyocks,2604
5
+ proxyagent/providers.py,sha256=bX_grMoyUVbaImhCPPeTSopi4xmo4NCHjwkQ2qKK9Vo,4275
6
+ proxyagent/security.py,sha256=mzQwhEe6ApCk9cRCKS3DGihb3sb-PgDvkqU_BQnNhq0,2022
7
+ proxyagent/server.py,sha256=ZWlnddqamjdo6hacviy-mXstr_ZGgDlTXexXRFCUMJQ,8234
8
+ proxyagent/store.py,sha256=bvTE59cqx_wyUKL7Ik4KyGyUtamCG_yvgx0N74JTsrM,5367
9
+ proxyagent/tools.py,sha256=kW5wui_iQ9el0inwIotkk2CxNbtJm2EJ1IGyx388xEg,5598
10
+ proxyagent/ui/index.html,sha256=WnQWgXwItDkZEH5na_xPvoDnlLIgBY5WaUxwm6Ilo7M,8082
11
+ proxyagent-0.1.0.dist-info/METADATA,sha256=liqVAjM_phze91PBXaqzeWqZD8rSx7Zcdbqta8DJpRY,5589
12
+ proxyagent-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ proxyagent-0.1.0.dist-info/entry_points.txt,sha256=kD5eHfhjVQ5Sl221LZBsZW2fxlxW4h_dvdQnjOxBGHk,50
14
+ proxyagent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ proxyagent = proxyagent.cli:app