proxyagent 0.6.0__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.6.0 → proxyagent-0.7.0}/PKG-INFO +13 -5
- {proxyagent-0.6.0 → proxyagent-0.7.0}/README.md +12 -4
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/__init__.py +1 -1
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/config.py +1 -1
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/providers.py +39 -15
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/server.py +17 -7
- proxyagent-0.7.0/proxyagent/signers.py +88 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/ui/index.html +27 -16
- {proxyagent-0.6.0 → proxyagent-0.7.0}/pyproject.toml +1 -1
- {proxyagent-0.6.0 → proxyagent-0.7.0}/tests/test_proxy.py +34 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/.gitignore +0 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/aliases.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/cli.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/crypto.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/db.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/harness.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/pricing.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/security.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.7.0}/proxyagent/store.py +0 -0
- {proxyagent-0.6.0 → 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,18 @@ 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
|
+
```
|
|
185
193
|
|
|
186
194
|
## Credential pools & failover
|
|
187
195
|
A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
|
|
@@ -146,10 +146,18 @@ 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
|
+
```
|
|
153
161
|
|
|
154
162
|
## Credential pools & failover
|
|
155
163
|
A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
|
|
@@ -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,7 +12,7 @@ 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
|
|
|
@@ -47,6 +47,34 @@ def resolve_auth(provider, store: Store | None) -> tuple[dict, bool]:
|
|
|
47
47
|
return (cands[0], True) if cands else ({}, False)
|
|
48
48
|
|
|
49
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
|
|
76
|
+
|
|
77
|
+
|
|
50
78
|
def scope_allows(scope: list[str], provider: str, model: str) -> bool:
|
|
51
79
|
"""A scope entry is a glob over 'provider:model', e.g. 'anthropic:claude-*', or '*'."""
|
|
52
80
|
target = f"{provider}:{model or '*'}"
|
|
@@ -86,14 +114,12 @@ async def forward(
|
|
|
86
114
|
return 200, {"content-type": "text/event-stream"}, _mock_stream(provider.shape, payload), None
|
|
87
115
|
return 200, {"content-type": "application/json"}, payload, None
|
|
88
116
|
|
|
89
|
-
|
|
90
|
-
if not
|
|
117
|
+
plans = build_plans(provider, store, body)
|
|
118
|
+
if not plans:
|
|
91
119
|
return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy "
|
|
92
120
|
f"(set {provider.key_env} or `proxyagent provider add {provider_name}`)"}, None
|
|
93
121
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _log(status, ptok, ctok, err=None, attempt=0):
|
|
122
|
+
def _log(status, ptok, ctok, err=None):
|
|
97
123
|
store.log_request(
|
|
98
124
|
token_id=token["id"], token_label=token.get("label"), provider=provider_name,
|
|
99
125
|
model=model, status=status, prompt_tokens=ptok, completion_tokens=ctok,
|
|
@@ -108,12 +134,10 @@ async def forward(
|
|
|
108
134
|
status = 200
|
|
109
135
|
try:
|
|
110
136
|
async with httpx.AsyncClient(timeout=config.request_timeout) as client:
|
|
111
|
-
for i,
|
|
112
|
-
async with client.stream(
|
|
113
|
-
"POST", url, headers={"content-type": "application/json", **auth}, json=body
|
|
114
|
-
) as resp:
|
|
137
|
+
for i, (url, headers, raw) in enumerate(plans):
|
|
138
|
+
async with client.stream("POST", url, headers=headers, content=raw) as resp:
|
|
115
139
|
status = resp.status_code
|
|
116
|
-
if status in FAILOVER_STATUS and i < len(
|
|
140
|
+
if status in FAILOVER_STATUS and i < len(plans) - 1:
|
|
117
141
|
await resp.aread() # drain + rotate to the next credential
|
|
118
142
|
continue
|
|
119
143
|
async for chunk in resp.aiter_raw():
|
|
@@ -134,12 +158,12 @@ async def forward(
|
|
|
134
158
|
_log(status, ptok, ctok)
|
|
135
159
|
return 200, {"content-type": "text/event-stream"}, _gen(), None
|
|
136
160
|
|
|
137
|
-
# Non-streaming, with credential failover.
|
|
161
|
+
# Non-streaming, with credential failover across the pool.
|
|
138
162
|
last_status, last_payload = 502, {"error": "all credentials failed"}
|
|
139
163
|
async with httpx.AsyncClient(timeout=config.request_timeout) as client:
|
|
140
|
-
for i,
|
|
141
|
-
resp = await client.post(url, headers=
|
|
142
|
-
if resp.status_code in FAILOVER_STATUS and i < len(
|
|
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:
|
|
143
167
|
last_status = resp.status_code
|
|
144
168
|
continue
|
|
145
169
|
try:
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -181,14 +181,9 @@ 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;
|
|
@@ -199,18 +194,34 @@ async function refreshProviders(){
|
|
|
199
194
|
<div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${p.via_env?'<span class="badge on">env</span>':''}</div>
|
|
200
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>
|
|
201
196
|
<div class="row"><button class="sm" onclick="openConnect('${p.name}')">+ Add credential</button></div>
|
|
202
|
-
<div class="connect" id="c_${p.name}"
|
|
203
|
-
${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"/>`}
|
|
204
|
-
<input id="k_${p.name}" type="password" placeholder="${p.name} key / token"/>
|
|
205
|
-
<div class="row"><button class="sm" onclick="connect('${p.name}')">Add</button><button class="ghost sm" onclick="openConnect('${p.name}')">Cancel</button></div>
|
|
206
|
-
</div></div>`}).join("");
|
|
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
199
|
}
|
|
209
200
|
function openConnect(n){document.getElementById("c_"+n).classList.toggle("open")}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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()}
|
|
214
225
|
|
|
215
226
|
async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
|
|
216
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"
|
|
@@ -202,3 +202,37 @@ def test_credential_pool_and_failover_order():
|
|
|
202
202
|
assert cands[1]["Authorization"] == "Bearer sk-2" # failover tries #2 next
|
|
203
203
|
listed = s.list_credentials()
|
|
204
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
|
|
File without changes
|