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.
@@ -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.1.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 "-"), str(g.get("latency_ms") 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
- if not provider.key:
48
- return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy"}, None
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", **provider.auth_headers()}
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 []), error=err,
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
- "providers": config.configured_providers(),
172
- "tools": tools.list()}
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": config.configured_providers(), "tools": tools.names()}
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: " + ((u.providers||[]).join(", ") || "none");
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.1.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