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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyagent
3
- Version: 0.1.0
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.0"
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 "-"), 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,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"