proxyagent 0.1.0__tar.gz → 0.2.1__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 → proxyagent-0.2.1}/PKG-INFO +42 -1
- {proxyagent-0.1.0 → proxyagent-0.2.1}/README.md +34 -0
- {proxyagent-0.1.0 → proxyagent-0.2.1}/proxyagent/__init__.py +17 -1
- {proxyagent-0.1.0 → proxyagent-0.2.1}/proxyagent/cli.py +75 -2
- proxyagent-0.2.1/proxyagent/crypto.py +44 -0
- proxyagent-0.2.1/proxyagent/db.py +87 -0
- proxyagent-0.2.1/proxyagent/pricing.py +64 -0
- proxyagent-0.2.1/proxyagent/providers.py +180 -0
- {proxyagent-0.1.0 → proxyagent-0.2.1}/proxyagent/server.py +46 -4
- proxyagent-0.2.1/proxyagent/store.py +154 -0
- {proxyagent-0.1.0 → proxyagent-0.2.1}/proxyagent/ui/index.html +3 -1
- {proxyagent-0.1.0 → proxyagent-0.2.1}/pyproject.toml +4 -1
- proxyagent-0.2.1/tests/test_proxy.py +128 -0
- proxyagent-0.1.0/proxyagent/providers.py +0 -98
- proxyagent-0.1.0/proxyagent/store.py +0 -148
- proxyagent-0.1.0/tests/test_proxy.py +0 -72
- {proxyagent-0.1.0 → proxyagent-0.2.1}/.gitignore +0 -0
- {proxyagent-0.1.0 → proxyagent-0.2.1}/proxyagent/config.py +0 -0
- {proxyagent-0.1.0 → proxyagent-0.2.1}/proxyagent/harness.py +0 -0
- {proxyagent-0.1.0 → proxyagent-0.2.1}/proxyagent/security.py +0 -0
- {proxyagent-0.1.0 → proxyagent-0.2.1}/proxyagent/tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyagent
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
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
5
|
Project-URL: Homepage, https://github.com/teddyoweh/proxyagent
|
|
6
6
|
Author-email: Spawn Labs <teddy@spawnlabs.ai>
|
|
@@ -18,9 +18,16 @@ Requires-Dist: pydantic>=2.0
|
|
|
18
18
|
Requires-Dist: rich>=13.0
|
|
19
19
|
Requires-Dist: typer>=0.12
|
|
20
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'
|
|
21
24
|
Provides-Extra: dev
|
|
22
25
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
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'
|
|
24
31
|
Description-Content-Type: text/markdown
|
|
25
32
|
|
|
26
33
|
<div align="center">
|
|
@@ -54,6 +61,15 @@ proxy and use the **machine token** as the "api key." The proxy authenticates th
|
|
|
54
61
|
checks its scope, **swaps in the real key**, forwards upstream, and logs the call. The
|
|
55
62
|
machine never sees a real credential.
|
|
56
63
|
|
|
64
|
+
## Try it with zero keys (local)
|
|
65
|
+
```bash
|
|
66
|
+
pip install proxyagent && proxyagent serve # prints an admin token
|
|
67
|
+
proxyagent token new local --admin pa_admin_… # mint a token
|
|
68
|
+
# call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
|
|
69
|
+
curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
|
|
70
|
+
-d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
|
|
71
|
+
```
|
|
72
|
+
|
|
57
73
|
## Quickstart
|
|
58
74
|
|
|
59
75
|
**1. Run the proxy** (on a box you control — it holds the real keys):
|
|
@@ -98,6 +114,31 @@ export PROXYAGENT_TOOLS='[{"name":"crm","url":"https://hooks.you.com/crm","heade
|
|
|
98
114
|
# the proxy executes calls to managed tools server-side (keys stay here).
|
|
99
115
|
```
|
|
100
116
|
|
|
117
|
+
## Credentials, storage & cost
|
|
118
|
+
|
|
119
|
+
By default provider keys come from the **environment** and stay local. Or **add** them
|
|
120
|
+
once and they're stored **encrypted** (`proxy_agent_keys`) — locally in SQLite, or in
|
|
121
|
+
**Postgres** if you point at one. Either way the machine never sees them.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
export PROXYAGENT_SECRET_KEY=… # enables at-rest encryption (Fernet)
|
|
125
|
+
proxyagent provider add anthropic --key sk-ant-… # stored, encrypted
|
|
126
|
+
proxyagent provider add openai --key sk-… --kind api_key
|
|
127
|
+
# OAuth: store an access token → proxyagent provider add anthropic --key <oauth-token> --kind oauth
|
|
128
|
+
proxyagent provider ls
|
|
129
|
+
|
|
130
|
+
# Postgres-backed (shared, multi-instance): tables proxy_agent_keys / _tokens / _calls
|
|
131
|
+
export PROXYAGENT_DATABASE_URL=postgresql://user:pass@host/db # pip install 'proxyagent[postgres]'
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Every call is traced in `proxy_agent_calls` with **token usage, latency, and computed
|
|
135
|
+
cost** (per-model pricing, override via `PROXYAGENT_PRICING`). See it live:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
proxyagent usage # totals: requests · tokens · $ cost
|
|
139
|
+
proxyagent logs # per-request trace incl. cost
|
|
140
|
+
```
|
|
141
|
+
|
|
101
142
|
## Security model
|
|
102
143
|
- **Real keys never leave the proxy** — read from env, never persisted, never logged, never returned.
|
|
103
144
|
- **Machine tokens are stored hashed** (SHA-256); plaintext shown once. A stolen DB yields nothing usable.
|
|
@@ -29,6 +29,15 @@ proxy and use the **machine token** as the "api key." The proxy authenticates th
|
|
|
29
29
|
checks its scope, **swaps in the real key**, forwards upstream, and logs the call. The
|
|
30
30
|
machine never sees a real credential.
|
|
31
31
|
|
|
32
|
+
## Try it with zero keys (local)
|
|
33
|
+
```bash
|
|
34
|
+
pip install proxyagent && proxyagent serve # prints an admin token
|
|
35
|
+
proxyagent token new local --admin pa_admin_… # mint a token
|
|
36
|
+
# call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
|
|
37
|
+
curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
|
|
38
|
+
-d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
|
|
39
|
+
```
|
|
40
|
+
|
|
32
41
|
## Quickstart
|
|
33
42
|
|
|
34
43
|
**1. Run the proxy** (on a box you control — it holds the real keys):
|
|
@@ -73,6 +82,31 @@ export PROXYAGENT_TOOLS='[{"name":"crm","url":"https://hooks.you.com/crm","heade
|
|
|
73
82
|
# the proxy executes calls to managed tools server-side (keys stay here).
|
|
74
83
|
```
|
|
75
84
|
|
|
85
|
+
## Credentials, storage & cost
|
|
86
|
+
|
|
87
|
+
By default provider keys come from the **environment** and stay local. Or **add** them
|
|
88
|
+
once and they're stored **encrypted** (`proxy_agent_keys`) — locally in SQLite, or in
|
|
89
|
+
**Postgres** if you point at one. Either way the machine never sees them.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
export PROXYAGENT_SECRET_KEY=… # enables at-rest encryption (Fernet)
|
|
93
|
+
proxyagent provider add anthropic --key sk-ant-… # stored, encrypted
|
|
94
|
+
proxyagent provider add openai --key sk-… --kind api_key
|
|
95
|
+
# OAuth: store an access token → proxyagent provider add anthropic --key <oauth-token> --kind oauth
|
|
96
|
+
proxyagent provider ls
|
|
97
|
+
|
|
98
|
+
# Postgres-backed (shared, multi-instance): tables proxy_agent_keys / _tokens / _calls
|
|
99
|
+
export PROXYAGENT_DATABASE_URL=postgresql://user:pass@host/db # pip install 'proxyagent[postgres]'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Every call is traced in `proxy_agent_calls` with **token usage, latency, and computed
|
|
103
|
+
cost** (per-model pricing, override via `PROXYAGENT_PRICING`). See it live:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
proxyagent usage # totals: requests · tokens · $ cost
|
|
107
|
+
proxyagent logs # per-request trace incl. cost
|
|
108
|
+
```
|
|
109
|
+
|
|
76
110
|
## Security model
|
|
77
111
|
- **Real keys never leave the proxy** — read from env, never persisted, never logged, never returned.
|
|
78
112
|
- **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.1
|
|
19
|
+
__version__ = "0.2.1"
|
|
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,64 @@
|
|
|
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
|
+
# offline test provider — free
|
|
31
|
+
"mock": (0.0, 0.0),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _prices() -> dict[str, tuple[float, float]]:
|
|
36
|
+
prices = dict(DEFAULT_PRICES)
|
|
37
|
+
raw = os.environ.get("PROXYAGENT_PRICING")
|
|
38
|
+
if raw:
|
|
39
|
+
try:
|
|
40
|
+
for k, v in json.loads(raw).items():
|
|
41
|
+
prices[k] = (float(v[0]), float(v[1]))
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
return prices
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def price_for(model: str) -> tuple[float, float] | None:
|
|
48
|
+
if not model:
|
|
49
|
+
return None
|
|
50
|
+
prices = _prices()
|
|
51
|
+
# longest matching prefix wins (so gpt-4o-mini beats gpt-4o)
|
|
52
|
+
best = None
|
|
53
|
+
for prefix, p in prices.items():
|
|
54
|
+
if model.startswith(prefix) and (best is None or len(prefix) > len(best[0])):
|
|
55
|
+
best = (prefix, p)
|
|
56
|
+
return best[1] if best else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cost_usd(model: str, input_tokens: int | None, output_tokens: int | None) -> float | None:
|
|
60
|
+
p = price_for(model or "")
|
|
61
|
+
if p is None:
|
|
62
|
+
return None
|
|
63
|
+
cin, cout = p
|
|
64
|
+
return round((input_tokens or 0) / 1e6 * cin + (output_tokens or 0) / 1e6 * cout, 6)
|
|
@@ -0,0 +1,180 @@
|
|
|
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 . import pricing
|
|
16
|
+
from .config import Config, PROVIDERS
|
|
17
|
+
from .store import Store, now_ms
|
|
18
|
+
|
|
19
|
+
# Map our public path → (provider, upstream path).
|
|
20
|
+
ROUTES = {
|
|
21
|
+
"anthropic": ("anthropic", "/v1/messages"),
|
|
22
|
+
"openai": ("openai", "/v1/chat/completions"),
|
|
23
|
+
}
|
|
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
|
+
|
|
41
|
+
def scope_allows(scope: list[str], provider: str, model: str) -> bool:
|
|
42
|
+
"""A scope entry is a glob over 'provider:model', e.g. 'anthropic:claude-*', or '*'."""
|
|
43
|
+
target = f"{provider}:{model or '*'}"
|
|
44
|
+
for entry in scope:
|
|
45
|
+
if entry == "*" or fnmatch.fnmatch(target, entry) or fnmatch.fnmatch(provider, entry):
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract_usage(provider: str, payload: dict) -> tuple[int | None, int | None]:
|
|
51
|
+
u = payload.get("usage") or {}
|
|
52
|
+
if provider == "anthropic":
|
|
53
|
+
return u.get("input_tokens"), u.get("output_tokens")
|
|
54
|
+
return u.get("prompt_tokens"), u.get("completion_tokens")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def forward(
|
|
58
|
+
config: Config, provider_name: str, upstream_path: str, body: dict,
|
|
59
|
+
*, streaming: bool, token: dict, store: Store, tools_used: list[str] | None = None,
|
|
60
|
+
):
|
|
61
|
+
"""Forward a request upstream. Returns (status, headers, body_iter_or_dict, log_after)."""
|
|
62
|
+
provider = PROVIDERS[provider_name]
|
|
63
|
+
model = body.get("model", "")
|
|
64
|
+
t0 = now_ms()
|
|
65
|
+
|
|
66
|
+
# Offline mock — exercise the full pipeline (auth, scope, log, cost) with NO real
|
|
67
|
+
# key. Use model "mock" (or "mock-…") anywhere a real model would go.
|
|
68
|
+
if model.startswith("mock"):
|
|
69
|
+
payload, (ptok, ctok) = _mock_payload(provider_name, body)
|
|
70
|
+
store.log_request(
|
|
71
|
+
token_id=token["id"], token_label=token.get("label"), provider=provider_name,
|
|
72
|
+
model=model, status=200, prompt_tokens=ptok, completion_tokens=ctok,
|
|
73
|
+
latency_ms=now_ms() - t0, streamed=1 if streaming else 0,
|
|
74
|
+
tools_used=json.dumps(tools_used or []), cost_usd=pricing.cost_usd(model, ptok, ctok),
|
|
75
|
+
error=None)
|
|
76
|
+
if streaming:
|
|
77
|
+
return 200, {"content-type": "text/event-stream"}, _mock_stream(provider_name, payload), None
|
|
78
|
+
return 200, {"content-type": "application/json"}, payload, None
|
|
79
|
+
|
|
80
|
+
auth, ok = resolve_auth(provider, store)
|
|
81
|
+
if not ok:
|
|
82
|
+
return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy "
|
|
83
|
+
f"(set {provider.key_env} or `proxyagent provider add {provider_name}`)"}, None
|
|
84
|
+
|
|
85
|
+
url = provider.base_url + upstream_path
|
|
86
|
+
headers = {"content-type": "application/json", **auth}
|
|
87
|
+
|
|
88
|
+
def _log(status, ptok, ctok, err=None):
|
|
89
|
+
store.log_request(
|
|
90
|
+
token_id=token["id"], token_label=token.get("label"), provider=provider_name,
|
|
91
|
+
model=model, status=status, prompt_tokens=ptok, completion_tokens=ctok,
|
|
92
|
+
latency_ms=now_ms() - t0, streamed=1 if streaming else 0,
|
|
93
|
+
tools_used=json.dumps(tools_used or []), cost_usd=pricing.cost_usd(model, ptok, ctok),
|
|
94
|
+
error=err,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if streaming:
|
|
98
|
+
async def _gen():
|
|
99
|
+
ptok = ctok = None
|
|
100
|
+
status = 200
|
|
101
|
+
try:
|
|
102
|
+
async with httpx.AsyncClient(timeout=config.request_timeout) as client:
|
|
103
|
+
async with client.stream("POST", url, headers=headers, json=body) as resp:
|
|
104
|
+
status = resp.status_code
|
|
105
|
+
async for chunk in resp.aiter_raw():
|
|
106
|
+
# Best-effort usage capture from the final SSE event.
|
|
107
|
+
text = chunk.decode("utf-8", "ignore")
|
|
108
|
+
if '"output_tokens"' in text or '"completion_tokens"' in text:
|
|
109
|
+
try:
|
|
110
|
+
for line in text.splitlines():
|
|
111
|
+
if line.startswith("data:"):
|
|
112
|
+
d = json.loads(line[5:].strip())
|
|
113
|
+
usage = d.get("usage") or (d.get("message") or {}).get("usage") or {}
|
|
114
|
+
ptok = usage.get("input_tokens") or usage.get("prompt_tokens") or ptok
|
|
115
|
+
ctok = usage.get("output_tokens") or usage.get("completion_tokens") or ctok
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
yield chunk
|
|
119
|
+
finally:
|
|
120
|
+
_log(status, ptok, ctok)
|
|
121
|
+
return 200, {"content-type": "text/event-stream"}, _gen(), None
|
|
122
|
+
|
|
123
|
+
# Non-streaming.
|
|
124
|
+
async with httpx.AsyncClient(timeout=config.request_timeout) as client:
|
|
125
|
+
resp = await client.post(url, headers=headers, json=body)
|
|
126
|
+
try:
|
|
127
|
+
payload = resp.json()
|
|
128
|
+
except Exception:
|
|
129
|
+
payload = {"error": resp.text}
|
|
130
|
+
ptok, ctok = _extract_usage(provider_name, payload if isinstance(payload, dict) else {})
|
|
131
|
+
_log(resp.status_code, ptok, ctok, None if resp.is_success else str(payload)[:300])
|
|
132
|
+
return resp.status_code, {"content-type": "application/json"}, payload, None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------ #
|
|
136
|
+
# Offline mock — provider-shaped canned responses for local testing.
|
|
137
|
+
# ------------------------------------------------------------------ #
|
|
138
|
+
|
|
139
|
+
def _last_user_text(body: dict) -> str:
|
|
140
|
+
for m in reversed(body.get("messages", [])):
|
|
141
|
+
if m.get("role") == "user":
|
|
142
|
+
c = m.get("content")
|
|
143
|
+
if isinstance(c, str):
|
|
144
|
+
return c
|
|
145
|
+
if isinstance(c, list):
|
|
146
|
+
return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _mock_payload(provider: str, body: dict):
|
|
151
|
+
prompt = _last_user_text(body)[:200]
|
|
152
|
+
text = f"[proxyagent mock] received: {prompt!r}. No real key used — the pipeline works."
|
|
153
|
+
ptok, ctok = max(1, len(prompt) // 4), max(1, len(text) // 4)
|
|
154
|
+
if provider == "anthropic":
|
|
155
|
+
return ({
|
|
156
|
+
"id": "msg_mock", "type": "message", "role": "assistant", "model": body.get("model"),
|
|
157
|
+
"content": [{"type": "text", "text": text}], "stop_reason": "end_turn",
|
|
158
|
+
"usage": {"input_tokens": ptok, "output_tokens": ctok},
|
|
159
|
+
}, (ptok, ctok))
|
|
160
|
+
return ({
|
|
161
|
+
"id": "chatcmpl-mock", "object": "chat.completion", "model": body.get("model"),
|
|
162
|
+
"choices": [{"index": 0, "message": {"role": "assistant", "content": text},
|
|
163
|
+
"finish_reason": "stop"}],
|
|
164
|
+
"usage": {"prompt_tokens": ptok, "completion_tokens": ctok, "total_tokens": ptok + ctok},
|
|
165
|
+
}, (ptok, ctok))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _mock_stream(provider: str, payload: dict):
|
|
169
|
+
import json as _j
|
|
170
|
+
if provider == "anthropic":
|
|
171
|
+
text = payload["content"][0]["text"]
|
|
172
|
+
yield f"event: message_start\ndata: {_j.dumps({'type':'message_start','message':payload})}\n\n".encode()
|
|
173
|
+
yield (f"event: content_block_delta\ndata: "
|
|
174
|
+
f"{_j.dumps({'type':'content_block_delta','delta':{'type':'text_delta','text':text}})}\n\n").encode()
|
|
175
|
+
yield b"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
|
176
|
+
else:
|
|
177
|
+
text = payload["choices"][0]["message"]["content"]
|
|
178
|
+
chunk = {"choices": [{"delta": {"content": text}, "index": 0}]}
|
|
179
|
+
yield f"data: {_j.dumps(chunk)}\n\n".encode()
|
|
180
|
+
yield b"data: [DONE]\n\n"
|