proxyagent 0.1.0__tar.gz → 0.2.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/README.md → proxyagent-0.2.0/PKG-INFO +57 -0
- proxyagent-0.1.0/PKG-INFO → proxyagent-0.2.0/README.md +25 -25
- {proxyagent-0.1.0 → proxyagent-0.2.0}/proxyagent/__init__.py +17 -1
- {proxyagent-0.1.0 → proxyagent-0.2.0}/proxyagent/cli.py +75 -2
- proxyagent-0.2.0/proxyagent/crypto.py +44 -0
- proxyagent-0.2.0/proxyagent/db.py +87 -0
- proxyagent-0.2.0/proxyagent/pricing.py +62 -0
- {proxyagent-0.1.0 → proxyagent-0.2.0}/proxyagent/providers.py +23 -4
- {proxyagent-0.1.0 → proxyagent-0.2.0}/proxyagent/server.py +46 -4
- proxyagent-0.2.0/proxyagent/store.py +154 -0
- {proxyagent-0.1.0 → proxyagent-0.2.0}/proxyagent/ui/index.html +3 -1
- {proxyagent-0.1.0 → proxyagent-0.2.0}/pyproject.toml +4 -1
- {proxyagent-0.1.0 → proxyagent-0.2.0}/tests/test_proxy.py +37 -0
- proxyagent-0.1.0/proxyagent/store.py +0 -148
- {proxyagent-0.1.0 → proxyagent-0.2.0}/.gitignore +0 -0
- {proxyagent-0.1.0 → proxyagent-0.2.0}/proxyagent/config.py +0 -0
- {proxyagent-0.1.0 → proxyagent-0.2.0}/proxyagent/harness.py +0 -0
- {proxyagent-0.1.0 → proxyagent-0.2.0}/proxyagent/security.py +0 -0
- {proxyagent-0.1.0 → proxyagent-0.2.0}/proxyagent/tools.py +0 -0
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: proxyagent
|
|
3
|
+
Version: 0.2.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: all
|
|
22
|
+
Requires-Dist: cryptography>=42.0; extra == 'all'
|
|
23
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'all'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Provides-Extra: postgres
|
|
28
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
29
|
+
Provides-Extra: secure
|
|
30
|
+
Requires-Dist: cryptography>=42.0; extra == 'secure'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
1
33
|
<div align="center">
|
|
2
34
|
|
|
3
35
|
# proxyagent
|
|
@@ -73,6 +105,31 @@ export PROXYAGENT_TOOLS='[{"name":"crm","url":"https://hooks.you.com/crm","heade
|
|
|
73
105
|
# the proxy executes calls to managed tools server-side (keys stay here).
|
|
74
106
|
```
|
|
75
107
|
|
|
108
|
+
## Credentials, storage & cost
|
|
109
|
+
|
|
110
|
+
By default provider keys come from the **environment** and stay local. Or **add** them
|
|
111
|
+
once and they're stored **encrypted** (`proxy_agent_keys`) — locally in SQLite, or in
|
|
112
|
+
**Postgres** if you point at one. Either way the machine never sees them.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
export PROXYAGENT_SECRET_KEY=… # enables at-rest encryption (Fernet)
|
|
116
|
+
proxyagent provider add anthropic --key sk-ant-… # stored, encrypted
|
|
117
|
+
proxyagent provider add openai --key sk-… --kind api_key
|
|
118
|
+
# OAuth: store an access token → proxyagent provider add anthropic --key <oauth-token> --kind oauth
|
|
119
|
+
proxyagent provider ls
|
|
120
|
+
|
|
121
|
+
# Postgres-backed (shared, multi-instance): tables proxy_agent_keys / _tokens / _calls
|
|
122
|
+
export PROXYAGENT_DATABASE_URL=postgresql://user:pass@host/db # pip install 'proxyagent[postgres]'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Every call is traced in `proxy_agent_calls` with **token usage, latency, and computed
|
|
126
|
+
cost** (per-model pricing, override via `PROXYAGENT_PRICING`). See it live:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
proxyagent usage # totals: requests · tokens · $ cost
|
|
130
|
+
proxyagent logs # per-request trace incl. cost
|
|
131
|
+
```
|
|
132
|
+
|
|
76
133
|
## Security model
|
|
77
134
|
- **Real keys never leave the proxy** — read from env, never persisted, never logged, never returned.
|
|
78
135
|
- **Machine tokens are stored hashed** (SHA-256); plaintext shown once. A stolen DB yields nothing usable.
|
|
@@ -1,28 +1,3 @@
|
|
|
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
1
|
<div align="center">
|
|
27
2
|
|
|
28
3
|
# proxyagent
|
|
@@ -98,6 +73,31 @@ export PROXYAGENT_TOOLS='[{"name":"crm","url":"https://hooks.you.com/crm","heade
|
|
|
98
73
|
# the proxy executes calls to managed tools server-side (keys stay here).
|
|
99
74
|
```
|
|
100
75
|
|
|
76
|
+
## Credentials, storage & cost
|
|
77
|
+
|
|
78
|
+
By default provider keys come from the **environment** and stay local. Or **add** them
|
|
79
|
+
once and they're stored **encrypted** (`proxy_agent_keys`) — locally in SQLite, or in
|
|
80
|
+
**Postgres** if you point at one. Either way the machine never sees them.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export PROXYAGENT_SECRET_KEY=… # enables at-rest encryption (Fernet)
|
|
84
|
+
proxyagent provider add anthropic --key sk-ant-… # stored, encrypted
|
|
85
|
+
proxyagent provider add openai --key sk-… --kind api_key
|
|
86
|
+
# OAuth: store an access token → proxyagent provider add anthropic --key <oauth-token> --kind oauth
|
|
87
|
+
proxyagent provider ls
|
|
88
|
+
|
|
89
|
+
# Postgres-backed (shared, multi-instance): tables proxy_agent_keys / _tokens / _calls
|
|
90
|
+
export PROXYAGENT_DATABASE_URL=postgresql://user:pass@host/db # pip install 'proxyagent[postgres]'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Every call is traced in `proxy_agent_calls` with **token usage, latency, and computed
|
|
94
|
+
cost** (per-model pricing, override via `PROXYAGENT_PRICING`). See it live:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
proxyagent usage # totals: requests · tokens · $ cost
|
|
98
|
+
proxyagent logs # per-request trace incl. cost
|
|
99
|
+
```
|
|
100
|
+
|
|
101
101
|
## Security model
|
|
102
102
|
- **Real keys never leave the proxy** — read from env, never persisted, never logged, never returned.
|
|
103
103
|
- **Machine tokens are stored hashed** (SHA-256); plaintext shown once. A stolen DB yields nothing usable.
|
|
@@ -16,7 +16,7 @@ from typing import Optional
|
|
|
16
16
|
|
|
17
17
|
from .harness import run # noqa: F401 (the headline SDK call)
|
|
18
18
|
|
|
19
|
-
__version__ = "0.
|
|
19
|
+
__version__ = "0.2.0"
|
|
20
20
|
__all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
|
|
21
21
|
|
|
22
22
|
|
|
@@ -61,5 +61,21 @@ class Admin:
|
|
|
61
61
|
def revoke(self, token_id: str) -> None:
|
|
62
62
|
self._c.delete(f"/admin/tokens/{token_id}").raise_for_status()
|
|
63
63
|
|
|
64
|
+
def add_provider(self, provider: str, secret: str, kind: str = "api_key",
|
|
65
|
+
label: Optional[str] = None) -> str:
|
|
66
|
+
r = self._c.post("/admin/providers", json={
|
|
67
|
+
"provider": provider, "secret": secret, "kind": kind, "label": label})
|
|
68
|
+
r.raise_for_status()
|
|
69
|
+
return r.json()["id"]
|
|
70
|
+
|
|
71
|
+
def providers(self) -> dict:
|
|
72
|
+
return self._c.get("/admin/providers").json()
|
|
73
|
+
|
|
74
|
+
def remove_provider(self, cred_id: str) -> None:
|
|
75
|
+
self._c.delete(f"/admin/providers/{cred_id}").raise_for_status()
|
|
76
|
+
|
|
64
77
|
def logs(self, limit: int = 100) -> list:
|
|
65
78
|
return self._c.get("/admin/logs", params={"limit": limit}).json()["logs"]
|
|
79
|
+
|
|
80
|
+
def usage(self) -> dict:
|
|
81
|
+
return self._c.get("/admin/usage").json()
|
|
@@ -68,6 +68,58 @@ def run_harness(
|
|
|
68
68
|
|
|
69
69
|
token_app = typer.Typer(help="Mint / list / revoke machine tokens.")
|
|
70
70
|
app.add_typer(token_app, name="token")
|
|
71
|
+
provider_app = typer.Typer(help="Add / list / remove provider credentials (stored, encrypted).")
|
|
72
|
+
app.add_typer(provider_app, name="provider")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@provider_app.command("add")
|
|
76
|
+
def provider_add(
|
|
77
|
+
provider: str = typer.Argument(..., help="anthropic | openai"),
|
|
78
|
+
key: str = typer.Option(..., "--key", "--secret", help="API key, or OAuth access token with --kind oauth."),
|
|
79
|
+
kind: str = typer.Option("api_key", "--kind", help="api_key | oauth"),
|
|
80
|
+
label: str = typer.Option(None, "--label"),
|
|
81
|
+
proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
82
|
+
admin: str = typer.Option(None, "--admin"),
|
|
83
|
+
):
|
|
84
|
+
"""Store a provider credential (encrypted if PROXYAGENT_SECRET_KEY is set)."""
|
|
85
|
+
with _admin_client(proxy, admin) as c:
|
|
86
|
+
r = c.post("/admin/providers", json={"provider": provider, "secret": key,
|
|
87
|
+
"kind": kind, "label": label})
|
|
88
|
+
if r.status_code >= 400:
|
|
89
|
+
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
90
|
+
d = r.json()
|
|
91
|
+
note = "[green]encrypted[/green]" if d["stored"] == "encrypted" else "[yellow]plaintext — set PROXYAGENT_SECRET_KEY[/yellow]"
|
|
92
|
+
console.print(f"[green]✓[/green] stored [cyan]{provider}[/cyan] ({kind}) · {note}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@provider_app.command("ls")
|
|
96
|
+
def provider_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
97
|
+
admin: str = typer.Option(None, "--admin")):
|
|
98
|
+
"""List stored provider credentials (secrets never shown)."""
|
|
99
|
+
with _admin_client(proxy, admin) as c:
|
|
100
|
+
r = c.get("/admin/providers")
|
|
101
|
+
if r.status_code >= 400:
|
|
102
|
+
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
103
|
+
d = r.json()
|
|
104
|
+
t = Table(title=f"Provider credentials · encryption {'on' if d['encryption'] else 'OFF'}")
|
|
105
|
+
for col in ("ID", "Provider", "Kind", "Label", "Active"):
|
|
106
|
+
t.add_column(col)
|
|
107
|
+
for k in d["credentials"]:
|
|
108
|
+
t.add_row(k["id"], k["provider"], k["kind"], k.get("label") or "",
|
|
109
|
+
"[green]yes[/green]" if k["active"] else "no")
|
|
110
|
+
console.print(t)
|
|
111
|
+
console.print(f"[dim]configured (env+stored): {', '.join(d['configured']) or 'none'}[/dim]")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@provider_app.command("rm")
|
|
115
|
+
def provider_rm(cred_id: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
116
|
+
admin: str = typer.Option(None, "--admin")):
|
|
117
|
+
"""Remove a stored credential."""
|
|
118
|
+
with _admin_client(proxy, admin) as c:
|
|
119
|
+
r = c.delete(f"/admin/providers/{cred_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] removed {cred_id}")
|
|
71
123
|
|
|
72
124
|
|
|
73
125
|
@token_app.command("new")
|
|
@@ -132,14 +184,35 @@ def logs(limit: int = 50, proxy: str = typer.Option("http://127.0.0.1:8080", "--
|
|
|
132
184
|
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
133
185
|
rows = r.json()["logs"]
|
|
134
186
|
t = Table(title="Requests")
|
|
135
|
-
for col in ("Token", "Provider", "Model", "Status", "In", "Out", "ms"):
|
|
187
|
+
for col in ("Token", "Provider", "Model", "Status", "In", "Out", "Cost", "ms"):
|
|
136
188
|
t.add_column(col)
|
|
137
189
|
for g in rows:
|
|
190
|
+
cost = g.get("cost_usd")
|
|
138
191
|
t.add_row(g.get("token_label") or "", g.get("provider") or "", (g.get("model") or "")[:28],
|
|
139
192
|
str(g.get("status") or ""), str(g.get("prompt_tokens") or "-"),
|
|
140
|
-
str(g.get("completion_tokens") or "-"),
|
|
193
|
+
str(g.get("completion_tokens") or "-"),
|
|
194
|
+
f"${cost:.4f}" if cost else "-", str(g.get("latency_ms") or ""))
|
|
141
195
|
console.print(t)
|
|
142
196
|
|
|
143
197
|
|
|
198
|
+
@app.command()
|
|
199
|
+
def usage(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
200
|
+
admin: str = typer.Option(None, "--admin")):
|
|
201
|
+
"""Totals: requests, tokens, and cost across all proxied calls."""
|
|
202
|
+
with _admin_client(proxy, admin) as c:
|
|
203
|
+
r = c.get("/admin/usage")
|
|
204
|
+
if r.status_code >= 400:
|
|
205
|
+
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
206
|
+
d = r.json()
|
|
207
|
+
u = d["usage"]
|
|
208
|
+
console.print(Panel.fit(
|
|
209
|
+
f"[bold]{u['requests']}[/bold] requests "
|
|
210
|
+
f"[bold]{u['prompt_tokens']:,}[/bold] in · [bold]{u['completion_tokens']:,}[/bold] out "
|
|
211
|
+
f"[green]${u.get('cost_usd', 0):.4f}[/green]\n"
|
|
212
|
+
f"[dim]backend: {d.get('backend')} · providers: {', '.join(d['providers']) or 'none'} · "
|
|
213
|
+
f"encryption: {'on' if d.get('encryption') else 'off'}[/dim]",
|
|
214
|
+
title="usage", border_style="green"))
|
|
215
|
+
|
|
216
|
+
|
|
144
217
|
if __name__ == "__main__":
|
|
145
218
|
app()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""At-rest encryption for stored provider credentials.
|
|
2
|
+
|
|
3
|
+
If `PROXYAGENT_SECRET_KEY` is set (and `cryptography` is installed), provider secrets
|
|
4
|
+
are encrypted with Fernet before they touch the database. Without a key, secrets are
|
|
5
|
+
stored as-is and we warn loudly — fine for a laptop, not for a shared Postgres.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
_PREFIX = "enc:"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fernet():
|
|
18
|
+
key = os.environ.get("PROXYAGENT_SECRET_KEY")
|
|
19
|
+
if not key:
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
from cryptography.fernet import Fernet
|
|
23
|
+
except ImportError:
|
|
24
|
+
return None
|
|
25
|
+
derived = base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest())
|
|
26
|
+
return Fernet(derived)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def encryption_available() -> bool:
|
|
30
|
+
return _fernet() is not None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encrypt(value: str) -> str:
|
|
34
|
+
f = _fernet()
|
|
35
|
+
return _PREFIX + f.encrypt(value.encode()).decode() if f else value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def decrypt(value: str) -> str:
|
|
39
|
+
if value and value.startswith(_PREFIX):
|
|
40
|
+
f = _fernet()
|
|
41
|
+
if not f:
|
|
42
|
+
raise RuntimeError("PROXYAGENT_SECRET_KEY required to decrypt a stored credential")
|
|
43
|
+
return f.decrypt(value[len(_PREFIX):].encode()).decode()
|
|
44
|
+
return value
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Storage backend — SQLite by default (local), or Postgres when a URL is given.
|
|
2
|
+
|
|
3
|
+
Set `PROXYAGENT_DATABASE_URL=postgresql://…` (or pass `database_url=`) and everything
|
|
4
|
+
lands in Postgres; otherwise it's a local SQLite file. Tables are prefixed
|
|
5
|
+
`proxy_agent_` so they sit cleanly in a shared database.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def database_url() -> str | None:
|
|
16
|
+
return os.environ.get("PROXYAGENT_DATABASE_URL") or os.environ.get("DATABASE_URL")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_postgres(url: str | None) -> bool:
|
|
20
|
+
return bool(url) and url.startswith(("postgres://", "postgresql://"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DB:
|
|
24
|
+
"""Tiny cross-backend wrapper. Use `?` placeholders everywhere; we translate for
|
|
25
|
+
Postgres. Thread-safe via a lock (fine for a proxy's scale)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, sqlite_path: str = ":memory:", url: str | None = None):
|
|
28
|
+
self._lock = threading.RLock()
|
|
29
|
+
self.url = url if url is not None else database_url()
|
|
30
|
+
self.pg = is_postgres(self.url)
|
|
31
|
+
if self.pg:
|
|
32
|
+
import psycopg # type: ignore
|
|
33
|
+
self._conn = psycopg.connect(self.url, autocommit=True)
|
|
34
|
+
else:
|
|
35
|
+
self._conn = sqlite3.connect(sqlite_path, check_same_thread=False)
|
|
36
|
+
self._conn.row_factory = sqlite3.Row
|
|
37
|
+
|
|
38
|
+
def _q(self, sql: str) -> str:
|
|
39
|
+
return sql.replace("?", "%s") if self.pg else sql
|
|
40
|
+
|
|
41
|
+
def execute(self, sql: str, params: tuple = ()): # returns rowcount-ish handle
|
|
42
|
+
with self._lock:
|
|
43
|
+
if self.pg:
|
|
44
|
+
cur = self._conn.cursor()
|
|
45
|
+
cur.execute(self._q(sql), params)
|
|
46
|
+
return cur
|
|
47
|
+
cur = self._conn.execute(sql, params)
|
|
48
|
+
self._conn.commit()
|
|
49
|
+
return cur
|
|
50
|
+
|
|
51
|
+
def fetchone(self, sql: str, params: tuple = ()) -> dict | None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
if self.pg:
|
|
54
|
+
cur = self._conn.cursor()
|
|
55
|
+
cur.execute(self._q(sql), params)
|
|
56
|
+
row = cur.fetchone()
|
|
57
|
+
if not row:
|
|
58
|
+
return None
|
|
59
|
+
cols = [d[0] for d in cur.description]
|
|
60
|
+
return dict(zip(cols, row))
|
|
61
|
+
r = self._conn.execute(sql, params).fetchone()
|
|
62
|
+
return dict(r) if r else None
|
|
63
|
+
|
|
64
|
+
def fetchall(self, sql: str, params: tuple = ()) -> list[dict]:
|
|
65
|
+
with self._lock:
|
|
66
|
+
if self.pg:
|
|
67
|
+
cur = self._conn.cursor()
|
|
68
|
+
cur.execute(self._q(sql), params)
|
|
69
|
+
rows = cur.fetchall()
|
|
70
|
+
cols = [d[0] for d in cur.description]
|
|
71
|
+
return [dict(zip(cols, r)) for r in rows]
|
|
72
|
+
rows = self._conn.execute(sql, params).fetchall()
|
|
73
|
+
return [dict(r) for r in rows]
|
|
74
|
+
|
|
75
|
+
def executescript(self, script: str) -> None:
|
|
76
|
+
with self._lock:
|
|
77
|
+
if self.pg:
|
|
78
|
+
cur = self._conn.cursor()
|
|
79
|
+
for stmt in [s for s in script.split(";") if s.strip()]:
|
|
80
|
+
cur.execute(stmt)
|
|
81
|
+
else:
|
|
82
|
+
self._conn.executescript(script)
|
|
83
|
+
self._conn.commit()
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._conn.close()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Cost tracking — turn token usage into dollars.
|
|
2
|
+
|
|
3
|
+
Prices are USD per 1M tokens (input, output). Matched by a prefix of the model name,
|
|
4
|
+
so "claude-sonnet-4-5-2025…" picks up "claude-sonnet". Override / extend via
|
|
5
|
+
`PROXYAGENT_PRICING` (JSON: {"model-prefix": [in_per_mtok, out_per_mtok]}).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
# Indicative list prices (USD / 1M tokens). Tune freely.
|
|
14
|
+
DEFAULT_PRICES: dict[str, tuple[float, float]] = {
|
|
15
|
+
# Anthropic
|
|
16
|
+
"claude-opus": (15.0, 75.0),
|
|
17
|
+
"claude-sonnet": (3.0, 15.0),
|
|
18
|
+
"claude-haiku": (0.80, 4.0),
|
|
19
|
+
"claude-3-opus": (15.0, 75.0),
|
|
20
|
+
"claude-3-5-sonnet": (3.0, 15.0),
|
|
21
|
+
"claude-3-5-haiku": (0.80, 4.0),
|
|
22
|
+
# OpenAI
|
|
23
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
24
|
+
"gpt-4o": (2.50, 10.0),
|
|
25
|
+
"gpt-4.1-mini": (0.40, 1.60),
|
|
26
|
+
"gpt-4.1": (2.0, 8.0),
|
|
27
|
+
"o3-mini": (1.10, 4.40),
|
|
28
|
+
"o3": (2.0, 8.0),
|
|
29
|
+
"gpt-5": (1.25, 10.0),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _prices() -> dict[str, tuple[float, float]]:
|
|
34
|
+
prices = dict(DEFAULT_PRICES)
|
|
35
|
+
raw = os.environ.get("PROXYAGENT_PRICING")
|
|
36
|
+
if raw:
|
|
37
|
+
try:
|
|
38
|
+
for k, v in json.loads(raw).items():
|
|
39
|
+
prices[k] = (float(v[0]), float(v[1]))
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return prices
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def price_for(model: str) -> tuple[float, float] | None:
|
|
46
|
+
if not model:
|
|
47
|
+
return None
|
|
48
|
+
prices = _prices()
|
|
49
|
+
# longest matching prefix wins (so gpt-4o-mini beats gpt-4o)
|
|
50
|
+
best = None
|
|
51
|
+
for prefix, p in prices.items():
|
|
52
|
+
if model.startswith(prefix) and (best is None or len(prefix) > len(best[0])):
|
|
53
|
+
best = (prefix, p)
|
|
54
|
+
return best[1] if best else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cost_usd(model: str, input_tokens: int | None, output_tokens: int | None) -> float | None:
|
|
58
|
+
p = price_for(model or "")
|
|
59
|
+
if p is None:
|
|
60
|
+
return None
|
|
61
|
+
cin, cout = p
|
|
62
|
+
return round((input_tokens or 0) / 1e6 * cin + (output_tokens or 0) / 1e6 * cout, 6)
|
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
|
|
13
13
|
import httpx
|
|
14
14
|
|
|
15
|
+
from . import pricing
|
|
15
16
|
from .config import Config, PROVIDERS
|
|
16
17
|
from .store import Store, now_ms
|
|
17
18
|
|
|
@@ -22,6 +23,21 @@ ROUTES = {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
def resolve_auth(provider, store: Store | None) -> tuple[dict, bool]:
|
|
27
|
+
"""Auth headers for an upstream call. A stored credential (proxy_agent_keys) wins
|
|
28
|
+
over the env key; returns ({}, False) when nothing is configured."""
|
|
29
|
+
cred = store.get_credential(provider.name) if store else None
|
|
30
|
+
if cred:
|
|
31
|
+
secret, kind = cred["secret"], cred["kind"]
|
|
32
|
+
if kind == "oauth":
|
|
33
|
+
return {"Authorization": f"Bearer {secret}", **provider.extra_headers}, True
|
|
34
|
+
if provider.auth_style == "x-api-key":
|
|
35
|
+
return {"x-api-key": secret, **provider.extra_headers}, True
|
|
36
|
+
return {"Authorization": f"Bearer {secret}", **provider.extra_headers}, True
|
|
37
|
+
headers = provider.auth_headers()
|
|
38
|
+
return headers, bool(headers)
|
|
39
|
+
|
|
40
|
+
|
|
25
41
|
def scope_allows(scope: list[str], provider: str, model: str) -> bool:
|
|
26
42
|
"""A scope entry is a glob over 'provider:model', e.g. 'anthropic:claude-*', or '*'."""
|
|
27
43
|
target = f"{provider}:{model or '*'}"
|
|
@@ -44,11 +60,13 @@ async def forward(
|
|
|
44
60
|
):
|
|
45
61
|
"""Forward a request upstream. Returns (status, headers, body_iter_or_dict, log_after)."""
|
|
46
62
|
provider = PROVIDERS[provider_name]
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
auth, ok = resolve_auth(provider, store)
|
|
64
|
+
if not ok:
|
|
65
|
+
return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy "
|
|
66
|
+
f"(set {provider.key_env} or `proxyagent provider add {provider_name}`)"}, None
|
|
49
67
|
|
|
50
68
|
url = provider.base_url + upstream_path
|
|
51
|
-
headers = {"content-type": "application/json", **
|
|
69
|
+
headers = {"content-type": "application/json", **auth}
|
|
52
70
|
model = body.get("model", "")
|
|
53
71
|
t0 = now_ms()
|
|
54
72
|
|
|
@@ -57,7 +75,8 @@ async def forward(
|
|
|
57
75
|
token_id=token["id"], token_label=token.get("label"), provider=provider_name,
|
|
58
76
|
model=model, status=status, prompt_tokens=ptok, completion_tokens=ctok,
|
|
59
77
|
latency_ms=now_ms() - t0, streamed=1 if streaming else 0,
|
|
60
|
-
tools_used=json.dumps(tools_used or []),
|
|
78
|
+
tools_used=json.dumps(tools_used or []), cost_usd=pricing.cost_usd(model, ptok, ctok),
|
|
79
|
+
error=err,
|
|
61
80
|
)
|
|
62
81
|
|
|
63
82
|
if streaming:
|
|
@@ -14,6 +14,7 @@ from fastapi import FastAPI, Header, HTTPException, Request
|
|
|
14
14
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
|
+
from . import crypto
|
|
17
18
|
from .config import Config, PROVIDERS
|
|
18
19
|
from .providers import ROUTES, forward, scope_allows
|
|
19
20
|
from .security import token_matches
|
|
@@ -30,6 +31,14 @@ class TokenBody(BaseModel):
|
|
|
30
31
|
rate_limit: int = 0
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
class ProviderBody(BaseModel):
|
|
35
|
+
provider: str
|
|
36
|
+
secret: str
|
|
37
|
+
kind: str = "api_key" # api_key | oauth
|
|
38
|
+
label: str | None = None
|
|
39
|
+
refresh: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
33
42
|
def create_app(config: Config | None = None) -> FastAPI:
|
|
34
43
|
config = config or Config.load()
|
|
35
44
|
store = Store(config.db_path)
|
|
@@ -163,17 +172,50 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
163
172
|
require_admin(authorization, x_admin_token)
|
|
164
173
|
return {"logs": store.list_logs(limit)}
|
|
165
174
|
|
|
175
|
+
def _configured() -> list[str]:
|
|
176
|
+
env = set(config.configured_providers())
|
|
177
|
+
db = {c["provider"] for c in store.list_credentials() if c["active"]}
|
|
178
|
+
return sorted(env | db)
|
|
179
|
+
|
|
166
180
|
@app.get("/admin/usage")
|
|
167
181
|
async def usage_ep(authorization: str | None = Header(None),
|
|
168
182
|
x_admin_token: str | None = Header(None)):
|
|
169
183
|
require_admin(authorization, x_admin_token)
|
|
170
|
-
return {"usage": store.usage_summary(),
|
|
171
|
-
"
|
|
172
|
-
"
|
|
184
|
+
return {"usage": store.usage_summary(), "providers": _configured(),
|
|
185
|
+
"tools": tools.list(), "backend": store.backend,
|
|
186
|
+
"encryption": crypto.encryption_available()}
|
|
187
|
+
|
|
188
|
+
# -- provider credentials (proxy_agent_keys) -------------------------- #
|
|
189
|
+
@app.post("/admin/providers")
|
|
190
|
+
async def add_provider(body: ProviderBody, authorization: str | None = Header(None),
|
|
191
|
+
x_admin_token: str | None = Header(None)):
|
|
192
|
+
require_admin(authorization, x_admin_token)
|
|
193
|
+
if body.provider not in PROVIDERS:
|
|
194
|
+
raise HTTPException(400, f"unknown provider; known: {list(PROVIDERS)}")
|
|
195
|
+
cid = store.add_credential(body.provider, body.secret, kind=body.kind,
|
|
196
|
+
label=body.label, refresh=body.refresh)
|
|
197
|
+
return {"id": cid, "provider": body.provider, "kind": body.kind,
|
|
198
|
+
"stored": "encrypted" if crypto.encryption_available() else "plaintext"}
|
|
199
|
+
|
|
200
|
+
@app.get("/admin/providers")
|
|
201
|
+
async def list_providers(authorization: str | None = Header(None),
|
|
202
|
+
x_admin_token: str | None = Header(None)):
|
|
203
|
+
require_admin(authorization, x_admin_token)
|
|
204
|
+
return {"credentials": store.list_credentials(), "configured": _configured(),
|
|
205
|
+
"encryption": crypto.encryption_available()}
|
|
206
|
+
|
|
207
|
+
@app.delete("/admin/providers/{cid}")
|
|
208
|
+
async def del_provider(cid: str, authorization: str | None = Header(None),
|
|
209
|
+
x_admin_token: str | None = Header(None)):
|
|
210
|
+
require_admin(authorization, x_admin_token)
|
|
211
|
+
if not store.remove_credential(cid):
|
|
212
|
+
raise HTTPException(404, "no such credential")
|
|
213
|
+
return {"ok": True}
|
|
173
214
|
|
|
174
215
|
@app.get("/healthz")
|
|
175
216
|
async def healthz():
|
|
176
|
-
return {"ok": True, "providers":
|
|
217
|
+
return {"ok": True, "providers": _configured(), "tools": tools.names(),
|
|
218
|
+
"backend": store.backend}
|
|
177
219
|
|
|
178
220
|
# ------------------------------------------------------------------ #
|
|
179
221
|
# Dashboard
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Persistence — machine tokens, stored provider credentials, and call traces+cost.
|
|
2
|
+
|
|
3
|
+
Backend is SQLite (local) or Postgres (via URL) — see db.py. Tables:
|
|
4
|
+
* proxy_agent_tokens — machine tokens (hashed)
|
|
5
|
+
* proxy_agent_keys — provider credentials you add (api_key / oauth), encrypted
|
|
6
|
+
* proxy_agent_calls — every proxied request: usage, latency, cost, tools, errors
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import crypto
|
|
17
|
+
from .db import DB
|
|
18
|
+
from .security import hash_token, new_token, mask
|
|
19
|
+
|
|
20
|
+
_SCHEMA = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS proxy_agent_tokens (
|
|
22
|
+
id TEXT PRIMARY KEY, hash TEXT NOT NULL UNIQUE, label TEXT,
|
|
23
|
+
scope_json TEXT NOT NULL DEFAULT '["*"]', rate_limit INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
created_ms BIGINT, expires_ms BIGINT, revoked INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
last_used_ms BIGINT, masked TEXT
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS proxy_agent_keys (
|
|
28
|
+
id TEXT PRIMARY KEY, provider TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'api_key',
|
|
29
|
+
secret TEXT NOT NULL, refresh TEXT, expires_ms BIGINT, label TEXT,
|
|
30
|
+
created_ms BIGINT, meta_json TEXT, active INTEGER NOT NULL DEFAULT 1
|
|
31
|
+
);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS proxy_agent_calls (
|
|
33
|
+
id TEXT PRIMARY KEY, ts_ms BIGINT, token_id TEXT, token_label TEXT,
|
|
34
|
+
provider TEXT, model TEXT, status INTEGER,
|
|
35
|
+
prompt_tokens INTEGER, completion_tokens INTEGER, latency_ms INTEGER,
|
|
36
|
+
streamed INTEGER, tools_used TEXT, cost_usd DOUBLE PRECISION, error TEXT
|
|
37
|
+
);
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def now_ms() -> int:
|
|
42
|
+
return int(time.time() * 1000)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Store:
|
|
46
|
+
def __init__(self, path: str | Path = ":memory:", url: str | None = None):
|
|
47
|
+
self.db = DB(str(path), url=url)
|
|
48
|
+
self.db.executescript(_SCHEMA)
|
|
49
|
+
self.backend = "postgres" if self.db.pg else "sqlite"
|
|
50
|
+
|
|
51
|
+
# -- machine tokens ---------------------------------------------------- #
|
|
52
|
+
|
|
53
|
+
def create_token(self, label, scope, *, ttl_seconds=None, rate_limit=0):
|
|
54
|
+
plain = new_token()
|
|
55
|
+
tid = "tok_" + uuid.uuid4().hex[:12]
|
|
56
|
+
expires = now_ms() + ttl_seconds * 1000 if ttl_seconds else None
|
|
57
|
+
self.db.execute(
|
|
58
|
+
"""INSERT INTO proxy_agent_tokens
|
|
59
|
+
(id, hash, label, scope_json, rate_limit, created_ms, expires_ms, masked)
|
|
60
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
61
|
+
(tid, hash_token(plain), label, json.dumps(scope), rate_limit, now_ms(),
|
|
62
|
+
expires, mask(plain)),
|
|
63
|
+
)
|
|
64
|
+
return plain, self.get_token(tid)
|
|
65
|
+
|
|
66
|
+
def get_token(self, tid):
|
|
67
|
+
return self.db.fetchone("SELECT * FROM proxy_agent_tokens WHERE id=?", (tid,))
|
|
68
|
+
|
|
69
|
+
def get_token_by_hash(self, h):
|
|
70
|
+
return self.db.fetchone("SELECT * FROM proxy_agent_tokens WHERE hash=?", (h,))
|
|
71
|
+
|
|
72
|
+
def list_tokens(self):
|
|
73
|
+
return self.db.fetchall("SELECT * FROM proxy_agent_tokens ORDER BY created_ms DESC")
|
|
74
|
+
|
|
75
|
+
def revoke_token(self, tid):
|
|
76
|
+
cur = self.db.execute("UPDATE proxy_agent_tokens SET revoked=1 WHERE id=?", (tid,))
|
|
77
|
+
return cur.rowcount > 0
|
|
78
|
+
|
|
79
|
+
def touch_token(self, tid):
|
|
80
|
+
self.db.execute("UPDATE proxy_agent_tokens SET last_used_ms=? WHERE id=?", (now_ms(), tid))
|
|
81
|
+
|
|
82
|
+
def recent_request_count(self, tid, window_ms=60_000):
|
|
83
|
+
r = self.db.fetchone(
|
|
84
|
+
"SELECT COUNT(*) c FROM proxy_agent_calls WHERE token_id=? AND ts_ms>=?",
|
|
85
|
+
(tid, now_ms() - window_ms))
|
|
86
|
+
return (r or {}).get("c", 0)
|
|
87
|
+
|
|
88
|
+
# -- provider credentials (proxy_agent_keys) --------------------------- #
|
|
89
|
+
|
|
90
|
+
def add_credential(self, provider, secret, *, kind="api_key", refresh=None,
|
|
91
|
+
expires_ms=None, label=None, meta=None):
|
|
92
|
+
cid = "key_" + uuid.uuid4().hex[:12]
|
|
93
|
+
# one active credential per provider: deactivate older ones
|
|
94
|
+
self.db.execute("UPDATE proxy_agent_keys SET active=0 WHERE provider=?", (provider,))
|
|
95
|
+
self.db.execute(
|
|
96
|
+
"""INSERT INTO proxy_agent_keys
|
|
97
|
+
(id, provider, kind, secret, refresh, expires_ms, label, created_ms, meta_json, active)
|
|
98
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)""",
|
|
99
|
+
(cid, provider, kind, crypto.encrypt(secret),
|
|
100
|
+
crypto.encrypt(refresh) if refresh else None, expires_ms, label, now_ms(),
|
|
101
|
+
json.dumps(meta or {})),
|
|
102
|
+
)
|
|
103
|
+
return cid
|
|
104
|
+
|
|
105
|
+
def get_credential(self, provider):
|
|
106
|
+
"""Active credential for a provider, decrypted. None → fall back to env."""
|
|
107
|
+
r = self.db.fetchone(
|
|
108
|
+
"SELECT * FROM proxy_agent_keys WHERE provider=? AND active=1 ORDER BY created_ms DESC",
|
|
109
|
+
(provider,))
|
|
110
|
+
if not r:
|
|
111
|
+
return None
|
|
112
|
+
r = dict(r)
|
|
113
|
+
r["secret"] = crypto.decrypt(r["secret"])
|
|
114
|
+
if r.get("refresh"):
|
|
115
|
+
r["refresh"] = crypto.decrypt(r["refresh"])
|
|
116
|
+
return r
|
|
117
|
+
|
|
118
|
+
def list_credentials(self):
|
|
119
|
+
rows = self.db.fetchall("SELECT * FROM proxy_agent_keys ORDER BY created_ms DESC")
|
|
120
|
+
# never return the secret material
|
|
121
|
+
return [{"id": r["id"], "provider": r["provider"], "kind": r["kind"],
|
|
122
|
+
"label": r["label"], "active": bool(r["active"]),
|
|
123
|
+
"created_ms": r["created_ms"]} for r in rows]
|
|
124
|
+
|
|
125
|
+
def remove_credential(self, cid):
|
|
126
|
+
cur = self.db.execute("DELETE FROM proxy_agent_keys WHERE id=?", (cid,))
|
|
127
|
+
return cur.rowcount > 0
|
|
128
|
+
|
|
129
|
+
# -- call traces (proxy_agent_calls) ----------------------------------- #
|
|
130
|
+
|
|
131
|
+
def log_request(self, **kw):
|
|
132
|
+
kw.setdefault("id", "call_" + uuid.uuid4().hex[:12])
|
|
133
|
+
kw.setdefault("ts_ms", now_ms())
|
|
134
|
+
cols = ["id", "ts_ms", "token_id", "token_label", "provider", "model", "status",
|
|
135
|
+
"prompt_tokens", "completion_tokens", "latency_ms", "streamed",
|
|
136
|
+
"tools_used", "cost_usd", "error"]
|
|
137
|
+
self.db.execute(
|
|
138
|
+
f"INSERT INTO proxy_agent_calls ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})",
|
|
139
|
+
tuple(kw.get(c) for c in cols))
|
|
140
|
+
|
|
141
|
+
def list_logs(self, limit=200):
|
|
142
|
+
return self.db.fetchall("SELECT * FROM proxy_agent_calls ORDER BY ts_ms DESC LIMIT ?", (limit,))
|
|
143
|
+
|
|
144
|
+
def usage_summary(self):
|
|
145
|
+
r = self.db.fetchone(
|
|
146
|
+
"""SELECT COUNT(*) requests,
|
|
147
|
+
COALESCE(SUM(prompt_tokens),0) prompt_tokens,
|
|
148
|
+
COALESCE(SUM(completion_tokens),0) completion_tokens,
|
|
149
|
+
COALESCE(SUM(cost_usd),0) cost_usd
|
|
150
|
+
FROM proxy_agent_calls""")
|
|
151
|
+
return r or {"requests": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0}
|
|
152
|
+
|
|
153
|
+
def close(self):
|
|
154
|
+
self.db.close()
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
<div class="card stat"><div class="n" id="s_req">0</div><div class="l">Requests</div></div>
|
|
47
47
|
<div class="card stat"><div class="n" id="s_in">0</div><div class="l">Input tokens</div></div>
|
|
48
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_cost" style="color:var(--grn)">$0</div><div class="l">Cost</div></div>
|
|
49
50
|
<div class="card stat"><div class="n" id="s_tools">0</div><div class="l">Proxied tools</div></div>
|
|
50
51
|
</div>
|
|
51
52
|
|
|
@@ -85,8 +86,9 @@ async function boot() {
|
|
|
85
86
|
document.getElementById("s_req").textContent = u.usage.requests;
|
|
86
87
|
document.getElementById("s_in").textContent = u.usage.prompt_tokens;
|
|
87
88
|
document.getElementById("s_out").textContent = u.usage.completion_tokens;
|
|
89
|
+
document.getElementById("s_cost").textContent = "$" + (u.usage.cost_usd || 0).toFixed(4);
|
|
88
90
|
document.getElementById("s_tools").textContent = (u.tools||[]).length;
|
|
89
|
-
document.getElementById("provs").textContent = "providers:
|
|
91
|
+
document.getElementById("provs").textContent = `${u.backend||"sqlite"} · providers: ` + ((u.providers||[]).join(", ") || "none");
|
|
90
92
|
refreshTokens(); refreshLogs();
|
|
91
93
|
} catch (e) { document.getElementById("gateerr").textContent = "Invalid admin token."; }
|
|
92
94
|
}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxyagent"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "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."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -29,6 +29,9 @@ dependencies = [
|
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
31
|
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
|
|
32
|
+
postgres = ["psycopg[binary]>=3.1"] # store keys/calls in Postgres via PROXYAGENT_DATABASE_URL
|
|
33
|
+
secure = ["cryptography>=42.0"] # encrypt stored provider credentials at rest
|
|
34
|
+
all = ["psycopg[binary]>=3.1", "cryptography>=42.0"]
|
|
32
35
|
|
|
33
36
|
[project.scripts]
|
|
34
37
|
proxyagent = "proxyagent.cli:app"
|
|
@@ -70,3 +70,40 @@ def test_healthz_and_ui():
|
|
|
70
70
|
c = _client()
|
|
71
71
|
assert c.get("/healthz").json()["ok"] is True
|
|
72
72
|
assert "proxyagent" in c.get("/").text
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_pricing():
|
|
76
|
+
from proxyagent.pricing import cost_usd
|
|
77
|
+
# 1M in @ $3, 1M out @ $15 for sonnet
|
|
78
|
+
assert cost_usd("claude-sonnet-4-5", 1_000_000, 1_000_000) == 18.0
|
|
79
|
+
assert cost_usd("gpt-4o-mini", 1_000_000, 0) == 0.15
|
|
80
|
+
assert cost_usd("unknown-model", 100, 100) is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_credential_storage_and_resolution():
|
|
84
|
+
from proxyagent.config import PROVIDERS
|
|
85
|
+
from proxyagent.providers import resolve_auth
|
|
86
|
+
s = Store(":memory:")
|
|
87
|
+
# env fallback when nothing stored
|
|
88
|
+
headers, ok = resolve_auth(PROVIDERS["openai"], s)
|
|
89
|
+
cid = s.add_credential("openai", "sk-real-key", kind="api_key", label="prod")
|
|
90
|
+
cred = s.get_credential("openai")
|
|
91
|
+
assert cred["secret"] == "sk-real-key" # decrypted roundtrip
|
|
92
|
+
# list never leaks the secret
|
|
93
|
+
listed = s.list_credentials()
|
|
94
|
+
assert listed[0]["provider"] == "openai" and "secret" not in listed[0]
|
|
95
|
+
# resolve_auth now uses the stored credential
|
|
96
|
+
headers, ok = resolve_auth(PROVIDERS["openai"], s)
|
|
97
|
+
assert ok and headers["Authorization"] == "Bearer sk-real-key"
|
|
98
|
+
assert s.remove_credential(cid)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_provider_admin_endpoints():
|
|
102
|
+
c = _client()
|
|
103
|
+
r = c.post("/admin/providers", headers=ADMIN, json={"provider": "anthropic", "secret": "sk-ant-x"})
|
|
104
|
+
assert r.status_code == 200 and r.json()["provider"] == "anthropic"
|
|
105
|
+
listed = c.get("/admin/providers", headers=ADMIN).json()
|
|
106
|
+
assert "anthropic" in listed["configured"]
|
|
107
|
+
# unknown provider rejected
|
|
108
|
+
assert c.post("/admin/providers", headers=ADMIN,
|
|
109
|
+
json={"provider": "nope", "secret": "x"}).status_code == 400
|
|
@@ -1,148 +0,0 @@
|
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|