relay-codex 0.0.0-stage → 0.4.1
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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +92 -0
- package/docs/publishing.md +56 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +47 -4
- package/python/Menu.swift +70 -0
- package/python/broker.py +297 -0
- package/python/commands.py +701 -0
- package/python/compatibility.py +225 -0
- package/python/desktop.py +52 -0
- package/python/launch.py +117 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +579 -0
- package/python/probe_native.py +91 -0
- package/python/processes.py +12 -0
- package/python/requirements.lock +6 -0
- package/python/requirements.txt +2 -0
- package/python/router.py +2053 -0
- package/python/runtime.py +97 -0
- package/python/service.py +663 -0
- package/python/settings.py +109 -0
- package/tests/fake_native.py +238 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +519 -0
- package/tests/test_pool.py +446 -0
- package/tests/test_relay.py +173 -0
- package/tests/test_router.py +314 -0
- package/tests/test_services.py +473 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Offline tests: no real accounts, Keychain access, or model calls."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
from router import (
|
|
10
|
+
Controller,
|
|
11
|
+
RPC,
|
|
12
|
+
RemoteError,
|
|
13
|
+
RouterError,
|
|
14
|
+
account_home,
|
|
15
|
+
limited,
|
|
16
|
+
transport_guard,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeAccount:
|
|
21
|
+
def __init__(self, label, blocked=False, error=False):
|
|
22
|
+
self.label = label
|
|
23
|
+
self.blocked = blocked
|
|
24
|
+
self.error = error
|
|
25
|
+
self.checks = 0
|
|
26
|
+
|
|
27
|
+
async def usage(self, force=False):
|
|
28
|
+
self.checks += 1
|
|
29
|
+
if self.error:
|
|
30
|
+
raise RouterError("unavailable")
|
|
31
|
+
return {"ordinaryUsageAllowed": not self.blocked}
|
|
32
|
+
|
|
33
|
+
async def credentials(self, refresh=False):
|
|
34
|
+
return {"accessToken": "FAKE_NOT_A_CREDENTIAL", "chatgptAccountId": self.label}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FakeBackend:
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.calls = []
|
|
40
|
+
self.sent = []
|
|
41
|
+
|
|
42
|
+
async def call(self, method, params=None, **kwargs):
|
|
43
|
+
self.calls.append((method, params))
|
|
44
|
+
if method == "account/read":
|
|
45
|
+
return {"account": {"type": "chatgpt"}}
|
|
46
|
+
return {"ok": True}
|
|
47
|
+
|
|
48
|
+
async def send(self, message):
|
|
49
|
+
self.sent.append(message)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FakeClient:
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.messages = []
|
|
55
|
+
|
|
56
|
+
async def send(self, raw):
|
|
57
|
+
self.messages.append(json.loads(raw))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def completed(
|
|
61
|
+
code="usageLimitExceeded", status="failed", thread="thread-1", turn="turn-1"
|
|
62
|
+
):
|
|
63
|
+
return {
|
|
64
|
+
"method": "turn/completed",
|
|
65
|
+
"params": {
|
|
66
|
+
"threadId": thread,
|
|
67
|
+
"turn": {"id": turn, "status": status, "error": {"codexErrorInfo": code}},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ControllerTests(unittest.IsolatedAsyncioTestCase):
|
|
73
|
+
async def asyncSetUp(self):
|
|
74
|
+
self.backend = FakeBackend()
|
|
75
|
+
self.accounts = [FakeAccount("one"), FakeAccount("two"), FakeAccount("three")]
|
|
76
|
+
self.reports = []
|
|
77
|
+
self.c = Controller(
|
|
78
|
+
self.accounts, self.backend, self.reports.append, interval=0.02
|
|
79
|
+
)
|
|
80
|
+
self.c.client = FakeClient()
|
|
81
|
+
self.c.last_turn["thread-1"] = {
|
|
82
|
+
"threadId": "thread-1",
|
|
83
|
+
"model": "chosen-model",
|
|
84
|
+
"input": [{"text": "UNIQUE_ORIGINAL_USER_REQUEST"}],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async def asyncTearDown(self):
|
|
88
|
+
if self.c.recovery:
|
|
89
|
+
self.c.recovery.cancel()
|
|
90
|
+
await asyncio.gather(self.c.recovery, return_exceptions=True)
|
|
91
|
+
|
|
92
|
+
async def test_quota_switch_and_continue_exact_thread_without_replay(self):
|
|
93
|
+
await self.c.event(completed())
|
|
94
|
+
await asyncio.wait_for(self.c.recovery, 1)
|
|
95
|
+
self.assertEqual(self.c.index, 1)
|
|
96
|
+
self.assertEqual(
|
|
97
|
+
[name for name, _ in self.backend.calls],
|
|
98
|
+
["account/login/start", "account/read", "turn/start"],
|
|
99
|
+
)
|
|
100
|
+
params = self.backend.calls[-1][1]
|
|
101
|
+
self.assertEqual(params["threadId"], "thread-1")
|
|
102
|
+
self.assertNotIn("UNIQUE_ORIGINAL_USER_REQUEST", params["input"][0]["text"])
|
|
103
|
+
self.assertNotIn("approvalPolicy", params) # Retain existing thread policy.
|
|
104
|
+
self.assertNotIn(
|
|
105
|
+
"model", params
|
|
106
|
+
) # Retain the sticky model; no silent downgrade.
|
|
107
|
+
|
|
108
|
+
async def test_duplicates_do_not_create_two_turns(self):
|
|
109
|
+
message = completed()
|
|
110
|
+
await self.c.event(message)
|
|
111
|
+
await self.c.event(message)
|
|
112
|
+
await self.c.recovery
|
|
113
|
+
await self.c.event(message)
|
|
114
|
+
self.assertEqual(sum(name == "turn/start" for name, _ in self.backend.calls), 1)
|
|
115
|
+
|
|
116
|
+
async def test_policy_errors_and_success_never_rotate(self):
|
|
117
|
+
for code in [
|
|
118
|
+
"cyberPolicy",
|
|
119
|
+
"misalignmentPolicyViolation",
|
|
120
|
+
"contextWindowExceeded",
|
|
121
|
+
"badRequest",
|
|
122
|
+
]:
|
|
123
|
+
await self.c.event(completed(code=code, turn=code))
|
|
124
|
+
await self.c.event(completed(status="completed"))
|
|
125
|
+
await self.c.event(completed(status="interrupted"))
|
|
126
|
+
self.assertIsNone(self.c.recovery)
|
|
127
|
+
self.assertEqual(self.backend.calls, [])
|
|
128
|
+
|
|
129
|
+
async def test_untracked_thread_never_resubmitted(self):
|
|
130
|
+
await self.c.event(completed(thread="subagent"))
|
|
131
|
+
self.assertIsNone(self.c.recovery)
|
|
132
|
+
|
|
133
|
+
async def test_skips_blocked_and_failed_accounts(self):
|
|
134
|
+
self.accounts[1].blocked = True
|
|
135
|
+
await self.c.event(completed())
|
|
136
|
+
await self.c.recovery
|
|
137
|
+
self.assertEqual(self.c.index, 2)
|
|
138
|
+
|
|
139
|
+
async def test_pending_approval_prevents_switch(self):
|
|
140
|
+
self.c.server_requests.add(991)
|
|
141
|
+
await self.c.event(completed())
|
|
142
|
+
await asyncio.sleep(0.03)
|
|
143
|
+
self.assertEqual(self.backend.calls, [])
|
|
144
|
+
await self.c.forward({"id": 991, "result": {"decision": "accept"}}, {})
|
|
145
|
+
self.assertEqual(self.backend.sent[-1]["id"], 991)
|
|
146
|
+
self.c.recovery.cancel()
|
|
147
|
+
|
|
148
|
+
async def test_running_subtask_prevents_switch(self):
|
|
149
|
+
self.c.active_threads.add("still-running")
|
|
150
|
+
await self.c.event(completed())
|
|
151
|
+
await asyncio.sleep(0.03)
|
|
152
|
+
self.assertEqual(self.backend.calls, [])
|
|
153
|
+
|
|
154
|
+
async def test_resolved_server_request_does_not_leave_stale_approval_wait(self):
|
|
155
|
+
self.c.server_requests.add(991)
|
|
156
|
+
await self.c.event(
|
|
157
|
+
{
|
|
158
|
+
"method": "serverRequest/resolved",
|
|
159
|
+
"params": {"requestId": 991, "threadId": "thread-1"},
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
self.assertFalse(self.c.server_requests)
|
|
163
|
+
await self.c.event(completed())
|
|
164
|
+
await self.c.recovery
|
|
165
|
+
self.assertEqual(self.c.index, 1)
|
|
166
|
+
|
|
167
|
+
async def test_user_interrupt_cancels_wait(self):
|
|
168
|
+
self.accounts[1].blocked = self.accounts[2].blocked = True
|
|
169
|
+
await self.c.event(completed())
|
|
170
|
+
await asyncio.sleep(0.005)
|
|
171
|
+
await self.c.forward(
|
|
172
|
+
{"id": 7, "method": "turn/interrupt", "params": {"threadId": "thread-1"}},
|
|
173
|
+
{},
|
|
174
|
+
)
|
|
175
|
+
await asyncio.sleep(0.005)
|
|
176
|
+
self.assertTrue(self.c.recovery.cancelled())
|
|
177
|
+
self.assertFalse(
|
|
178
|
+
any(name == "account/login/start" for name, _ in self.backend.calls)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def test_no_busy_loop_when_all_accounts_exhausted(self):
|
|
182
|
+
for account in self.accounts:
|
|
183
|
+
account.blocked = True
|
|
184
|
+
await self.c.event(completed())
|
|
185
|
+
await asyncio.sleep(0.055)
|
|
186
|
+
self.assertLessEqual(sum(account.checks for account in self.accounts), 9)
|
|
187
|
+
self.assertEqual(self.backend.calls, [])
|
|
188
|
+
|
|
189
|
+
async def test_refresh_never_returns_different_account(self):
|
|
190
|
+
await self.c.event(
|
|
191
|
+
{
|
|
192
|
+
"id": 88,
|
|
193
|
+
"method": "account/chatgptAuthTokens/refresh",
|
|
194
|
+
"params": {"previousAccountId": "different"},
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
self.assertIn("error", self.backend.sent[-1])
|
|
198
|
+
self.assertFalse(self.c.client.messages)
|
|
199
|
+
|
|
200
|
+
async def test_refresh_credentials_not_forwarded_to_frontend(self):
|
|
201
|
+
await self.c.event(
|
|
202
|
+
{
|
|
203
|
+
"id": 88,
|
|
204
|
+
"method": "account/chatgptAuthTokens/refresh",
|
|
205
|
+
"params": {"previousAccountId": "one"},
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
self.assertIn("result", self.backend.sent[-1])
|
|
209
|
+
self.assertFalse(self.c.client.messages)
|
|
210
|
+
|
|
211
|
+
async def test_pool_auth_cannot_be_replaced_by_frontend_logout(self):
|
|
212
|
+
await self.c.forward({"id": 1, "method": "account/logout"}, {})
|
|
213
|
+
self.assertEqual(self.backend.calls, [])
|
|
214
|
+
self.assertIn("error", self.c.client.messages[-1])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class PolicyTests(unittest.TestCase):
|
|
218
|
+
def test_percentage_alone_is_not_a_block(self):
|
|
219
|
+
self.assertFalse(limited({"rateLimits": {"primary": {"usedPercent": 100}}}))
|
|
220
|
+
self.assertTrue(limited({"ordinaryUsageAllowed": False}))
|
|
221
|
+
self.assertTrue(
|
|
222
|
+
limited({"rateLimits": {"rateLimitReachedType": "rate_limit_reached"}})
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def test_other_model_bucket_is_not_a_global_block(self):
|
|
226
|
+
value = {
|
|
227
|
+
"rateLimitsByLimitId": {
|
|
228
|
+
"other": {
|
|
229
|
+
"normalModelSlug": "other-model",
|
|
230
|
+
"rateLimitReachedType": "rate_limit_reached",
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
self.assertFalse(limited(value, "chosen-model"))
|
|
235
|
+
self.assertTrue(limited(value, "other-model"))
|
|
236
|
+
|
|
237
|
+
def test_label_cannot_escape_account_root(self):
|
|
238
|
+
for label in ["../bad", "/tmp/bad", "a/b", "", ".", "a\n"]:
|
|
239
|
+
with self.assertRaises(RouterError):
|
|
240
|
+
account_home(Path("/private/test"), label)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class RPCTests(unittest.IsolatedAsyncioTestCase):
|
|
244
|
+
async def test_multiplexing_errors_and_disconnect(self):
|
|
245
|
+
program = """
|
|
246
|
+
import json,sys
|
|
247
|
+
for raw in sys.stdin:
|
|
248
|
+
d=json.loads(raw)
|
|
249
|
+
if d['method']=='quit': break
|
|
250
|
+
if d['method']=='bad':
|
|
251
|
+
reply={'id':d['id'],'error':{'code':-32602,'message':'specific client error'}}
|
|
252
|
+
else: reply={'id':d['id'],'result':{'echo':d['params']}}
|
|
253
|
+
print(json.dumps(reply),flush=True)
|
|
254
|
+
"""
|
|
255
|
+
proc = await asyncio.create_subprocess_exec(
|
|
256
|
+
sys.executable,
|
|
257
|
+
"-u",
|
|
258
|
+
"-c",
|
|
259
|
+
program,
|
|
260
|
+
stdin=asyncio.subprocess.PIPE,
|
|
261
|
+
stdout=asyncio.subprocess.PIPE,
|
|
262
|
+
)
|
|
263
|
+
rpc = RPC(proc)
|
|
264
|
+
try:
|
|
265
|
+
a, b = await asyncio.gather(
|
|
266
|
+
rpc.call("echo", {"a": 1}), rpc.call("echo", {"b": 2})
|
|
267
|
+
)
|
|
268
|
+
self.assertEqual(a, {"echo": {"a": 1}})
|
|
269
|
+
self.assertEqual(b, {"echo": {"b": 2}})
|
|
270
|
+
with self.assertRaises(RemoteError) as ctx:
|
|
271
|
+
await rpc.call("bad", {})
|
|
272
|
+
self.assertEqual(ctx.exception.payload["code"], -32602)
|
|
273
|
+
with self.assertRaises(RouterError):
|
|
274
|
+
await rpc.call("quit", {}, timeout=1)
|
|
275
|
+
finally:
|
|
276
|
+
await rpc.close()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class TransportTests(unittest.IsolatedAsyncioTestCase):
|
|
280
|
+
async def test_loopback_transport_requires_bearer_and_rejects_browser_origins(self):
|
|
281
|
+
from websockets.asyncio.server import serve
|
|
282
|
+
from websockets.asyncio.client import connect
|
|
283
|
+
from websockets.exceptions import InvalidStatus
|
|
284
|
+
|
|
285
|
+
async def echo(ws):
|
|
286
|
+
async for message in ws:
|
|
287
|
+
await ws.send(message)
|
|
288
|
+
|
|
289
|
+
async with serve(
|
|
290
|
+
echo, "127.0.0.1", 0, process_request=transport_guard("test-token")
|
|
291
|
+
) as server:
|
|
292
|
+
url = f"ws://127.0.0.1:{server.sockets[0].getsockname()[1]}"
|
|
293
|
+
for headers in [
|
|
294
|
+
{},
|
|
295
|
+
{"Authorization": "Bearer wrong"},
|
|
296
|
+
{
|
|
297
|
+
"Authorization": "Bearer test-token",
|
|
298
|
+
"Origin": "https://example.invalid",
|
|
299
|
+
},
|
|
300
|
+
]:
|
|
301
|
+
with self.assertRaises(InvalidStatus):
|
|
302
|
+
async with connect(url, additional_headers=headers, proxy=None):
|
|
303
|
+
pass
|
|
304
|
+
async with connect(
|
|
305
|
+
url,
|
|
306
|
+
additional_headers={"Authorization": "Bearer test-token"},
|
|
307
|
+
proxy=None,
|
|
308
|
+
) as ws:
|
|
309
|
+
await ws.send("test")
|
|
310
|
+
self.assertEqual(await ws.recv(), "test")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
unittest.main()
|