proxyagent 0.1.0__tar.gz
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-0.1.0/.gitignore +13 -0
- proxyagent-0.1.0/PKG-INFO +129 -0
- proxyagent-0.1.0/README.md +104 -0
- proxyagent-0.1.0/proxyagent/__init__.py +65 -0
- proxyagent-0.1.0/proxyagent/cli.py +145 -0
- proxyagent-0.1.0/proxyagent/config.py +82 -0
- proxyagent-0.1.0/proxyagent/harness.py +73 -0
- proxyagent-0.1.0/proxyagent/providers.py +98 -0
- proxyagent-0.1.0/proxyagent/security.py +59 -0
- proxyagent-0.1.0/proxyagent/server.py +186 -0
- proxyagent-0.1.0/proxyagent/store.py +148 -0
- proxyagent-0.1.0/proxyagent/tools.py +144 -0
- proxyagent-0.1.0/proxyagent/ui/index.html +123 -0
- proxyagent-0.1.0/pyproject.toml +41 -0
- proxyagent-0.1.0/tests/test_proxy.py +72 -0
|
@@ -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,104 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# proxyagent
|
|
4
|
+
|
|
5
|
+
**Run any agent — Claude, Codex, custom — on any machine, with _no API key on the machine._**
|
|
6
|
+
|
|
7
|
+
A secure, self-hosted proxy for models **and** tools. Your keys live in one hardened place; every machine holds only a scoped, revocable token.
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Agents need model access (and tool access) to do anything. Today that means scattering
|
|
14
|
+
real API keys across every machine an agent runs on — a security nightmare. `proxyagent`
|
|
15
|
+
fixes it: stand up **one** proxy that holds the real credentials, and point every agent at
|
|
16
|
+
it. The machine gets a throwaway token; the real key never leaves the proxy.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
remote machine proxy (you host) upstream
|
|
20
|
+
┌────────────────┐ token only ┌──────────────────┐ real key ┌───────────┐
|
|
21
|
+
│ claude / codex │ ───────────► │ proxyagent serve │ ─────────► │ Anthropic │
|
|
22
|
+
│ (no real key) │ ◄─────────── │ scope·log·tools │ ◄───────── │ OpenAI │
|
|
23
|
+
└────────────────┘ stream └──────────────────┘ └───────────┘
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
Every harness honours `*_BASE_URL`, so the shim is trivial: point the base URL at the
|
|
28
|
+
proxy and use the **machine token** as the "api key." The proxy authenticates the token,
|
|
29
|
+
checks its scope, **swaps in the real key**, forwards upstream, and logs the call. The
|
|
30
|
+
machine never sees a real credential.
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
**1. Run the proxy** (on a box you control — it holds the real keys):
|
|
35
|
+
```bash
|
|
36
|
+
pip install proxyagent
|
|
37
|
+
export ANTHROPIC_API_KEY=sk-ant-… # and/or OPENAI_API_KEY=sk-…
|
|
38
|
+
proxyagent serve # prints an admin token + a dashboard at :8080
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**2. Mint a machine token** (scoped + revocable):
|
|
42
|
+
```bash
|
|
43
|
+
proxyagent token new macbook-01 --scope "anthropic:claude-*" --admin pa_admin_…
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**3. Run any agent on any machine — no real key there:**
|
|
47
|
+
```bash
|
|
48
|
+
PROXYAGENT_TOKEN=pa_… proxyagent run claude-code \
|
|
49
|
+
--goal "build a SwiftUI todo app" --proxy https://proxy.you.com
|
|
50
|
+
# or: proxyagent run codex --goal "fix the failing tests" --token pa_…
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or use any harness directly — just set the env and the proxy does the rest:
|
|
54
|
+
```bash
|
|
55
|
+
export ANTHROPIC_BASE_URL=https://proxy.you.com/anthropic
|
|
56
|
+
export ANTHROPIC_API_KEY=pa_… # the machine token, not the real key
|
|
57
|
+
claude -p "ship it"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## The dashboard
|
|
61
|
+
`proxyagent serve` ships a dashboard at `/` — mint/revoke tokens, watch live usage and a
|
|
62
|
+
full request audit log, see configured providers + proxied tools. Paste the admin token to
|
|
63
|
+
open it.
|
|
64
|
+
|
|
65
|
+
## Proxied tools — the same trick, for tools
|
|
66
|
+
The proxy can also hold your **tool** keys and hand agents governed tools — so an agent gets
|
|
67
|
+
web search (and custom tools) without ever holding the tool's credential.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
export TAVILY_API_KEY=tvly-… # web_search uses this; agents never see it
|
|
71
|
+
export PROXYAGENT_TOOLS='[{"name":"crm","url":"https://hooks.you.com/crm","headers":{"Authorization":"Bearer …"}}]'
|
|
72
|
+
# then send requests with header x-proxyagent-tools: on → tool defs are injected;
|
|
73
|
+
# the proxy executes calls to managed tools server-side (keys stay here).
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Security model
|
|
77
|
+
- **Real keys never leave the proxy** — read from env, never persisted, never logged, never returned.
|
|
78
|
+
- **Machine tokens are stored hashed** (SHA-256); plaintext shown once. A stolen DB yields nothing usable.
|
|
79
|
+
- **Scoped** (`provider:model` globs), **expiring** (TTL), **revocable**, **rate-limited**.
|
|
80
|
+
- **Constant-time** token comparison; sensitive headers redacted from logs.
|
|
81
|
+
- Admin API + dashboard gated by a separate admin token. Run it behind TLS.
|
|
82
|
+
|
|
83
|
+
## SDK
|
|
84
|
+
```python
|
|
85
|
+
import proxyagent
|
|
86
|
+
|
|
87
|
+
# host the proxy (embed in your own service):
|
|
88
|
+
app = proxyagent.create_app() # ASGI app
|
|
89
|
+
|
|
90
|
+
# mint tokens programmatically:
|
|
91
|
+
admin = proxyagent.Admin("https://proxy.you.com", "pa_admin_…")
|
|
92
|
+
token = admin.mint("ci-runner", scope=["anthropic:claude-*"], ttl_seconds=3600)
|
|
93
|
+
|
|
94
|
+
# run a harness on this machine, no key here:
|
|
95
|
+
proxyagent.run("claude-code", goal="build the app",
|
|
96
|
+
proxy="https://proxy.you.com", token=token)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Supported harnesses
|
|
100
|
+
`claude-code`, `codex`, and any **custom** command (`--command "my-agent {goal}"`). Adding one
|
|
101
|
+
is a few lines — it just needs to respect `*_BASE_URL`.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
Apache-2.0
|
|
@@ -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"]
|
|
@@ -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()
|
|
@@ -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]
|
|
@@ -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
|