proxyagent 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyagent
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools.
5
5
  Project-URL: Homepage, https://github.com/teddyoweh/proxyagent
6
6
  Author-email: Spawn Labs <teddy@spawnlabs.ai>
@@ -61,6 +61,15 @@ proxy and use the **machine token** as the "api key." The proxy authenticates th
61
61
  checks its scope, **swaps in the real key**, forwards upstream, and logs the call. The
62
62
  machine never sees a real credential.
63
63
 
64
+ ## Try it with zero keys (local)
65
+ ```bash
66
+ pip install proxyagent && proxyagent serve # prints an admin token
67
+ proxyagent token new local --admin pa_admin_… # mint a token
68
+ # call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
69
+ curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
70
+ -d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
71
+ ```
72
+
64
73
  ## Quickstart
65
74
 
66
75
  **1. Run the proxy** (on a box you control — it holds the real keys):
@@ -29,6 +29,15 @@ proxy and use the **machine token** as the "api key." The proxy authenticates th
29
29
  checks its scope, **swaps in the real key**, forwards upstream, and logs the call. The
30
30
  machine never sees a real credential.
31
31
 
32
+ ## Try it with zero keys (local)
33
+ ```bash
34
+ pip install proxyagent && proxyagent serve # prints an admin token
35
+ proxyagent token new local --admin pa_admin_… # mint a token
36
+ # call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
37
+ curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
38
+ -d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
39
+ ```
40
+
32
41
  ## Quickstart
33
42
 
34
43
  **1. Run the proxy** (on a box you control — it holds the real keys):
@@ -16,7 +16,7 @@ from typing import Optional
16
16
 
17
17
  from .harness import run # noqa: F401 (the headline SDK call)
18
18
 
19
- __version__ = "0.2.0"
19
+ __version__ = "0.2.1"
20
20
  __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
21
 
22
22
 
@@ -27,6 +27,8 @@ DEFAULT_PRICES: dict[str, tuple[float, float]] = {
27
27
  "o3-mini": (1.10, 4.40),
28
28
  "o3": (2.0, 8.0),
29
29
  "gpt-5": (1.25, 10.0),
30
+ # offline test provider — free
31
+ "mock": (0.0, 0.0),
30
32
  }
31
33
 
32
34
 
@@ -60,6 +60,23 @@ async def forward(
60
60
  ):
61
61
  """Forward a request upstream. Returns (status, headers, body_iter_or_dict, log_after)."""
62
62
  provider = PROVIDERS[provider_name]
63
+ model = body.get("model", "")
64
+ t0 = now_ms()
65
+
66
+ # Offline mock — exercise the full pipeline (auth, scope, log, cost) with NO real
67
+ # key. Use model "mock" (or "mock-…") anywhere a real model would go.
68
+ if model.startswith("mock"):
69
+ payload, (ptok, ctok) = _mock_payload(provider_name, body)
70
+ store.log_request(
71
+ token_id=token["id"], token_label=token.get("label"), provider=provider_name,
72
+ model=model, status=200, prompt_tokens=ptok, completion_tokens=ctok,
73
+ latency_ms=now_ms() - t0, streamed=1 if streaming else 0,
74
+ tools_used=json.dumps(tools_used or []), cost_usd=pricing.cost_usd(model, ptok, ctok),
75
+ error=None)
76
+ if streaming:
77
+ return 200, {"content-type": "text/event-stream"}, _mock_stream(provider_name, payload), None
78
+ return 200, {"content-type": "application/json"}, payload, None
79
+
63
80
  auth, ok = resolve_auth(provider, store)
64
81
  if not ok:
65
82
  return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy "
@@ -67,8 +84,6 @@ async def forward(
67
84
 
68
85
  url = provider.base_url + upstream_path
69
86
  headers = {"content-type": "application/json", **auth}
70
- model = body.get("model", "")
71
- t0 = now_ms()
72
87
 
73
88
  def _log(status, ptok, ctok, err=None):
74
89
  store.log_request(
@@ -115,3 +130,51 @@ async def forward(
115
130
  ptok, ctok = _extract_usage(provider_name, payload if isinstance(payload, dict) else {})
116
131
  _log(resp.status_code, ptok, ctok, None if resp.is_success else str(payload)[:300])
117
132
  return resp.status_code, {"content-type": "application/json"}, payload, None
133
+
134
+
135
+ # ------------------------------------------------------------------ #
136
+ # Offline mock — provider-shaped canned responses for local testing.
137
+ # ------------------------------------------------------------------ #
138
+
139
+ def _last_user_text(body: dict) -> str:
140
+ for m in reversed(body.get("messages", [])):
141
+ if m.get("role") == "user":
142
+ c = m.get("content")
143
+ if isinstance(c, str):
144
+ return c
145
+ if isinstance(c, list):
146
+ return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
147
+ return ""
148
+
149
+
150
+ def _mock_payload(provider: str, body: dict):
151
+ prompt = _last_user_text(body)[:200]
152
+ text = f"[proxyagent mock] received: {prompt!r}. No real key used — the pipeline works."
153
+ ptok, ctok = max(1, len(prompt) // 4), max(1, len(text) // 4)
154
+ if provider == "anthropic":
155
+ return ({
156
+ "id": "msg_mock", "type": "message", "role": "assistant", "model": body.get("model"),
157
+ "content": [{"type": "text", "text": text}], "stop_reason": "end_turn",
158
+ "usage": {"input_tokens": ptok, "output_tokens": ctok},
159
+ }, (ptok, ctok))
160
+ return ({
161
+ "id": "chatcmpl-mock", "object": "chat.completion", "model": body.get("model"),
162
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": text},
163
+ "finish_reason": "stop"}],
164
+ "usage": {"prompt_tokens": ptok, "completion_tokens": ctok, "total_tokens": ptok + ctok},
165
+ }, (ptok, ctok))
166
+
167
+
168
+ async def _mock_stream(provider: str, payload: dict):
169
+ import json as _j
170
+ if provider == "anthropic":
171
+ text = payload["content"][0]["text"]
172
+ yield f"event: message_start\ndata: {_j.dumps({'type':'message_start','message':payload})}\n\n".encode()
173
+ yield (f"event: content_block_delta\ndata: "
174
+ f"{_j.dumps({'type':'content_block_delta','delta':{'type':'text_delta','text':text}})}\n\n").encode()
175
+ yield b"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
176
+ else:
177
+ text = payload["choices"][0]["message"]["content"]
178
+ chunk = {"choices": [{"delta": {"content": text}, "index": 0}]}
179
+ yield f"data: {_j.dumps(chunk)}\n\n".encode()
180
+ yield b"data: [DONE]\n\n"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proxyagent"
7
- version = "0.2.0"
7
+ version = "0.2.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"
@@ -98,6 +98,25 @@ def test_credential_storage_and_resolution():
98
98
  assert s.remove_credential(cid)
99
99
 
100
100
 
101
+ def test_mock_provider_offline():
102
+ """Full pipeline with no real key: mint → call model 'mock' → response + usage + log."""
103
+ c = _client()
104
+ tok = c.post("/admin/tokens", headers=ADMIN, json={"label": "m", "scope": ["*"]}).json()["token"]
105
+ r = c.post("/anthropic/v1/messages", headers={"x-api-key": tok},
106
+ json={"model": "mock", "max_tokens": 50, "messages": [{"role": "user", "content": "hello"}]})
107
+ assert r.status_code == 200
108
+ body = r.json()
109
+ assert body["content"][0]["text"].startswith("[proxyagent mock]")
110
+ assert body["usage"]["input_tokens"] >= 1
111
+ # it was logged (with $0 cost)
112
+ logs = c.get("/admin/logs", headers=ADMIN).json()["logs"]
113
+ assert logs[0]["model"] == "mock" and logs[0]["status"] == 200
114
+ # openai shape too
115
+ r2 = c.post("/openai/v1/chat/completions", headers={"authorization": f"Bearer {tok}"},
116
+ json={"model": "mock", "messages": [{"role": "user", "content": "hi"}]})
117
+ assert r2.json()["choices"][0]["message"]["content"].startswith("[proxyagent mock]")
118
+
119
+
101
120
  def test_provider_admin_endpoints():
102
121
  c = _client()
103
122
  r = c.post("/admin/providers", headers=ADMIN, json={"provider": "anthropic", "secret": "sk-ant-x"})
File without changes
File without changes
File without changes