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.
- {proxyagent-0.2.1 → proxyagent-0.3.1}/PKG-INFO +23 -3
- {proxyagent-0.2.1 → proxyagent-0.3.1}/README.md +22 -2
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/__init__.py +1 -1
- proxyagent-0.3.1/proxyagent/aliases.py +49 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/cli.py +139 -35
- proxyagent-0.3.1/proxyagent/config.py +95 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/providers.py +5 -12
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/server.py +43 -21
- {proxyagent-0.2.1 → proxyagent-0.3.1}/pyproject.toml +1 -1
- {proxyagent-0.2.1 → proxyagent-0.3.1}/tests/test_proxy.py +40 -1
- proxyagent-0.2.1/proxyagent/config.py +0 -82
- {proxyagent-0.2.1 → proxyagent-0.3.1}/.gitignore +0 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/crypto.py +0 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/db.py +0 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/harness.py +0 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/pricing.py +0 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/security.py +0 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/store.py +0 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/tools.py +0 -0
- {proxyagent-0.2.1 → proxyagent-0.3.1}/proxyagent/ui/index.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyagent
|
|
3
|
-
Version: 0.
|
|
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
|
|
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-*"
|
|
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
|
|
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-*"
|
|
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`.
|
|
@@ -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] (
|
|
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]
|
|
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 —
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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]{
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
|
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(
|
|
86
|
+
async def _proxy(provider: str, request: Request, authorization, x_api_key):
|
|
87
87
|
token = auth_machine(authorization, x_api_key)
|
|
88
|
-
|
|
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
|
|
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,
|
|
93
|
-
raise HTTPException(403, f"token scope does not allow {
|
|
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,
|
|
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,
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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("/
|
|
115
|
-
async def
|
|
116
|
-
|
|
117
|
-
return await _proxy(
|
|
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(), "
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|