proxyagent 0.5.1__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {proxyagent-0.5.1 → proxyagent-0.7.0}/PKG-INFO +25 -5
- {proxyagent-0.5.1 → proxyagent-0.7.0}/README.md +24 -4
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/__init__.py +1 -1
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/config.py +1 -1
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/providers.py +95 -43
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/server.py +25 -12
- proxyagent-0.7.0/proxyagent/signers.py +88 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/store.py +37 -20
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/ui/index.html +32 -22
- {proxyagent-0.5.1 → proxyagent-0.7.0}/pyproject.toml +1 -1
- {proxyagent-0.5.1 → proxyagent-0.7.0}/tests/test_proxy.py +48 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/.gitignore +0 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/aliases.py +0 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/cli.py +0 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/crypto.py +0 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/db.py +0 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/harness.py +0 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/pricing.py +0 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/security.py +0 -0
- {proxyagent-0.5.1 → proxyagent-0.7.0}/proxyagent/tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyagent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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>
|
|
@@ -178,10 +178,30 @@ is to centralise *all* of them so the machine running the harness holds only a `
|
|
|
178
178
|
| **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
|
|
179
179
|
| **Gemini CLI** | Google | API key · OAuth · Vertex |
|
|
180
180
|
|
|
181
|
-
Connect each mode
|
|
182
|
-
Bedrock
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
Connect each mode in the dashboard's **Harnesses** tab (or `proxyagent provider add … --kind`).
|
|
182
|
+
**API key, OAuth, AWS Bedrock, and Azure are wired today** — for Bedrock the proxy holds the AWS
|
|
183
|
+
credentials and **SigV4-signs** the Claude-on-Bedrock request itself, so the machine needs no
|
|
184
|
+
AWS at all. (Vertex lands next.) The model providers below are the *backends* for model-agnostic
|
|
185
|
+
harnesses (aider, Cline…).
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
# the cloud-credential paths — the machine that runs the harness holds none of these:
|
|
189
|
+
proxyagent provider add anthropic --kind bedrock --key <AWS_SECRET> # + meta: access_key, region
|
|
190
|
+
proxyagent provider add openai --kind azure --key <AZURE_KEY> # + meta: endpoint
|
|
191
|
+
proxyagent provider add anthropic --kind oauth --key <OAUTH_TOKEN>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Credential pools & failover
|
|
195
|
+
A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
|
|
196
|
+
auth types (several API keys, OAuth tokens, …); each is managed individually in the
|
|
197
|
+
dashboard. The proxy rotates through the pool, **failing over** to the next credential on
|
|
198
|
+
any `429` / `5xx` — so a rate-limited or dead key never takes you down.
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
proxyagent provider add anthropic --key sk-ant-aaa # additive — builds the pool
|
|
202
|
+
proxyagent provider add anthropic --key sk-ant-bbb
|
|
203
|
+
proxyagent provider add anthropic --key <oauth> --kind oauth
|
|
204
|
+
```
|
|
185
205
|
|
|
186
206
|
## Per-token budgets
|
|
187
207
|
Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
|
|
@@ -146,10 +146,30 @@ is to centralise *all* of them so the machine running the harness holds only a `
|
|
|
146
146
|
| **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
|
|
147
147
|
| **Gemini CLI** | Google | API key · OAuth · Vertex |
|
|
148
148
|
|
|
149
|
-
Connect each mode
|
|
150
|
-
Bedrock
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
Connect each mode in the dashboard's **Harnesses** tab (or `proxyagent provider add … --kind`).
|
|
150
|
+
**API key, OAuth, AWS Bedrock, and Azure are wired today** — for Bedrock the proxy holds the AWS
|
|
151
|
+
credentials and **SigV4-signs** the Claude-on-Bedrock request itself, so the machine needs no
|
|
152
|
+
AWS at all. (Vertex lands next.) The model providers below are the *backends* for model-agnostic
|
|
153
|
+
harnesses (aider, Cline…).
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# the cloud-credential paths — the machine that runs the harness holds none of these:
|
|
157
|
+
proxyagent provider add anthropic --kind bedrock --key <AWS_SECRET> # + meta: access_key, region
|
|
158
|
+
proxyagent provider add openai --kind azure --key <AZURE_KEY> # + meta: endpoint
|
|
159
|
+
proxyagent provider add anthropic --kind oauth --key <OAUTH_TOKEN>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Credential pools & failover
|
|
163
|
+
A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
|
|
164
|
+
auth types (several API keys, OAuth tokens, …); each is managed individually in the
|
|
165
|
+
dashboard. The proxy rotates through the pool, **failing over** to the next credential on
|
|
166
|
+
any `429` / `5xx` — so a rate-limited or dead key never takes you down.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
proxyagent provider add anthropic --key sk-ant-aaa # additive — builds the pool
|
|
170
|
+
proxyagent provider add anthropic --key sk-ant-bbb
|
|
171
|
+
proxyagent provider add anthropic --key <oauth> --kind oauth
|
|
172
|
+
```
|
|
153
173
|
|
|
154
174
|
## Per-token budgets
|
|
155
175
|
Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
|
|
@@ -101,7 +101,7 @@ AUTH_LABELS = {"api_key": "API key", "oauth": "OAuth", "bedrock": "AWS Bedrock",
|
|
|
101
101
|
"vertex": "Google Vertex", "azure": "Azure"}
|
|
102
102
|
# Auth modes that are fully wired today (just a key swap). Others are surfaced in the
|
|
103
103
|
# UI as "available" and built out (Bedrock SigV4 / Vertex token / OAuth refresh).
|
|
104
|
-
AUTH_READY = {"api_key"}
|
|
104
|
+
AUTH_READY = {"api_key", "oauth", "bedrock", "azure"}
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
@dataclass
|
|
@@ -12,23 +12,67 @@ import json
|
|
|
12
12
|
|
|
13
13
|
import httpx
|
|
14
14
|
|
|
15
|
-
from . import pricing
|
|
15
|
+
from . import pricing, signers
|
|
16
16
|
from .config import Config, PROVIDERS
|
|
17
17
|
from .store import Store, now_ms
|
|
18
18
|
|
|
19
|
+
FAILOVER_STATUS = {429, 500, 502, 503, 504, 529}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _headers_for(provider, secret: str, kind: str) -> dict:
|
|
23
|
+
if kind != "api_key": # oauth / bearer
|
|
24
|
+
return {"Authorization": f"Bearer {secret}", **provider.extra_headers}
|
|
25
|
+
if provider.auth_style == "x-api-key":
|
|
26
|
+
return {"x-api-key": secret, **provider.extra_headers}
|
|
27
|
+
return {"Authorization": f"Bearer {secret}", **provider.extra_headers}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_candidates(provider, store: Store | None) -> list[dict]:
|
|
31
|
+
"""Every usable auth header-set for a provider, in rotation order: the stored pool
|
|
32
|
+
(api_key then oauth creds), then the env key as a last resort. The forwarder tries
|
|
33
|
+
them in turn, rotating past any that 429/5xx — that's the failover."""
|
|
34
|
+
out: list[dict] = []
|
|
35
|
+
if store:
|
|
36
|
+
for c in store.get_credentials(provider.name, kind="api_key"):
|
|
37
|
+
out.append(_headers_for(provider, c["secret"], "api_key"))
|
|
38
|
+
for c in store.get_credentials(provider.name, kind="oauth"):
|
|
39
|
+
out.append(_headers_for(provider, c["secret"], "oauth"))
|
|
40
|
+
if provider.key:
|
|
41
|
+
out.append(provider.auth_headers())
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
19
45
|
def resolve_auth(provider, store: Store | None) -> tuple[dict, bool]:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
46
|
+
cands = resolve_candidates(provider, store)
|
|
47
|
+
return (cands[0], True) if cands else ({}, False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_plans(provider, store: Store | None, body: dict) -> list[tuple]:
|
|
51
|
+
"""Every way to fulfil this request, in rotation order, as (url, headers, body_bytes).
|
|
52
|
+
Each credential kind maps to its own upstream + signing: api_key/oauth → the provider
|
|
53
|
+
endpoint; azure → a custom deployment URL; bedrock → SigV4-signed Claude-on-Bedrock."""
|
|
54
|
+
plans: list[tuple] = []
|
|
55
|
+
raw = json.dumps(body).encode("utf-8")
|
|
56
|
+
JSON = {"content-type": "application/json"}
|
|
57
|
+
if store:
|
|
58
|
+
for c in store.get_credentials(provider.name):
|
|
59
|
+
kind, meta = c["kind"], (c.get("meta") or {})
|
|
60
|
+
if kind == "api_key":
|
|
61
|
+
plans.append((provider.endpoint, {**JSON, **_headers_for(provider, c["secret"], "api_key")}, raw))
|
|
62
|
+
elif kind == "oauth":
|
|
63
|
+
plans.append((provider.endpoint, {**JSON, "Authorization": f"Bearer {c['secret']}", **provider.extra_headers}, raw))
|
|
64
|
+
elif kind == "azure":
|
|
65
|
+
ep = (meta.get("endpoint") or "").rstrip("/")
|
|
66
|
+
if ep:
|
|
67
|
+
plans.append((ep, {**JSON, "api-key": c["secret"]}, raw))
|
|
68
|
+
elif kind == "bedrock":
|
|
69
|
+
try:
|
|
70
|
+
plans.append(signers.bedrock_plan(c, body))
|
|
71
|
+
except Exception: # noqa: BLE001 — skip a malformed bedrock cred
|
|
72
|
+
pass
|
|
73
|
+
if provider.key:
|
|
74
|
+
plans.append((provider.endpoint, {**JSON, **provider.auth_headers()}, raw))
|
|
75
|
+
return plans
|
|
32
76
|
|
|
33
77
|
|
|
34
78
|
def scope_allows(scope: list[str], provider: str, model: str) -> bool:
|
|
@@ -70,14 +114,11 @@ async def forward(
|
|
|
70
114
|
return 200, {"content-type": "text/event-stream"}, _mock_stream(provider.shape, payload), None
|
|
71
115
|
return 200, {"content-type": "application/json"}, payload, None
|
|
72
116
|
|
|
73
|
-
|
|
74
|
-
if not
|
|
117
|
+
plans = build_plans(provider, store, body)
|
|
118
|
+
if not plans:
|
|
75
119
|
return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy "
|
|
76
120
|
f"(set {provider.key_env} or `proxyagent provider add {provider_name}`)"}, None
|
|
77
121
|
|
|
78
|
-
url = provider.endpoint
|
|
79
|
-
headers = {"content-type": "application/json", **auth}
|
|
80
|
-
|
|
81
122
|
def _log(status, ptok, ctok, err=None):
|
|
82
123
|
store.log_request(
|
|
83
124
|
token_id=token["id"], token_label=token.get("label"), provider=provider_name,
|
|
@@ -93,36 +134,47 @@ async def forward(
|
|
|
93
134
|
status = 200
|
|
94
135
|
try:
|
|
95
136
|
async with httpx.AsyncClient(timeout=config.request_timeout) as client:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
137
|
+
for i, (url, headers, raw) in enumerate(plans):
|
|
138
|
+
async with client.stream("POST", url, headers=headers, content=raw) as resp:
|
|
139
|
+
status = resp.status_code
|
|
140
|
+
if status in FAILOVER_STATUS and i < len(plans) - 1:
|
|
141
|
+
await resp.aread() # drain + rotate to the next credential
|
|
142
|
+
continue
|
|
143
|
+
async for chunk in resp.aiter_raw():
|
|
144
|
+
text = chunk.decode("utf-8", "ignore")
|
|
145
|
+
if '"output_tokens"' in text or '"completion_tokens"' in text:
|
|
146
|
+
try:
|
|
147
|
+
for line in text.splitlines():
|
|
148
|
+
if line.startswith("data:"):
|
|
149
|
+
d = json.loads(line[5:].strip())
|
|
150
|
+
usage = d.get("usage") or (d.get("message") or {}).get("usage") or {}
|
|
151
|
+
ptok = usage.get("input_tokens") or usage.get("prompt_tokens") or ptok
|
|
152
|
+
ctok = usage.get("output_tokens") or usage.get("completion_tokens") or ctok
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
yield chunk
|
|
156
|
+
break
|
|
112
157
|
finally:
|
|
113
158
|
_log(status, ptok, ctok)
|
|
114
159
|
return 200, {"content-type": "text/event-stream"}, _gen(), None
|
|
115
160
|
|
|
116
|
-
# Non-streaming.
|
|
161
|
+
# Non-streaming, with credential failover across the pool.
|
|
162
|
+
last_status, last_payload = 502, {"error": "all credentials failed"}
|
|
117
163
|
async with httpx.AsyncClient(timeout=config.request_timeout) as client:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
164
|
+
for i, (url, headers, raw) in enumerate(plans):
|
|
165
|
+
resp = await client.post(url, headers=headers, content=raw)
|
|
166
|
+
if resp.status_code in FAILOVER_STATUS and i < len(plans) - 1:
|
|
167
|
+
last_status = resp.status_code
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
payload = resp.json()
|
|
171
|
+
except Exception:
|
|
172
|
+
payload = {"error": resp.text}
|
|
173
|
+
ptok, ctok = _extract_usage(provider.shape, payload if isinstance(payload, dict) else {})
|
|
174
|
+
_log(resp.status_code, ptok, ctok, None if resp.is_success else str(payload)[:300])
|
|
175
|
+
return resp.status_code, {"content-type": "application/json"}, payload, None
|
|
176
|
+
_log(last_status, None, None, "all credentials failed")
|
|
177
|
+
return last_status, {"content-type": "application/json"}, last_payload, None
|
|
126
178
|
|
|
127
179
|
|
|
128
180
|
# ------------------------------------------------------------------ #
|
|
@@ -35,9 +35,10 @@ class TokenBody(BaseModel):
|
|
|
35
35
|
class ProviderBody(BaseModel):
|
|
36
36
|
provider: str
|
|
37
37
|
secret: str
|
|
38
|
-
kind: str = "api_key" # api_key | oauth
|
|
38
|
+
kind: str = "api_key" # api_key | oauth | bedrock | azure | vertex
|
|
39
39
|
label: str | None = None
|
|
40
40
|
refresh: str | None = None
|
|
41
|
+
meta: dict | None = None # bedrock: {access_key, region}; azure: {endpoint}
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
def create_app(config: Config | None = None) -> FastAPI:
|
|
@@ -204,7 +205,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
204
205
|
if body.provider not in PROVIDERS:
|
|
205
206
|
raise HTTPException(400, f"unknown provider; known: {list(PROVIDERS)}")
|
|
206
207
|
cid = store.add_credential(body.provider, body.secret, kind=body.kind,
|
|
207
|
-
label=body.label, refresh=body.refresh)
|
|
208
|
+
label=body.label, refresh=body.refresh, meta=body.meta)
|
|
208
209
|
return {"id": cid, "provider": body.provider, "kind": body.kind,
|
|
209
210
|
"stored": "encrypted" if crypto.encryption_available() else "plaintext"}
|
|
210
211
|
|
|
@@ -227,19 +228,28 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
227
228
|
async def harnesses(authorization: str | None = Header(None),
|
|
228
229
|
x_admin_token: str | None = Header(None)):
|
|
229
230
|
require_admin(authorization, x_admin_token)
|
|
230
|
-
|
|
231
|
+
kinds_by_prov: dict[str, set] = {}
|
|
232
|
+
for c in store.list_credentials():
|
|
233
|
+
if c["active"]:
|
|
234
|
+
kinds_by_prov.setdefault(c["provider"], set()).add(c["kind"])
|
|
231
235
|
out = []
|
|
232
236
|
for name, h in HARNESSES.items():
|
|
233
237
|
prov = PROVIDERS.get(h["provider"])
|
|
234
|
-
|
|
238
|
+
have = kinds_by_prov.get(h["provider"], set())
|
|
239
|
+
env_key = bool(prov and prov.key)
|
|
240
|
+
|
|
241
|
+
def _conn(m):
|
|
242
|
+
if m == "api_key":
|
|
243
|
+
return env_key or "api_key" in have
|
|
244
|
+
return m in have
|
|
245
|
+
|
|
235
246
|
out.append({
|
|
236
247
|
"name": name, "label": h["label"], "provider": h["provider"],
|
|
237
248
|
"color": h["color"], "install": h["install"],
|
|
238
249
|
"auth": [{"mode": m, "label": AUTH_LABELS.get(m, m),
|
|
239
|
-
"ready": m in AUTH_READY,
|
|
240
|
-
"connected": m == "api_key" and configured}
|
|
250
|
+
"ready": m in AUTH_READY, "connected": _conn(m)}
|
|
241
251
|
for m in h["auth"]],
|
|
242
|
-
"configured":
|
|
252
|
+
"configured": env_key or bool(have),
|
|
243
253
|
})
|
|
244
254
|
return {"harnesses": out}
|
|
245
255
|
|
|
@@ -247,18 +257,21 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
247
257
|
async def catalog(authorization: str | None = Header(None),
|
|
248
258
|
x_admin_token: str | None = Header(None)):
|
|
249
259
|
require_admin(authorization, x_admin_token)
|
|
250
|
-
|
|
260
|
+
pool: dict[str, list] = {}
|
|
261
|
+
for c in store.list_credentials():
|
|
262
|
+
if c["active"]:
|
|
263
|
+
pool.setdefault(c["provider"], []).append(c)
|
|
251
264
|
out = []
|
|
252
265
|
for name, prov in PROVIDERS.items():
|
|
253
266
|
meta = CATALOG.get(name, {})
|
|
254
|
-
|
|
267
|
+
creds = pool.get(name, [])
|
|
255
268
|
out.append({
|
|
256
269
|
"name": name, "label": meta.get("label", name.title()),
|
|
257
270
|
"kinds": meta.get("kinds", ["api_key"]), "color": meta.get("color", "#888"),
|
|
258
271
|
"models": meta.get("models", []), "shape": prov.shape,
|
|
259
|
-
"via_env": bool(prov.key), "via_store": bool(
|
|
260
|
-
"
|
|
261
|
-
|
|
272
|
+
"via_env": bool(prov.key), "via_store": bool(creds),
|
|
273
|
+
"creds": [{"id": c["id"], "kind": c["kind"], "masked": c.get("masked"),
|
|
274
|
+
"label": c.get("label")} for c in creds],
|
|
262
275
|
"endpoint": prov.endpoint,
|
|
263
276
|
})
|
|
264
277
|
return {"providers": out, "encryption": crypto.encryption_available()}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Request signers for the cloud auth modes — so the proxy holds the cloud credentials
|
|
2
|
+
and the machine running the harness holds none.
|
|
3
|
+
|
|
4
|
+
* AWS SigV4 (Bedrock) — full signature, no boto3 dependency.
|
|
5
|
+
* Azure OpenAI — api-key header to a custom deployment endpoint.
|
|
6
|
+
|
|
7
|
+
Vertex (GCP service-account → access token) lands next.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sign(key: bytes, msg: str) -> bytes:
|
|
20
|
+
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sigv4_headers(*, method: str, host: str, path: str, region: str, service: str,
|
|
24
|
+
access_key: str, secret_key: str, body: bytes,
|
|
25
|
+
session_token: str | None = None,
|
|
26
|
+
content_type: str = "application/json", now: datetime.datetime | None = None) -> dict:
|
|
27
|
+
"""AWS Signature Version 4 headers for a request. `path` must already be URI-encoded."""
|
|
28
|
+
now = now or datetime.datetime.now(datetime.timezone.utc)
|
|
29
|
+
amzdate = now.strftime("%Y%m%dT%H%M%SZ")
|
|
30
|
+
datestamp = now.strftime("%Y%m%d")
|
|
31
|
+
payload_hash = hashlib.sha256(body).hexdigest()
|
|
32
|
+
|
|
33
|
+
hdrs = {"content-type": content_type, "host": host, "x-amz-date": amzdate}
|
|
34
|
+
if session_token:
|
|
35
|
+
hdrs["x-amz-security-token"] = session_token
|
|
36
|
+
signed_headers = ";".join(sorted(hdrs))
|
|
37
|
+
canonical_headers = "".join(f"{k}:{hdrs[k]}\n" for k in sorted(hdrs))
|
|
38
|
+
canonical_request = f"{method}\n{path}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
|
39
|
+
|
|
40
|
+
scope = f"{datestamp}/{region}/{service}/aws4_request"
|
|
41
|
+
string_to_sign = (f"AWS4-HMAC-SHA256\n{amzdate}\n{scope}\n"
|
|
42
|
+
f"{hashlib.sha256(canonical_request.encode()).hexdigest()}")
|
|
43
|
+
k_date = _sign(("AWS4" + secret_key).encode("utf-8"), datestamp)
|
|
44
|
+
k_region = _sign(k_date, region)
|
|
45
|
+
k_service = _sign(k_region, service)
|
|
46
|
+
k_signing = _sign(k_service, "aws4_request")
|
|
47
|
+
signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
48
|
+
|
|
49
|
+
auth = (f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
|
|
50
|
+
f"SignedHeaders={signed_headers}, Signature={signature}")
|
|
51
|
+
out = {"Authorization": auth, "x-amz-date": amzdate, "content-type": content_type}
|
|
52
|
+
if session_token:
|
|
53
|
+
out["x-amz-security-token"] = session_token
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Map a few common Anthropic model ids → their Bedrock ids (best-effort; pass a bedrock id directly to skip).
|
|
58
|
+
def bedrock_model_id(model: str) -> str:
|
|
59
|
+
if model.startswith("anthropic.") or ":" in model:
|
|
60
|
+
return model
|
|
61
|
+
table = {
|
|
62
|
+
"claude-opus-4": "anthropic.claude-opus-4-20250514-v1:0",
|
|
63
|
+
"claude-sonnet-4-5": "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
|
64
|
+
"claude-sonnet-4": "anthropic.claude-sonnet-4-20250514-v1:0",
|
|
65
|
+
"claude-3-5-sonnet": "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
|
66
|
+
"claude-3-5-haiku": "anthropic.claude-3-5-haiku-20241022-v1:0",
|
|
67
|
+
}
|
|
68
|
+
for prefix, bid in table.items():
|
|
69
|
+
if model.startswith(prefix):
|
|
70
|
+
return bid
|
|
71
|
+
return model
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def bedrock_plan(cred: dict, body: dict):
|
|
75
|
+
"""(url, headers, body_bytes) for a Claude-on-Bedrock invoke, SigV4-signed here."""
|
|
76
|
+
meta = cred.get("meta") or {}
|
|
77
|
+
region = meta.get("region", "us-east-1")
|
|
78
|
+
model_id = bedrock_model_id(body.get("model", ""))
|
|
79
|
+
payload = {k: v for k, v in body.items() if k != "model"}
|
|
80
|
+
payload["anthropic_version"] = "bedrock-2023-05-31"
|
|
81
|
+
raw = json.dumps(payload).encode("utf-8")
|
|
82
|
+
host = f"bedrock-runtime.{region}.amazonaws.com"
|
|
83
|
+
path = f"/model/{quote(model_id, safe='')}/invoke"
|
|
84
|
+
headers = sigv4_headers(
|
|
85
|
+
method="POST", host=host, path=path, region=region, service="bedrock",
|
|
86
|
+
access_key=meta.get("access_key", ""), secret_key=cred.get("secret", ""),
|
|
87
|
+
body=raw, session_token=meta.get("session_token"))
|
|
88
|
+
return f"https://{host}{path}", headers, raw
|
|
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS proxy_agent_tokens (
|
|
|
27
27
|
CREATE TABLE IF NOT EXISTS proxy_agent_keys (
|
|
28
28
|
id TEXT PRIMARY KEY, provider TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'api_key',
|
|
29
29
|
secret TEXT NOT NULL, refresh TEXT, expires_ms BIGINT, label TEXT,
|
|
30
|
-
created_ms BIGINT, meta_json TEXT, active INTEGER NOT NULL DEFAULT 1
|
|
30
|
+
created_ms BIGINT, meta_json TEXT, active INTEGER NOT NULL DEFAULT 1, masked TEXT
|
|
31
31
|
);
|
|
32
32
|
CREATE TABLE IF NOT EXISTS proxy_agent_calls (
|
|
33
33
|
id TEXT PRIMARY KEY, ts_ms BIGINT, token_id TEXT, token_label TEXT,
|
|
@@ -48,10 +48,12 @@ class Store:
|
|
|
48
48
|
self.db.executescript(_SCHEMA)
|
|
49
49
|
self.backend = "postgres" if self.db.pg else "sqlite"
|
|
50
50
|
# migrate older DBs created before budget_usd existed
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
for stmt in ("ALTER TABLE proxy_agent_tokens ADD COLUMN budget_usd DOUBLE PRECISION",
|
|
52
|
+
"ALTER TABLE proxy_agent_keys ADD COLUMN masked TEXT"):
|
|
53
|
+
try:
|
|
54
|
+
self.db.execute(stmt)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
55
57
|
|
|
56
58
|
# -- machine tokens ---------------------------------------------------- #
|
|
57
59
|
|
|
@@ -98,40 +100,55 @@ class Store:
|
|
|
98
100
|
# -- provider credentials (proxy_agent_keys) --------------------------- #
|
|
99
101
|
|
|
100
102
|
def add_credential(self, provider, secret, *, kind="api_key", refresh=None,
|
|
101
|
-
expires_ms=None, label=None, meta=None, replace=
|
|
103
|
+
expires_ms=None, label=None, meta=None, replace=False):
|
|
104
|
+
"""Add a credential to a provider's POOL. A provider can hold many credentials
|
|
105
|
+
across auth types (several api_keys, oauth tokens, bedrock, vertex…). replace=True
|
|
106
|
+
swaps out other creds of the SAME kind; default keeps them (for failover/rotation)."""
|
|
102
107
|
cid = "key_" + uuid.uuid4().hex[:12]
|
|
103
|
-
# replace=True (default, the UI "connect"): swap the key. replace=False adds an
|
|
104
|
-
# ADDITIONAL active key to the provider's pool for failover/rotation.
|
|
105
108
|
if replace:
|
|
106
|
-
self.db.execute("UPDATE proxy_agent_keys SET active=0 WHERE provider=?",
|
|
109
|
+
self.db.execute("UPDATE proxy_agent_keys SET active=0 WHERE provider=? AND kind=?",
|
|
110
|
+
(provider, kind))
|
|
107
111
|
self.db.execute(
|
|
108
112
|
"""INSERT INTO proxy_agent_keys
|
|
109
|
-
(id, provider, kind, secret, refresh, expires_ms, label, created_ms, meta_json, active)
|
|
110
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)""",
|
|
113
|
+
(id, provider, kind, secret, refresh, expires_ms, label, created_ms, meta_json, active, masked)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)""",
|
|
111
115
|
(cid, provider, kind, crypto.encrypt(secret),
|
|
112
116
|
crypto.encrypt(refresh) if refresh else None, expires_ms, label, now_ms(),
|
|
113
|
-
json.dumps(meta or {})),
|
|
117
|
+
json.dumps(meta or {}), mask(secret)),
|
|
114
118
|
)
|
|
115
119
|
return cid
|
|
116
120
|
|
|
117
|
-
def
|
|
118
|
-
"""Active credential for a provider, decrypted. None → fall back to env."""
|
|
119
|
-
r = self.db.fetchone(
|
|
120
|
-
"SELECT * FROM proxy_agent_keys WHERE provider=? AND active=1 ORDER BY created_ms DESC",
|
|
121
|
-
(provider,))
|
|
122
|
-
if not r:
|
|
123
|
-
return None
|
|
121
|
+
def _decrypt_row(self, r):
|
|
124
122
|
r = dict(r)
|
|
125
123
|
r["secret"] = crypto.decrypt(r["secret"])
|
|
126
124
|
if r.get("refresh"):
|
|
127
125
|
r["refresh"] = crypto.decrypt(r["refresh"])
|
|
126
|
+
if r.get("meta_json"):
|
|
127
|
+
try:
|
|
128
|
+
r["meta"] = json.loads(r["meta_json"])
|
|
129
|
+
except Exception:
|
|
130
|
+
r["meta"] = {}
|
|
128
131
|
return r
|
|
129
132
|
|
|
133
|
+
def get_credential(self, provider, kind=None):
|
|
134
|
+
"""Most-recent active credential (optionally of a kind), decrypted."""
|
|
135
|
+
creds = self.get_credentials(provider, kind=kind)
|
|
136
|
+
return creds[-1] if creds else None
|
|
137
|
+
|
|
138
|
+
def get_credentials(self, provider, kind=None):
|
|
139
|
+
"""The provider's whole active pool, decrypted, oldest→newest (rotation order)."""
|
|
140
|
+
q = "SELECT * FROM proxy_agent_keys WHERE provider=? AND active=1"
|
|
141
|
+
args = [provider]
|
|
142
|
+
if kind:
|
|
143
|
+
q += " AND kind=?"
|
|
144
|
+
args.append(kind)
|
|
145
|
+
return [self._decrypt_row(r) for r in self.db.fetchall(q + " ORDER BY created_ms", tuple(args))]
|
|
146
|
+
|
|
130
147
|
def list_credentials(self):
|
|
131
148
|
rows = self.db.fetchall("SELECT * FROM proxy_agent_keys ORDER BY created_ms DESC")
|
|
132
149
|
# never return the secret material
|
|
133
150
|
return [{"id": r["id"], "provider": r["provider"], "kind": r["kind"],
|
|
134
|
-
"label": r["label"], "active": bool(r["active"]),
|
|
151
|
+
"label": r["label"], "active": bool(r["active"]), "masked": r.get("masked"),
|
|
135
152
|
"created_ms": r["created_ms"]} for r in rows]
|
|
136
153
|
|
|
137
154
|
def remove_credential(self, cid):
|
|
@@ -181,37 +181,47 @@ async function renderHarnesses(){
|
|
|
181
181
|
${h.configured?'<span class="pill ok">ready</span>':'<span class="pill no">connect a key</span>'}</div>
|
|
182
182
|
<div class="badges">${chips}</div>
|
|
183
183
|
<div class="models mono" style="font-size:11px">${h.install}</div>
|
|
184
|
-
<div class="row"><button class="sm" onclick="openConnect('h_${h.name}')"
|
|
185
|
-
<div class="connect" id="c_h_${h.name}"
|
|
186
|
-
<input id="k_h_${h.name}" type="password" placeholder="${h.provider} API key"/>
|
|
187
|
-
<div class="row"><button class="sm" onclick="connectHarness('${h.name}','${h.provider}')">Save</button><button class="ghost sm" onclick="openConnect('h_${h.name}')">Cancel</button></div>
|
|
188
|
-
</div></div>`}).join("");
|
|
184
|
+
<div class="row"><button class="sm" onclick="openConnect('h_${h.name}')">+ Connect auth</button></div>
|
|
185
|
+
<div class="connect" id="c_h_${h.name}">${connectForm('h_'+h.name, h.auth.map(a=>a.mode), h.provider)}</div></div>`}).join("");
|
|
189
186
|
}
|
|
190
|
-
async function connectHarness(name,provider){const key=document.getElementById("k_h_"+name).value.trim();if(!key)return;
|
|
191
|
-
await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret:key,kind:"api_key"})});renderHarnesses();refreshProviders()}
|
|
192
187
|
async function refreshProviders(){
|
|
193
188
|
const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
|
|
194
189
|
let connected=0;
|
|
195
|
-
g.innerHTML=d.providers.map(p=>{const on=p.via_env||
|
|
196
|
-
const
|
|
190
|
+
g.innerHTML=d.providers.map(p=>{const creds=p.creds||[];const on=p.via_env||creds.length;if(on)connected++;
|
|
191
|
+
const credrows=creds.map(c=>`<div class="row" style="justify-content:space-between;gap:6px"><span class="badge on">${c.kind} · ${c.masked||""}</span><button class="danger sm" onclick="disconnect('${c.id}')">remove</button></div>`).join("");
|
|
197
192
|
return `<div class="prov"><div class="top">${logo(p.name,p.color)}<div style="flex:1"><h3>${p.label}</h3><div class="ep">${p.shape} · ${p.name}</div></div>
|
|
198
193
|
${on?'<span class="pill ok">on</span>':'<span class="pill no">off</span>'}</div>
|
|
199
|
-
<div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${
|
|
200
|
-
<div
|
|
201
|
-
<div class="row"
|
|
202
|
-
<div class="connect" id="c_${p.name}"
|
|
203
|
-
<input id="k_${p.name}" type="password" placeholder="${p.name} API key${p.kinds.includes('oauth')?' / OAuth token':''}"/>
|
|
204
|
-
<div class="row">${p.kinds.length>1?`<select id="kind_${p.name}">${p.kinds.map(k=>`<option value="${k}">${k}</option>`).join("")}</select>`:`<input id="kind_${p.name}" type="hidden" value="api_key"/>`}
|
|
205
|
-
<button class="sm" onclick="connect('${p.name}')">Save</button><button class="ghost sm" onclick="openConnect('${p.name}')">Cancel</button></div>
|
|
206
|
-
</div></div>`}).join("");
|
|
194
|
+
<div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${p.via_env?'<span class="badge on">env</span>':''}</div>
|
|
195
|
+
<div style="display:flex;flex-direction:column;gap:6px;margin:6px 0 11px">${credrows||'<span class="muted" style="font-size:11.5px">No keys yet</span>'}</div>
|
|
196
|
+
<div class="row"><button class="sm" onclick="openConnect('${p.name}')">+ Add credential</button></div>
|
|
197
|
+
<div class="connect" id="c_${p.name}">${connectForm(p.name,p.kinds,p.name)}</div></div>`}).join("");
|
|
207
198
|
document.getElementById("s_prov").textContent=connected;
|
|
208
|
-
document.getElementById("enc2")&&0;
|
|
209
199
|
}
|
|
210
200
|
function openConnect(n){document.getElementById("c_"+n).classList.toggle("open")}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
201
|
+
function fieldsFor(uid,kind){
|
|
202
|
+
if(kind==="bedrock")return `<input id="f1_${uid}" placeholder="AWS access key id"/><input id="f2_${uid}" type="password" placeholder="AWS secret access key"/><input id="f3_${uid}" placeholder="region (default us-east-1)"/>`;
|
|
203
|
+
if(kind==="azure")return `<input id="f1_${uid}" placeholder="Azure endpoint URL (…/chat/completions?api-version=…)"/><input id="f2_${uid}" type="password" placeholder="api-key"/>`;
|
|
204
|
+
if(kind==="vertex")return `<span class="muted" style="font-size:12px">Vertex (service-account JSON) — coming next.</span>`;
|
|
205
|
+
return `<input id="f1_${uid}" type="password" placeholder="${kind==='oauth'?'OAuth access token':'API key'}"/>`;
|
|
206
|
+
}
|
|
207
|
+
function onKind(uid){document.getElementById("ff_"+uid).innerHTML=fieldsFor(uid,document.getElementById("kind_"+uid).value)}
|
|
208
|
+
function connectForm(uid,kinds,provider){
|
|
209
|
+
return `<select id="kind_${uid}" onchange="onKind('${uid}')">${kinds.map(k=>`<option value="${k}">${k}</option>`).join("")}</select>
|
|
210
|
+
<div id="ff_${uid}">${fieldsFor(uid,kinds[0])}</div>
|
|
211
|
+
<div class="row"><button class="sm" onclick="submitCred('${uid}','${provider}')">Add credential</button><button class="ghost sm" onclick="openConnect('${uid}')">Cancel</button></div>`;
|
|
212
|
+
}
|
|
213
|
+
async function submitCred(uid,provider){
|
|
214
|
+
const kind=document.getElementById("kind_"+uid).value,g=id=>{const e=document.getElementById(id);return e?e.value.trim():""};
|
|
215
|
+
let secret,meta={};
|
|
216
|
+
if(kind==="bedrock"){secret=g("f2_"+uid);meta={access_key:g("f1_"+uid),region:g("f3_"+uid)||"us-east-1"}}
|
|
217
|
+
else if(kind==="azure"){secret=g("f2_"+uid);meta={endpoint:g("f1_"+uid)}}
|
|
218
|
+
else if(kind==="vertex"){return}
|
|
219
|
+
else{secret=g("f1_"+uid)}
|
|
220
|
+
if(!secret)return;
|
|
221
|
+
await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret,kind,meta})});
|
|
222
|
+
renderHarnesses();refreshProviders();
|
|
223
|
+
}
|
|
224
|
+
async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});renderHarnesses();refreshProviders()}
|
|
215
225
|
|
|
216
226
|
async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
|
|
217
227
|
document.getElementById("toks").innerHTML=d.tokens.map(t=>`<tr><td class="mono">${t.id}</td><td>${t.label||""}</td><td class="mono">${t.masked||""}</td><td class="mono">${(t.scope||[]).join(", ")}</td><td class="mono">${t.budget_usd?('$'+(t.spent_usd||0).toFixed(4)+' / $'+(+t.budget_usd).toFixed(2)):'—'}</td><td>${t.revoked?'<span class="pill no">revoked</span>':'<span class="pill ok">active</span>'}</td><td>${t.revoked?"":`<button class="danger sm" onclick="revokeTok('${t.id}')">revoke</button>`}</td></tr>`).join("")}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxyagent"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -188,3 +188,51 @@ def test_harness_catalog():
|
|
|
188
188
|
cc = next(x for x in h if x["name"] == "claude-code")
|
|
189
189
|
assert {a["mode"] for a in cc["auth"]} == {"api_key", "oauth", "bedrock", "vertex"}
|
|
190
190
|
assert any(a["mode"] == "api_key" and a["ready"] for a in cc["auth"])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_credential_pool_and_failover_order():
|
|
194
|
+
from proxyagent.providers import resolve_candidates
|
|
195
|
+
s = Store(":memory:")
|
|
196
|
+
s.add_credential("openai", "sk-1", kind="api_key") # additive by default →
|
|
197
|
+
s.add_credential("openai", "sk-2", kind="api_key") # a pool of two keys
|
|
198
|
+
creds = s.get_credentials("openai", kind="api_key")
|
|
199
|
+
assert [c["secret"] for c in creds] == ["sk-1", "sk-2"] # oldest→newest rotation order
|
|
200
|
+
cands = resolve_candidates(PROVIDERS["openai"], s)
|
|
201
|
+
assert cands[0]["Authorization"] == "Bearer sk-1"
|
|
202
|
+
assert cands[1]["Authorization"] == "Bearer sk-2" # failover tries #2 next
|
|
203
|
+
listed = s.list_credentials()
|
|
204
|
+
assert len(listed) == 2 and all("secret" not in c and c["masked"] for c in listed)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_sigv4_structure_and_determinism():
|
|
208
|
+
import datetime, re
|
|
209
|
+
from proxyagent.signers import sigv4_headers
|
|
210
|
+
now = datetime.datetime(2015, 8, 30, 12, 36, 0, tzinfo=datetime.timezone.utc)
|
|
211
|
+
kw = dict(method="POST", host="bedrock-runtime.us-east-1.amazonaws.com", path="/model/x/invoke",
|
|
212
|
+
region="us-east-1", service="bedrock", access_key="AKIDEXAMPLE",
|
|
213
|
+
secret_key="wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", body=b'{"a":1}', now=now)
|
|
214
|
+
h1, h2 = sigv4_headers(**kw), sigv4_headers(**kw)
|
|
215
|
+
assert h1 == h2 # deterministic at a fixed time
|
|
216
|
+
a = h1["Authorization"]
|
|
217
|
+
assert a.startswith("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/bedrock/aws4_request")
|
|
218
|
+
assert "SignedHeaders=content-type;host;x-amz-date" in a
|
|
219
|
+
assert re.search(r"Signature=[0-9a-f]{64}$", a) and h1["x-amz-date"] == "20150830T123600Z"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_bedrock_plan_and_build_plans():
|
|
223
|
+
from proxyagent.signers import bedrock_plan
|
|
224
|
+
from proxyagent.providers import build_plans
|
|
225
|
+
url, headers, raw = bedrock_plan(
|
|
226
|
+
{"secret": "sk", "meta": {"access_key": "AKID", "region": "us-west-2"}},
|
|
227
|
+
{"model": "claude-sonnet-4-5", "max_tokens": 10, "messages": []})
|
|
228
|
+
assert url.startswith("https://bedrock-runtime.us-west-2.amazonaws.com/model/") and url.endswith("/invoke")
|
|
229
|
+
import json
|
|
230
|
+
b = json.loads(raw)
|
|
231
|
+
assert b["anthropic_version"] == "bedrock-2023-05-31" and "model" not in b
|
|
232
|
+
assert headers["Authorization"].startswith("AWS4-HMAC-SHA256")
|
|
233
|
+
# a provider can mix api_key + bedrock in its pool; both become plans
|
|
234
|
+
s = Store(":memory:")
|
|
235
|
+
s.add_credential("anthropic", "sk-1", kind="api_key")
|
|
236
|
+
s.add_credential("anthropic", "awssecret", kind="bedrock", meta={"access_key": "AKID", "region": "us-east-1"})
|
|
237
|
+
plans = build_plans(PROVIDERS["anthropic"], s, {"model": "claude-sonnet-4-5", "messages": []})
|
|
238
|
+
assert len(plans) == 2 and plans[0][0].endswith("/v1/messages") and "bedrock-runtime" in plans[1][0]
|
|
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
|