proxyagent 0.2.1__tar.gz → 0.3.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.2.1
3
+ Version: 0.3.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>
@@ -64,7 +64,7 @@ machine never sees a real credential.
64
64
  ## Try it with zero keys (local)
65
65
  ```bash
66
66
  pip install proxyagent && proxyagent serve # prints an admin token
67
- proxyagent token new local --admin pa_admin_… # mint a token
67
+ proxyagent token new local # works locally, no admin token needed # mint a token
68
68
  # call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
69
69
  curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
70
70
  -d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
@@ -81,7 +81,7 @@ proxyagent serve # prints an admin token + a dashboard at
81
81
 
82
82
  **2. Mint a machine token** (scoped + revocable):
83
83
  ```bash
84
- proxyagent token new macbook-01 --scope "anthropic:claude-*" --admin pa_admin_…
84
+ proxyagent token new macbook-01 --scope "anthropic:claude-*" # local: no admin token needed
85
85
  ```
86
86
 
87
87
  **3. Run any agent on any machine — no real key there:**
@@ -162,6 +162,26 @@ proxyagent.run("claude-code", goal="build the app",
162
162
  proxy="https://proxy.you.com", token=token)
163
163
  ```
164
164
 
165
+ ## Supported providers
166
+ `anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
167
+ `xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
168
+ Point a harness/agent at `https://proxy.you.com/<provider>/v1` and it routes there.
169
+ Add or override any endpoint with `PROXYAGENT_<NAME>_ENDPOINT`.
170
+
171
+ ## Model remap — rename or reroute models
172
+ Rewrite the requested model before forwarding — rename it, or reroute it to a totally
173
+ different provider:
174
+
175
+ ```bash
176
+ proxyagent alias set gpt-4o anthropic:claude-sonnet-4-5 # send "gpt-4o" calls to Claude
177
+ proxyagent alias set '*' mock # force EVERYTHING offline (no keys)
178
+ proxyagent alias ls
179
+ ```
180
+
181
+ The `'*' → mock` trick is the **offline harness** unlock: point `claude-code` at the
182
+ proxy, map everything to `mock`, and it runs end-to-end with zero keys and zero spend —
183
+ perfect for local dev, demos, and CI.
184
+
165
185
  ## Supported harnesses
166
186
  `claude-code`, `codex`, and any **custom** command (`--command "my-agent {goal}"`). Adding one
167
187
  is a few lines — it just needs to respect `*_BASE_URL`.
@@ -32,7 +32,7 @@ machine never sees a real credential.
32
32
  ## Try it with zero keys (local)
33
33
  ```bash
34
34
  pip install proxyagent && proxyagent serve # prints an admin token
35
- proxyagent token new local --admin pa_admin_… # mint a token
35
+ proxyagent token new local # works locally, no admin token needed # mint a token
36
36
  # call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
37
37
  curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
38
38
  -d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
@@ -49,7 +49,7 @@ proxyagent serve # prints an admin token + a dashboard at
49
49
 
50
50
  **2. Mint a machine token** (scoped + revocable):
51
51
  ```bash
52
- proxyagent token new macbook-01 --scope "anthropic:claude-*" --admin pa_admin_…
52
+ proxyagent token new macbook-01 --scope "anthropic:claude-*" # local: no admin token needed
53
53
  ```
54
54
 
55
55
  **3. Run any agent on any machine — no real key there:**
@@ -130,6 +130,26 @@ proxyagent.run("claude-code", goal="build the app",
130
130
  proxy="https://proxy.you.com", token=token)
131
131
  ```
132
132
 
133
+ ## Supported providers
134
+ `anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
135
+ `xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
136
+ Point a harness/agent at `https://proxy.you.com/<provider>/v1` and it routes there.
137
+ Add or override any endpoint with `PROXYAGENT_<NAME>_ENDPOINT`.
138
+
139
+ ## Model remap — rename or reroute models
140
+ Rewrite the requested model before forwarding — rename it, or reroute it to a totally
141
+ different provider:
142
+
143
+ ```bash
144
+ proxyagent alias set gpt-4o anthropic:claude-sonnet-4-5 # send "gpt-4o" calls to Claude
145
+ proxyagent alias set '*' mock # force EVERYTHING offline (no keys)
146
+ proxyagent alias ls
147
+ ```
148
+
149
+ The `'*' → mock` trick is the **offline harness** unlock: point `claude-code` at the
150
+ proxy, map everything to `mock`, and it runs end-to-end with zero keys and zero spend —
151
+ perfect for local dev, demos, and CI.
152
+
133
153
  ## Supported harnesses
134
154
  `claude-code`, `codex`, and any **custom** command (`--command "my-agent {goal}"`). Adding one
135
155
  is a few lines — it just needs to respect `*_BASE_URL`.
@@ -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.2.1"
19
+ __version__ = "0.3.1"
20
20
  __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
21
 
22
22
 
@@ -0,0 +1,49 @@
1
+ """Model remapping — rewrite the requested model (and optionally re-route to another
2
+ provider) before forwarding.
3
+
4
+ A map entry's value is either a model name (rename) or "provider:model" (reroute):
5
+
6
+ PROXYAGENT_MODEL_MAP='{"*": "mock"}' # force everything offline
7
+ PROXYAGENT_MODEL_MAP='{"gpt-4o": "anthropic:claude-sonnet-4-5"}' # reroute to Claude
8
+
9
+ Lookup order: exact "provider:model" → exact "model" → wildcard "*".
10
+ Runtime overrides (set via the admin API) win over the env map.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+
18
+ _RUNTIME: dict[str, str] = {}
19
+
20
+
21
+ def _env_map() -> dict[str, str]:
22
+ raw = os.environ.get("PROXYAGENT_MODEL_MAP")
23
+ if not raw:
24
+ return {}
25
+ try:
26
+ return {str(k): str(v) for k, v in json.loads(raw).items()}
27
+ except Exception:
28
+ return {}
29
+
30
+
31
+ def get_map() -> dict[str, str]:
32
+ return {**_env_map(), **_RUNTIME}
33
+
34
+
35
+ def set_map(m: dict) -> None:
36
+ _RUNTIME.clear()
37
+ _RUNTIME.update({str(k): str(v) for k, v in (m or {}).items()})
38
+
39
+
40
+ def remap(provider: str, model: str) -> tuple[str, str]:
41
+ """Return the (provider, model) to actually use."""
42
+ m = get_map()
43
+ target = m.get(f"{provider}:{model}") or m.get(model) or m.get("*")
44
+ if not target:
45
+ return provider, model
46
+ if ":" in target:
47
+ p, mm = target.split(":", 1)
48
+ return p, mm
49
+ return provider, target
@@ -16,6 +16,9 @@ console = Console()
16
16
  err = Console(stderr=True)
17
17
 
18
18
 
19
+ DEFAULT_PROXY = "http://127.0.0.1:8080"
20
+
21
+
19
22
  def _admin_client(proxy: str, admin: Optional[str]) -> httpx.Client:
20
23
  admin = admin or os.environ.get("PROXYAGENT_ADMIN_TOKEN")
21
24
  if not admin:
@@ -25,6 +28,22 @@ def _admin_client(proxy: str, admin: Optional[str]) -> httpx.Client:
25
28
  return httpx.Client(base_url=proxy.rstrip("/"), headers={"x-admin-token": admin}, timeout=30)
26
29
 
27
30
 
31
+ def _is_remote(proxy: str, admin: Optional[str]) -> bool:
32
+ """Manage a REMOTE proxy (via admin API) only if an admin token is given or the
33
+ proxy isn't localhost. Otherwise operate on the LOCAL store directly — no admin
34
+ token needed, you already have filesystem access."""
35
+ from urllib.parse import urlparse
36
+ if admin or os.environ.get("PROXYAGENT_ADMIN_TOKEN"):
37
+ return True
38
+ return urlparse(proxy).hostname not in (None, "127.0.0.1", "localhost", "0.0.0.0")
39
+
40
+
41
+ def _local_store():
42
+ from .config import Config
43
+ from .store import Store
44
+ return Store(Config.load().db_path)
45
+
46
+
28
47
  @app.command()
29
48
  def serve(host: str = "127.0.0.1", port: int = 8080):
30
49
  """Run the proxy server + dashboard."""
@@ -36,15 +55,27 @@ def serve(host: str = "127.0.0.1", port: int = 8080):
36
55
  config = Config.load()
37
56
  if config.admin_token_plain:
38
57
  console.print(Panel.fit(
39
- f"[green]✓ proxyagent[/green]\n\n[bold]Admin token[/bold] (shown once)\n"
58
+ f"[green]✓ proxyagent[/green]\n\n[bold]Admin token[/bold] (for the dashboard)\n"
40
59
  f" [yellow]{config.admin_token_plain}[/yellow]\n\n"
41
- f"[dim]Save it — you need it for the dashboard + `proxyagent token`.[/dim]",
60
+ f"[dim]Reveal anytime: [bold]proxyagent admin-token[/bold][/dim]",
42
61
  border_style="green"))
43
62
  console.print(f"[dim]Dashboard:[/dim] http://{host}:{port} "
44
- f"[dim]providers:[/dim] {', '.join(config.configured_providers()) or 'none — set ANTHROPIC_API_KEY / OPENAI_API_KEY'}")
63
+ f"[dim]providers:[/dim] {', '.join(config.configured_providers()) or 'none — `proxyagent provider add anthropic --key …`'}")
64
+ console.print("[dim]Mint a machine token in another terminal:[/dim] [bold]proxyagent token new[/bold] [dim](works locally, no admin token needed)[/dim]")
45
65
  uvicorn.run(create_app(config), host=host, port=port, log_level="warning")
46
66
 
47
67
 
68
+ @app.command("admin-token")
69
+ def admin_token():
70
+ """Print this machine's admin token (for the dashboard)."""
71
+ from .config import Config
72
+ cfg = Config.load()
73
+ if cfg.admin_token_plain:
74
+ console.print(cfg.admin_token_plain)
75
+ else:
76
+ err.print("[yellow]Admin token is set via PROXYAGENT_ADMIN_TOKEN (not stored here).[/yellow]")
77
+
78
+
48
79
  @app.command("run")
49
80
  def run_harness(
50
81
  harness: str = typer.Argument(..., help="claude-code | codex | <custom>"),
@@ -70,6 +101,46 @@ token_app = typer.Typer(help="Mint / list / revoke machine tokens.")
70
101
  app.add_typer(token_app, name="token")
71
102
  provider_app = typer.Typer(help="Add / list / remove provider credentials (stored, encrypted).")
72
103
  app.add_typer(provider_app, name="provider")
104
+ alias_app = typer.Typer(help="Model remap — rename or reroute models (e.g. force everything to mock).")
105
+ app.add_typer(alias_app, name="alias")
106
+
107
+
108
+ @alias_app.command("ls")
109
+ def alias_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
110
+ admin: str = typer.Option(None, "--admin")):
111
+ """Show the current model map."""
112
+ with _admin_client(proxy, admin) as c:
113
+ m = c.get("/admin/aliases").json()["map"]
114
+ if not m:
115
+ console.print("[dim]No aliases. e.g. `proxyagent alias set '*' mock`[/dim]"); return
116
+ t = Table(title="Model aliases")
117
+ t.add_column("Match"); t.add_column("→ Target")
118
+ for k, v in m.items():
119
+ t.add_row(k, v)
120
+ console.print(t)
121
+
122
+
123
+ @alias_app.command("set")
124
+ def alias_set(match: str, target: str,
125
+ proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
126
+ admin: str = typer.Option(None, "--admin")):
127
+ """Map a model → a model (rename) or 'provider:model' (reroute). Use '*' to catch all."""
128
+ with _admin_client(proxy, admin) as c:
129
+ m = c.get("/admin/aliases").json()["map"]
130
+ m[match] = target
131
+ c.put("/admin/aliases", json={"map": m})
132
+ console.print(f"[green]✓[/green] [cyan]{match}[/cyan] → {target}")
133
+
134
+
135
+ @alias_app.command("rm")
136
+ def alias_rm(match: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
137
+ admin: str = typer.Option(None, "--admin")):
138
+ """Remove an alias."""
139
+ with _admin_client(proxy, admin) as c:
140
+ m = c.get("/admin/aliases").json()["map"]
141
+ m.pop(match, None)
142
+ c.put("/admin/aliases", json={"map": m})
143
+ console.print(f"[green]✓[/green] removed {match}")
73
144
 
74
145
 
75
146
  @provider_app.command("add")
@@ -82,13 +153,21 @@ def provider_add(
82
153
  admin: str = typer.Option(None, "--admin"),
83
154
  ):
84
155
  """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]"
156
+ from .config import PROVIDERS
157
+ if provider not in PROVIDERS:
158
+ err.print(f"[red]✗[/red] unknown provider; known: {', '.join(PROVIDERS)}"); raise typer.Exit(1)
159
+ if not _is_remote(proxy, admin):
160
+ from . import crypto
161
+ _local_store().add_credential(provider, key, kind=kind, label=label)
162
+ stored = "encrypted" if crypto.encryption_available() else "plaintext"
163
+ else:
164
+ with _admin_client(proxy, admin) as c:
165
+ r = c.post("/admin/providers", json={"provider": provider, "secret": key,
166
+ "kind": kind, "label": label})
167
+ if r.status_code >= 400:
168
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
169
+ stored = r.json()["stored"]
170
+ note = "[green]encrypted[/green]" if stored == "encrypted" else "[yellow]plaintext — set PROXYAGENT_SECRET_KEY[/yellow]"
92
171
  console.print(f"[green]✓[/green] stored [cyan]{provider}[/cyan] ({kind}) · {note}")
93
172
 
94
173
 
@@ -96,11 +175,19 @@ def provider_add(
96
175
  def provider_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
97
176
  admin: str = typer.Option(None, "--admin")):
98
177
  """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()
178
+ if not _is_remote(proxy, admin):
179
+ from . import crypto
180
+ from .config import PROVIDERS
181
+ creds = _local_store().list_credentials()
182
+ configured = sorted({n for n, p in PROVIDERS.items() if p.key}
183
+ | {c["provider"] for c in creds if c["active"]})
184
+ d = {"credentials": creds, "configured": configured, "encryption": crypto.encryption_available()}
185
+ else:
186
+ with _admin_client(proxy, admin) as c:
187
+ r = c.get("/admin/providers")
188
+ if r.status_code >= 400:
189
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
190
+ d = r.json()
104
191
  t = Table(title=f"Provider credentials · encryption {'on' if d['encryption'] else 'OFF'}")
105
192
  for col in ("ID", "Provider", "Kind", "Label", "Active"):
106
193
  t.add_column(col)
@@ -115,10 +202,14 @@ def provider_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
115
202
  def provider_rm(cred_id: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
116
203
  admin: str = typer.Option(None, "--admin")):
117
204
  """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)
205
+ if not _is_remote(proxy, admin):
206
+ if not _local_store().remove_credential(cred_id):
207
+ err.print(f"[red]✗[/red] no such credential"); raise typer.Exit(1)
208
+ else:
209
+ with _admin_client(proxy, admin) as c:
210
+ r = c.delete(f"/admin/providers/{cred_id}")
211
+ if r.status_code >= 400:
212
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
122
213
  console.print(f"[green]✓[/green] removed {cred_id}")
123
214
 
124
215
 
@@ -132,14 +223,17 @@ def token_new(
132
223
  admin: Optional[str] = typer.Option(None, "--admin"),
133
224
  ):
134
225
  """Mint a machine token — give it to a remote machine; it holds no real key."""
135
- with _admin_client(proxy, admin) as c:
136
- r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
137
- "ttl_seconds": ttl, "rate_limit": rate})
138
- if r.status_code >= 400:
139
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
140
- d = r.json()
226
+ if not _is_remote(proxy, admin):
227
+ plain, _ = _local_store().create_token(label, list(scope), ttl_seconds=ttl, rate_limit=rate)
228
+ else:
229
+ with _admin_client(proxy, admin) as c:
230
+ r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
231
+ "ttl_seconds": ttl, "rate_limit": rate})
232
+ if r.status_code >= 400:
233
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
234
+ plain = r.json()["token"]
141
235
  console.print(Panel.fit(
142
- f"[green]✓ machine token[/green] [dim]({label})[/dim]\n\n [yellow]{d['token']}[/yellow]\n\n"
236
+ f"[green]✓ machine token[/green] [dim]({label})[/dim]\n\n [yellow]{plain}[/yellow]\n\n"
143
237
  f"[dim]scope: {', '.join(scope)} · shown once[/dim]", border_style="green"))
144
238
 
145
239
 
@@ -147,11 +241,17 @@ def token_new(
147
241
  def token_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
148
242
  admin: Optional[str] = typer.Option(None, "--admin")):
149
243
  """List machine tokens."""
150
- with _admin_client(proxy, admin) as c:
151
- r = c.get("/admin/tokens")
152
- if r.status_code >= 400:
153
- err.print(f"[red][/red] {r.text}"); raise typer.Exit(1)
154
- rows = r.json()["tokens"]
244
+ if not _is_remote(proxy, admin):
245
+ import json as _json
246
+ rows = [{"id": t["id"], "label": t["label"], "masked": t["masked"],
247
+ "scope": _json.loads(t["scope_json"]), "revoked": t["revoked"]}
248
+ for t in _local_store().list_tokens()]
249
+ else:
250
+ with _admin_client(proxy, admin) as c:
251
+ r = c.get("/admin/tokens")
252
+ if r.status_code >= 400:
253
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
254
+ rows = r.json()["tokens"]
155
255
  if not rows:
156
256
  console.print("[dim]No tokens.[/dim]"); return
157
257
  t = Table(title="Machine tokens")
@@ -167,10 +267,14 @@ def token_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
167
267
  def token_revoke(token_id: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
168
268
  admin: Optional[str] = typer.Option(None, "--admin")):
169
269
  """Revoke a token by id."""
170
- with _admin_client(proxy, admin) as c:
171
- r = c.delete(f"/admin/tokens/{token_id}")
172
- if r.status_code >= 400:
173
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
270
+ if not _is_remote(proxy, admin):
271
+ if not _local_store().revoke_token(token_id):
272
+ err.print(f"[red]✗[/red] no such token"); raise typer.Exit(1)
273
+ else:
274
+ with _admin_client(proxy, admin) as c:
275
+ r = c.delete(f"/admin/tokens/{token_id}")
276
+ if r.status_code >= 400:
277
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
174
278
  console.print(f"[green]✓[/green] revoked {token_id}")
175
279
 
176
280
 
@@ -0,0 +1,95 @@
1
+ """Configuration — provider upstreams, real credentials (env only), paths, admin auth.
2
+
3
+ Real keys are read from the environment and never persisted. The proxy is the ONLY
4
+ place they live.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from .security import hash_token, new_token, ADMIN_PREFIX
14
+
15
+ HOME = Path(os.environ.get("PROXYAGENT_HOME", Path.home() / ".proxyagent"))
16
+
17
+
18
+ @dataclass
19
+ class Provider:
20
+ name: str
21
+ endpoint: str # full upstream URL (e.g. …/v1/chat/completions)
22
+ key_env: str # env var holding the REAL key
23
+ auth_style: str # "bearer" | "x-api-key"
24
+ shape: str # "openai" | "anthropic" (request + usage format)
25
+ extra_headers: dict = field(default_factory=dict)
26
+
27
+ @property
28
+ def key(self) -> str | None:
29
+ return os.environ.get(self.key_env)
30
+
31
+ def auth_headers(self) -> dict:
32
+ key = self.key
33
+ if not key:
34
+ return {}
35
+ if self.auth_style == "x-api-key":
36
+ return {"x-api-key": key, **self.extra_headers}
37
+ return {"Authorization": f"Bearer {key}", **self.extra_headers}
38
+
39
+
40
+ def _p(name, endpoint, key_env, *, shape="openai", style="bearer", extra=None) -> Provider:
41
+ endpoint = os.environ.get(f"PROXYAGENT_{name.upper()}_ENDPOINT", endpoint)
42
+ return Provider(name, endpoint, key_env, style, shape, extra or {})
43
+
44
+
45
+ # Built-in upstreams. Anthropic uses its Messages API; the rest are OpenAI-compatible.
46
+ # Add your own / override endpoints via PROXYAGENT_<NAME>_ENDPOINT.
47
+ PROVIDERS: dict[str, Provider] = {
48
+ "anthropic": _p("anthropic", "https://api.anthropic.com/v1/messages", "ANTHROPIC_API_KEY",
49
+ shape="anthropic", style="x-api-key",
50
+ extra={"anthropic-version": os.environ.get("ANTHROPIC_VERSION", "2023-06-01")}),
51
+ "openai": _p("openai", "https://api.openai.com/v1/chat/completions", "OPENAI_API_KEY"),
52
+ "gemini": _p("gemini", "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", "GEMINI_API_KEY"),
53
+ "groq": _p("groq", "https://api.groq.com/openai/v1/chat/completions", "GROQ_API_KEY"),
54
+ "openrouter": _p("openrouter", "https://openrouter.ai/api/v1/chat/completions", "OPENROUTER_API_KEY"),
55
+ "mistral": _p("mistral", "https://api.mistral.ai/v1/chat/completions", "MISTRAL_API_KEY"),
56
+ "deepseek": _p("deepseek", "https://api.deepseek.com/v1/chat/completions", "DEEPSEEK_API_KEY"),
57
+ "xai": _p("xai", "https://api.x.ai/v1/chat/completions", "XAI_API_KEY"),
58
+ "together": _p("together", "https://api.together.xyz/v1/chat/completions", "TOGETHER_API_KEY"),
59
+ }
60
+
61
+
62
+ @dataclass
63
+ class Config:
64
+ home: Path = HOME
65
+ db_path: str = ""
66
+ admin_token_hash: str = ""
67
+ admin_token_plain: str | None = None # only set when freshly generated
68
+ request_timeout: float = 600.0
69
+
70
+ @classmethod
71
+ def load(cls) -> "Config":
72
+ HOME.mkdir(parents=True, exist_ok=True)
73
+ cfg = cls(db_path=str(HOME / "proxyagent.db"))
74
+ # Admin token: from env, or a persisted one, or freshly generated (shown once).
75
+ env_admin = os.environ.get("PROXYAGENT_ADMIN_TOKEN")
76
+ admin_file = HOME / "admin_token"
77
+ existing = admin_file.read_text().strip() if admin_file.exists() else ""
78
+ if env_admin:
79
+ # Production: trust the env token, persist nothing.
80
+ cfg.admin_token_hash = hash_token(env_admin)
81
+ elif existing.startswith(ADMIN_PREFIX):
82
+ # Local: the plaintext is stored (0600) so the dashboard stays reachable.
83
+ cfg.admin_token_plain = existing
84
+ cfg.admin_token_hash = hash_token(existing)
85
+ else:
86
+ # Fresh (or migrating an old hash-only file we can't recover): regenerate.
87
+ plain = new_token(ADMIN_PREFIX)
88
+ admin_file.write_text(plain)
89
+ admin_file.chmod(0o600)
90
+ cfg.admin_token_plain = plain
91
+ cfg.admin_token_hash = hash_token(plain)
92
+ return cfg
93
+
94
+ def configured_providers(self) -> list[str]:
95
+ return [n for n, p in PROVIDERS.items() if p.key]
@@ -16,13 +16,6 @@ from . import pricing
16
16
  from .config import Config, PROVIDERS
17
17
  from .store import Store, now_ms
18
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
19
  def resolve_auth(provider, store: Store | None) -> tuple[dict, bool]:
27
20
  """Auth headers for an upstream call. A stored credential (proxy_agent_keys) wins
28
21
  over the env key; returns ({}, False) when nothing is configured."""
@@ -55,7 +48,7 @@ def _extract_usage(provider: str, payload: dict) -> tuple[int | None, int | None
55
48
 
56
49
 
57
50
  async def forward(
58
- config: Config, provider_name: str, upstream_path: str, body: dict,
51
+ config: Config, provider_name: str, body: dict,
59
52
  *, streaming: bool, token: dict, store: Store, tools_used: list[str] | None = None,
60
53
  ):
61
54
  """Forward a request upstream. Returns (status, headers, body_iter_or_dict, log_after)."""
@@ -66,7 +59,7 @@ async def forward(
66
59
  # Offline mock — exercise the full pipeline (auth, scope, log, cost) with NO real
67
60
  # key. Use model "mock" (or "mock-…") anywhere a real model would go.
68
61
  if model.startswith("mock"):
69
- payload, (ptok, ctok) = _mock_payload(provider_name, body)
62
+ payload, (ptok, ctok) = _mock_payload(provider.shape, body)
70
63
  store.log_request(
71
64
  token_id=token["id"], token_label=token.get("label"), provider=provider_name,
72
65
  model=model, status=200, prompt_tokens=ptok, completion_tokens=ctok,
@@ -74,7 +67,7 @@ async def forward(
74
67
  tools_used=json.dumps(tools_used or []), cost_usd=pricing.cost_usd(model, ptok, ctok),
75
68
  error=None)
76
69
  if streaming:
77
- return 200, {"content-type": "text/event-stream"}, _mock_stream(provider_name, payload), None
70
+ return 200, {"content-type": "text/event-stream"}, _mock_stream(provider.shape, payload), None
78
71
  return 200, {"content-type": "application/json"}, payload, None
79
72
 
80
73
  auth, ok = resolve_auth(provider, store)
@@ -82,7 +75,7 @@ async def forward(
82
75
  return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy "
83
76
  f"(set {provider.key_env} or `proxyagent provider add {provider_name}`)"}, None
84
77
 
85
- url = provider.base_url + upstream_path
78
+ url = provider.endpoint
86
79
  headers = {"content-type": "application/json", **auth}
87
80
 
88
81
  def _log(status, ptok, ctok, err=None):
@@ -127,7 +120,7 @@ async def forward(
127
120
  payload = resp.json()
128
121
  except Exception:
129
122
  payload = {"error": resp.text}
130
- ptok, ctok = _extract_usage(provider_name, payload if isinstance(payload, dict) else {})
123
+ ptok, ctok = _extract_usage(provider.shape, payload if isinstance(payload, dict) else {})
131
124
  _log(resp.status_code, ptok, ctok, None if resp.is_success else str(payload)[:300])
132
125
  return resp.status_code, {"content-type": "application/json"}, payload, None
133
126
 
@@ -14,9 +14,9 @@ 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
+ from . import aliases, crypto
18
18
  from .config import Config, PROVIDERS
19
- from .providers import ROUTES, forward, scope_allows
19
+ from .providers import forward, scope_allows
20
20
  from .security import token_matches
21
21
  from .store import Store, now_ms
22
22
  from .tools import ToolRegistry
@@ -83,38 +83,44 @@ def create_app(config: Config | None = None) -> FastAPI:
83
83
  # ------------------------------------------------------------------ #
84
84
  # Provider proxy endpoints
85
85
  # ------------------------------------------------------------------ #
86
- async def _proxy(provider_key: str, request: Request, authorization, x_api_key):
86
+ async def _proxy(provider: str, request: Request, authorization, x_api_key):
87
87
  token = auth_machine(authorization, x_api_key)
88
- provider_name, upstream_path = ROUTES[provider_key]
88
+ if provider not in PROVIDERS:
89
+ raise HTTPException(404, f"unknown provider '{provider}' (known: {list(PROVIDERS)})")
89
90
  body = await request.json()
90
- model = body.get("model", "")
91
+ # model remap — may rename the model and/or reroute to another provider
92
+ provider, model = aliases.remap(provider, body.get("model", ""))
93
+ if provider not in PROVIDERS:
94
+ raise HTTPException(400, f"alias target provider '{provider}' is unknown")
95
+ body["model"] = model
91
96
  scope = _json.loads(token["scope_json"])
92
- if not scope_allows(scope, provider_name, model):
93
- raise HTTPException(403, f"token scope does not allow {provider_name}:{model}")
97
+ if not scope_allows(scope, provider, model):
98
+ raise HTTPException(403, f"token scope does not allow {provider}:{model}")
94
99
 
95
100
  used_tools: list[str] = []
96
101
  if request.headers.get("x-proxyagent-tools", "").lower() in ("1", "on", "true"):
97
- body = tools.inject(body, provider_name)
102
+ body = tools.inject(body, PROVIDERS[provider].shape)
98
103
  used_tools = tools.names()
99
104
 
100
105
  streaming = bool(body.get("stream"))
101
106
  status, headers, payload, _ = await forward(
102
- config, provider_name, upstream_path, body,
103
- streaming=streaming, token=token, store=store, tools_used=used_tools,
104
- )
107
+ config, provider, body, streaming=streaming, token=token, store=store,
108
+ tools_used=used_tools)
105
109
  if streaming:
106
110
  return StreamingResponse(payload, media_type="text/event-stream")
107
111
  return JSONResponse(payload, status_code=status)
108
112
 
109
- @app.post("/anthropic/v1/messages")
110
- async def anthropic(request: Request, authorization: str | None = Header(None),
111
- x_api_key: str | None = Header(None)):
112
- return await _proxy("anthropic", request, authorization, x_api_key)
113
+ # OpenAI-compatible providers hit /<provider>/v1/chat/completions; Anthropic-style
114
+ # hit /<provider>/v1/messages. The provider segment selects the upstream.
115
+ @app.post("/{provider}/v1/chat/completions")
116
+ async def chat(provider: str, request: Request, authorization: str | None = Header(None),
117
+ x_api_key: str | None = Header(None)):
118
+ return await _proxy(provider, request, authorization, x_api_key)
113
119
 
114
- @app.post("/openai/v1/chat/completions")
115
- async def openai(request: Request, authorization: str | None = Header(None),
116
- x_api_key: str | None = Header(None)):
117
- return await _proxy("openai", request, authorization, x_api_key)
120
+ @app.post("/{provider}/v1/messages")
121
+ async def messages(provider: str, request: Request, authorization: str | None = Header(None),
122
+ x_api_key: str | None = Header(None)):
123
+ return await _proxy(provider, request, authorization, x_api_key)
118
124
 
119
125
  # ------------------------------------------------------------------ #
120
126
  # Tools — execute a proxied tool (creds stay here)
@@ -212,10 +218,26 @@ def create_app(config: Config | None = None) -> FastAPI:
212
218
  raise HTTPException(404, "no such credential")
213
219
  return {"ok": True}
214
220
 
221
+ # -- model aliases / remap -------------------------------------------- #
222
+ @app.get("/admin/aliases")
223
+ async def get_aliases(authorization: str | None = Header(None),
224
+ x_admin_token: str | None = Header(None)):
225
+ require_admin(authorization, x_admin_token)
226
+ return {"map": aliases.get_map()}
227
+
228
+ @app.put("/admin/aliases")
229
+ async def set_aliases(request: Request, authorization: str | None = Header(None),
230
+ x_admin_token: str | None = Header(None)):
231
+ require_admin(authorization, x_admin_token)
232
+ body = await request.json()
233
+ aliases.set_map(body.get("map", body))
234
+ return {"map": aliases.get_map()}
235
+
215
236
  @app.get("/healthz")
216
237
  async def healthz():
217
- return {"ok": True, "providers": _configured(), "tools": tools.names(),
218
- "backend": store.backend}
238
+ return {"ok": True, "providers": _configured(), "available": sorted(PROVIDERS),
239
+ "tools": tools.names(), "backend": store.backend,
240
+ "aliases": len(aliases.get_map())}
219
241
 
220
242
  # ------------------------------------------------------------------ #
221
243
  # Dashboard
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proxyagent"
7
- version = "0.2.1"
7
+ version = "0.3.1"
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"
@@ -5,9 +5,18 @@ import os
5
5
  os.environ.setdefault("PROXYAGENT_HOME", "/tmp/proxyagent_test_home")
6
6
  os.environ["PROXYAGENT_ADMIN_TOKEN"] = "pa_admin_test"
7
7
 
8
+ import pytest # noqa: E402
8
9
  from fastapi.testclient import TestClient # noqa: E402
9
10
 
10
- from proxyagent.config import Config # noqa: E402
11
+ from proxyagent import aliases as _aliases # noqa: E402
12
+ from proxyagent.config import Config, PROVIDERS # noqa: E402
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _reset_aliases():
17
+ _aliases.set_map({})
18
+ yield
19
+ _aliases.set_map({})
11
20
  from proxyagent.providers import scope_allows # noqa: E402
12
21
  from proxyagent.security import hash_token, token_matches, new_token # noqa: E402
13
22
  from proxyagent.server import create_app # noqa: E402
@@ -126,3 +135,33 @@ def test_provider_admin_endpoints():
126
135
  # unknown provider rejected
127
136
  assert c.post("/admin/providers", headers=ADMIN,
128
137
  json={"provider": "nope", "secret": "x"}).status_code == 400
138
+
139
+
140
+ def test_more_providers_route():
141
+ # new providers are routable; mock works on any of them with no key
142
+ assert "groq" in PROVIDERS and "gemini" in PROVIDERS and "openrouter" in PROVIDERS
143
+ c = _client()
144
+ tok = c.post("/admin/tokens", headers=ADMIN, json={"scope": ["*"]}).json()["token"]
145
+ r = c.post("/groq/v1/chat/completions", headers={"authorization": f"Bearer {tok}"},
146
+ json={"model": "mock", "messages": [{"role": "user", "content": "hi"}]})
147
+ assert r.status_code == 200 and r.json()["choices"][0]["message"]["content"].startswith("[proxyagent mock]")
148
+ # unknown provider → 404
149
+ assert c.post("/nope/v1/chat/completions", headers={"authorization": f"Bearer {tok}"},
150
+ json={"model": "mock", "messages": []}).status_code == 404
151
+
152
+
153
+ def test_model_remap_forces_mock_offline():
154
+ c = _client()
155
+ tok = c.post("/admin/tokens", headers=ADMIN, json={"scope": ["*"]}).json()["token"]
156
+ # map everything to mock → a "real" model call runs offline, no key
157
+ c.put("/admin/aliases", headers=ADMIN, json={"map": {"*": "mock"}})
158
+ r = c.post("/openai/v1/chat/completions", headers={"authorization": f"Bearer {tok}"},
159
+ json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]})
160
+ assert r.status_code == 200 and "[proxyagent mock]" in r.json()["choices"][0]["message"]["content"]
161
+
162
+
163
+ def test_model_remap_reroutes_provider():
164
+ from proxyagent.aliases import remap
165
+ _aliases.set_map({"gpt-4o": "anthropic:mock"})
166
+ assert remap("openai", "gpt-4o") == ("anthropic", "mock")
167
+ assert remap("openai", "gpt-4o-mini") == ("openai", "gpt-4o-mini") # no match
@@ -1,82 +0,0 @@
1
- """Configuration — provider upstreams, real credentials (env only), paths, admin auth.
2
-
3
- Real keys are read from the environment and never persisted. The proxy is the ONLY
4
- place they live.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import os
10
- from dataclasses import dataclass, field
11
- from pathlib import Path
12
-
13
- from .security import hash_token, new_token, ADMIN_PREFIX
14
-
15
- HOME = Path(os.environ.get("PROXYAGENT_HOME", Path.home() / ".proxyagent"))
16
-
17
-
18
- @dataclass
19
- class Provider:
20
- name: str
21
- base_url: str # upstream API root
22
- key_env: str # env var holding the REAL key
23
- auth_style: str # "bearer" (OpenAI) | "x-api-key" (Anthropic)
24
- extra_headers: dict = field(default_factory=dict)
25
-
26
- @property
27
- def key(self) -> str | None:
28
- return os.environ.get(self.key_env)
29
-
30
- def auth_headers(self) -> dict:
31
- key = self.key
32
- if not key:
33
- return {}
34
- if self.auth_style == "x-api-key":
35
- return {"x-api-key": key, **self.extra_headers}
36
- return {"Authorization": f"Bearer {key}", **self.extra_headers}
37
-
38
-
39
- # Built-in upstreams. base_url overridable via env (e.g. Azure, self-hosted, gateways).
40
- def _provider(name, default_base, key_env, style, extra=None) -> Provider:
41
- base = os.environ.get(f"PROXYAGENT_{name.upper()}_BASE_URL", default_base)
42
- return Provider(name, base.rstrip("/"), key_env, style, extra or {})
43
-
44
-
45
- PROVIDERS: dict[str, Provider] = {
46
- "anthropic": _provider(
47
- "anthropic", "https://api.anthropic.com", "ANTHROPIC_API_KEY", "x-api-key",
48
- {"anthropic-version": os.environ.get("ANTHROPIC_VERSION", "2023-06-01")},
49
- ),
50
- "openai": _provider("openai", "https://api.openai.com", "OPENAI_API_KEY", "bearer"),
51
- }
52
-
53
-
54
- @dataclass
55
- class Config:
56
- home: Path = HOME
57
- db_path: str = ""
58
- admin_token_hash: str = ""
59
- admin_token_plain: str | None = None # only set when freshly generated
60
- request_timeout: float = 600.0
61
-
62
- @classmethod
63
- def load(cls) -> "Config":
64
- HOME.mkdir(parents=True, exist_ok=True)
65
- cfg = cls(db_path=str(HOME / "proxyagent.db"))
66
- # Admin token: from env, or a persisted one, or freshly generated (shown once).
67
- env_admin = os.environ.get("PROXYAGENT_ADMIN_TOKEN")
68
- admin_file = HOME / "admin_token"
69
- if env_admin:
70
- cfg.admin_token_hash = hash_token(env_admin)
71
- elif admin_file.exists():
72
- cfg.admin_token_hash = admin_file.read_text().strip()
73
- else:
74
- plain = new_token(ADMIN_PREFIX)
75
- cfg.admin_token_hash = hash_token(plain)
76
- admin_file.write_text(cfg.admin_token_hash)
77
- admin_file.chmod(0o600)
78
- cfg.admin_token_plain = plain
79
- return cfg
80
-
81
- def configured_providers(self) -> list[str]:
82
- return [n for n, p in PROVIDERS.items() if p.key]
File without changes
File without changes