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 +65 -0
- proxyagent/cli.py +145 -0
- proxyagent/config.py +82 -0
- proxyagent/harness.py +73 -0
- proxyagent/providers.py +98 -0
- proxyagent/security.py +59 -0
- proxyagent/server.py +186 -0
- proxyagent/store.py +148 -0
- proxyagent/tools.py +144 -0
- proxyagent/ui/index.html +123 -0
- proxyagent-0.1.0.dist-info/METADATA +129 -0
- proxyagent-0.1.0.dist-info/RECORD +14 -0
- proxyagent-0.1.0.dist-info/WHEEL +4 -0
- proxyagent-0.1.0.dist-info/entry_points.txt +2 -0
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
|
proxyagent/providers.py
ADDED
|
@@ -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
|
proxyagent/ui/index.html
ADDED
|
@@ -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,,
|