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.
@@ -0,0 +1,446 @@
1
+ """Schedules use an injected clock. No real five-minute delays or accounts."""
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+
10
+ from pool import Pool, file_lock, reset_time, failure_kind
11
+ from router import Controller, RouterError, RPC
12
+ from test_router import FakeAccount, FakeBackend, FakeClient, completed
13
+ import compatibility
14
+
15
+
16
+ class Clock:
17
+ def __init__(self):
18
+ self.now = 1000
19
+ self.waits = []
20
+
21
+ def __call__(self):
22
+ return self.now
23
+
24
+ async def sleep(self, seconds):
25
+ self.waits.append(seconds)
26
+ self.now += seconds
27
+ await asyncio.sleep(0)
28
+
29
+
30
+ class ScheduleTests(unittest.IsolatedAsyncioTestCase):
31
+ async def asyncSetUp(self):
32
+ self.tmp = tempfile.TemporaryDirectory()
33
+ self.clock = Clock()
34
+ self.pool = Pool(Path(self.tmp.name), clock=self.clock)
35
+ self.accounts = [FakeAccount("one"), FakeAccount("two"), FakeAccount("three")]
36
+ self.backend = FakeBackend()
37
+ self.controller = Controller(
38
+ self.accounts,
39
+ self.backend,
40
+ report=lambda x: None,
41
+ pool=self.pool,
42
+ clock=self.clock,
43
+ sleep=self.clock.sleep,
44
+ )
45
+ self.controller.client = FakeClient()
46
+ self.controller.last_turn["thread-1"] = {"model": "model-x"}
47
+
48
+ async def asyncTearDown(self):
49
+ if self.controller.recovery:
50
+ self.controller.recovery.cancel()
51
+ await asyncio.gather(self.controller.recovery, return_exceptions=True)
52
+ self.pool.close()
53
+ self.tmp.cleanup()
54
+
55
+ async def fail(self, code="internalServerError", turn="turn"):
56
+ await self.controller.event(completed(code=code, turn=turn))
57
+ await self.controller.recovery
58
+
59
+ async def test_same_account_retry_at_5_10_15_minutes_then_switch(self):
60
+ for attempt in range(3):
61
+ await self.fail(turn=str(attempt))
62
+ self.assertEqual(self.controller.index, 0)
63
+ self.assertEqual(self.clock.now, 1000 + 300 * (attempt + 1))
64
+ self.assertEqual(sum(name == "turn/start" for name, _ in self.backend.calls), 3)
65
+ self.assertFalse(
66
+ any(name == "account/login/start" for name, _ in self.backend.calls)
67
+ )
68
+ await self.fail(turn="last")
69
+ self.assertNotEqual(self.controller.index, 0)
70
+ self.assertEqual(self.clock.now, 1900)
71
+
72
+ async def test_revoked_login_retries_then_changes_account(self):
73
+ async def revoked(refresh=False):
74
+ raise RouterError("revoked")
75
+
76
+ self.accounts[0].credentials = revoked
77
+ await self.fail("unauthorized")
78
+ self.assertEqual(self.clock.waits, [300, 300, 300])
79
+ self.assertEqual(self.controller.index, 1)
80
+
81
+ async def test_success_clears_failure_episode(self):
82
+ await self.fail()
83
+ await self.controller.event(completed(status="completed", turn="success"))
84
+ self.assertFalse(self.controller.episodes)
85
+ await self.fail(turn="new")
86
+ self.assertEqual(self.controller.episodes["thread-1"]["attempt"], 1)
87
+
88
+ async def test_cancel_pending_retry_no_continuation(self):
89
+ waiting = asyncio.Event()
90
+
91
+ async def sleep(seconds):
92
+ waiting.set()
93
+ await asyncio.Future()
94
+
95
+ self.controller.sleep = sleep
96
+ await self.controller.event(completed(code="internalServerError"))
97
+ await waiting.wait()
98
+ await self.controller.forward(
99
+ {"id": 9, "method": "turn/interrupt", "params": {"threadId": "thread-1"}},
100
+ {},
101
+ )
102
+ await asyncio.sleep(0)
103
+ self.assertTrue(self.controller.recovery.cancelled())
104
+ self.assertFalse(any(m == "turn/start" for m, _ in self.backend.calls))
105
+
106
+ async def test_all_exhausted_wait_until_earliest_account_reset_no_polling(self):
107
+ for label, reset in [("one", 5000), ("two", 3000), ("three", 7000)]:
108
+ self.pool.mark(
109
+ label,
110
+ "model-x",
111
+ snapshot={
112
+ "ordinaryUsageAllowed": False,
113
+ "rateLimits": {"primary": {"usedPercent": 100, "resetsAt": reset}},
114
+ },
115
+ )
116
+ index, deadline = await self.controller.choose("model-x")
117
+ self.assertIsNone(index)
118
+ self.assertEqual(deadline, 3002)
119
+ self.assertEqual(sum(a.checks for a in self.accounts), 0)
120
+ self.clock.now = deadline
121
+ index, _ = await self.controller.choose("model-x")
122
+ self.assertEqual(index, 1)
123
+ self.assertEqual([a.checks for a in self.accounts], [0, 1, 0])
124
+
125
+ async def test_reset_must_be_rechecked_not_assumed_available(self):
126
+ self.pool.mark(
127
+ "two",
128
+ "model-x",
129
+ snapshot={
130
+ "rateLimits": {
131
+ "rateLimitReachedType": "rate_limit_reached",
132
+ "primary": {"usedPercent": 100, "resetsAt": 1100},
133
+ }
134
+ },
135
+ )
136
+ self.accounts[1].blocked = True
137
+ self.clock.now = 1102
138
+ self.pool.mark("one", "model-x", delay=900)
139
+ self.pool.mark("three", "model-x", delay=900)
140
+ index, deadline = await self.controller.choose("model-x")
141
+ self.assertIsNone(index)
142
+ self.assertGreater(deadline, self.clock.now)
143
+
144
+ async def test_unknown_reset_uses_five_minutes_not_busy_cycle(self):
145
+ for a in self.accounts:
146
+ a.blocked = True
147
+ index, until = await self.controller.choose("model-x")
148
+ self.assertIsNone(index)
149
+ self.assertEqual(until, 1300)
150
+ for _ in range(10):
151
+ await self.controller.choose("model-x")
152
+ self.assertEqual(sum(a.checks for a in self.accounts), 3)
153
+
154
+ async def test_cancellation_during_login_finishes_identity_transition(self):
155
+ started, finish = asyncio.Event(), asyncio.Event()
156
+ original = self.backend.call
157
+
158
+ async def delayed(method, params=None, **kwargs):
159
+ if method == "account/login/start":
160
+ started.set()
161
+ await finish.wait()
162
+ return await original(method, params, **kwargs)
163
+
164
+ self.backend.call = delayed
165
+ task = asyncio.create_task(self.controller.login(1))
166
+ await started.wait()
167
+ task.cancel()
168
+ await asyncio.sleep(0)
169
+ self.assertFalse(task.done())
170
+ finish.set()
171
+ await asyncio.gather(task, return_exceptions=True)
172
+ self.assertEqual(self.controller.index, 1)
173
+ self.assertTrue(task.cancelled())
174
+ self.assertEqual(self.pool.loads().get("two"), 1)
175
+
176
+ async def test_same_conversation_cannot_run_in_two_terminals(self):
177
+ other = Pool(Path(self.tmp.name))
178
+ try:
179
+ self.assertTrue(other.claim("thread-1"))
180
+ await self.controller.forward(
181
+ {
182
+ "id": 22,
183
+ "method": "turn/start",
184
+ "params": {"threadId": "thread-1", "input": []},
185
+ },
186
+ {},
187
+ )
188
+ self.assertEqual(self.backend.calls, [])
189
+ self.assertIn("error", self.controller.client.messages[-1])
190
+ finally:
191
+ other.close()
192
+
193
+
194
+ class PoolTests(unittest.TestCase):
195
+ def test_both_short_and_weekly_windows_must_reset(self):
196
+ snapshot = {
197
+ "rateLimits": {
198
+ "primary": {"usedPercent": 100, "resetsAt": 2000},
199
+ "secondary": {"usedPercent": 100, "resetsAt": 9000},
200
+ }
201
+ }
202
+ self.assertEqual(reset_time(snapshot, now=1000), 9002)
203
+ snapshot["rateLimits"]["secondary"]["usedPercent"] = 42
204
+ self.assertEqual(reset_time(snapshot, now=1000), 2002)
205
+
206
+ def test_missing_or_past_reset_is_not_invented(self):
207
+ for value in [None, 10]:
208
+ self.assertIsNone(
209
+ reset_time(
210
+ {
211
+ "rateLimits": {
212
+ "primary": {"usedPercent": 100, "resetsAt": value}
213
+ }
214
+ },
215
+ now=1000,
216
+ )
217
+ )
218
+
219
+ def test_all_retry_classes_and_no_policy_circumvention(self):
220
+ for code in [
221
+ "usageLimitExceeded",
222
+ "unauthorized",
223
+ "internalServerError",
224
+ "rateLimitExceeded",
225
+ ]:
226
+ self.assertIsNotNone(failure_kind(code))
227
+ for code in [
228
+ "cyberPolicy",
229
+ "misalignmentPolicyViolation",
230
+ "badRequest",
231
+ "sandboxError",
232
+ None,
233
+ ]:
234
+ self.assertIsNone(failure_kind(code))
235
+ for status in [None, 408, 429, 500, 503]:
236
+ self.assertEqual(
237
+ failure_kind({"httpConnectionFailed": {"httpStatusCode": status}}),
238
+ "transient",
239
+ )
240
+ self.assertEqual(
241
+ failure_kind({"httpConnectionFailed": {"httpStatusCode": 401}}), "auth"
242
+ )
243
+ self.assertIsNone(
244
+ failure_kind({"httpConnectionFailed": {"httpStatusCode": 403}})
245
+ )
246
+ self.assertEqual(
247
+ failure_kind({"responseStreamDisconnected": {"httpStatusCode": 200}}),
248
+ "transient",
249
+ )
250
+
251
+ def test_six_pool_clients_unique_threads_and_shared_reset(self):
252
+ with tempfile.TemporaryDirectory() as folder:
253
+ pools = [Pool(Path(folder)) for _ in range(6)]
254
+ try:
255
+ for i, pool in enumerate(pools):
256
+ self.assertTrue(pool.claim(f"thread-{i}"))
257
+ pool.assign(f"account{i % 4}")
258
+ self.assertEqual(sum(pools[0].loads().values()), 6)
259
+ self.assertFalse(pools[1].claim("thread-0"))
260
+ pools[0].mark("account0", "model", delay=500)
261
+ self.assertEqual(
262
+ pools[5].deadline("account0", "model"),
263
+ pools[0].deadline("account0", "model"),
264
+ )
265
+ pools[0].close()
266
+ self.assertTrue(pools[1].claim("thread-0"))
267
+ finally:
268
+ for pool in pools:
269
+ pool.close()
270
+
271
+ def test_cache_contains_usage_only_and_is_shared(self):
272
+ with tempfile.TemporaryDirectory() as folder:
273
+ a, b = Pool(Path(folder)), Pool(Path(folder))
274
+ a.save("one", {"rateLimits": {"primary": {"usedPercent": 24}}})
275
+ self.assertEqual(a.cached("one"), b.cached("one"))
276
+ a.invalidate("one")
277
+ self.assertIsNone(b.cached("one"))
278
+
279
+
280
+ class ConcurrencyTests(unittest.IsolatedAsyncioTestCase):
281
+ async def test_native_startup_lock_prevents_shared_database_initialization_race(
282
+ self,
283
+ ):
284
+ with tempfile.TemporaryDirectory() as folder:
285
+ root = Path(folder)
286
+ native = root / "fake-codex"
287
+ native.write_text(
288
+ "#!"
289
+ + str(Path(sys.executable).resolve())
290
+ + "\n"
291
+ + """
292
+ import os,sys,time,json
293
+ from pathlib import Path
294
+ home=Path(os.environ['CODEX_HOME'])
295
+ try:
296
+ fd=os.open(home/'native-migration-active',os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
297
+ except FileExistsError:
298
+ sys.exit(2)
299
+ time.sleep(.025)
300
+ os.close(fd); (home/'native-migration-active').unlink()
301
+ for raw in sys.stdin:
302
+ value=json.loads(raw)
303
+ if 'id' in value:
304
+ print(json.dumps({'id':value['id'],'result':{'ok':True}}),flush=True)
305
+ """
306
+ )
307
+ native.chmod(0o700)
308
+ home = root / "home"
309
+ backends = []
310
+
311
+ async def start():
312
+ rpc = await RPC.start(str(native), home)
313
+ backends.append(rpc)
314
+ await rpc.initialize()
315
+
316
+ try:
317
+ await asyncio.gather(*(start() for _ in range(6)))
318
+ self.assertEqual(len(backends), 6)
319
+ self.assertTrue(all(rpc.proc.returncode is None for rpc in backends))
320
+ finally:
321
+ await asyncio.gather(
322
+ *(rpc.close() for rpc in backends), return_exceptions=True
323
+ )
324
+
325
+ async def test_six_processes_serialize_only_credential_operation(self):
326
+ # Actual OS locks across independent Python processes, not coroutine mocks.
327
+ with tempfile.TemporaryDirectory() as folder:
328
+ program = """
329
+ import asyncio,sys,json
330
+ from pathlib import Path
331
+ from pool import file_lock,Pool
332
+ async def main():
333
+ root=Path(sys.argv[1]); pool=Pool(root)
334
+ pool.assign('shared')
335
+ assert pool.claim('thread-'+sys.argv[2])
336
+ async with file_lock(root/'credential.lock'):
337
+ p=root/'counter.json'; value=json.loads(p.read_text()) if p.exists() else {'active':0,'max':0,'finished':0}
338
+ value['active']+=1; value['max']=max(value['max'],value['active']); p.write_text(json.dumps(value))
339
+ await asyncio.sleep(.03)
340
+ value=json.loads(p.read_text()); value['active']-=1; value['finished']+=1; p.write_text(json.dumps(value))
341
+ await asyncio.sleep(.02)
342
+ pool.close()
343
+ asyncio.run(main())
344
+ """
345
+ processes = [
346
+ await asyncio.create_subprocess_exec(
347
+ sys.executable,
348
+ "-c",
349
+ program,
350
+ folder,
351
+ str(i),
352
+ stdout=asyncio.subprocess.PIPE,
353
+ stderr=asyncio.subprocess.PIPE,
354
+ )
355
+ for i in range(6)
356
+ ]
357
+ for p in processes:
358
+ out, err = await p.communicate()
359
+ self.assertEqual(p.returncode, 0, err.decode())
360
+ value = json.loads((Path(folder) / "counter.json").read_text())
361
+ self.assertEqual(value, {"active": 0, "max": 1, "finished": 6})
362
+
363
+ async def test_cancel_waiting_lock_does_not_leak_lock(self):
364
+ with tempfile.TemporaryDirectory() as folder:
365
+ path = Path(folder) / "lock"
366
+
367
+ async def wait():
368
+ async with file_lock(path):
369
+ pass
370
+
371
+ async with file_lock(path):
372
+ task = asyncio.create_task(wait())
373
+ await asyncio.sleep(0.01)
374
+ task.cancel()
375
+ await asyncio.gather(task, return_exceptions=True)
376
+ await asyncio.wait_for(wait(), 1)
377
+
378
+
379
+ class CompatibilityTests(unittest.TestCase):
380
+ def fixture(self, root):
381
+ methods = [
382
+ "initialize",
383
+ "account/read",
384
+ "account/login/start",
385
+ "account/rateLimits/read",
386
+ "thread/start",
387
+ "thread/resume",
388
+ "turn/start",
389
+ "turn/interrupt",
390
+ ]
391
+ (root / "v2").mkdir()
392
+ (root / "ClientRequest.json").write_text(
393
+ json.dumps(
394
+ {"oneOf": [{"properties": {"method": {"enum": [m]}}} for m in methods]}
395
+ )
396
+ )
397
+ (root / "v2/LoginAccountParams.json").write_text(
398
+ json.dumps(
399
+ {
400
+ "oneOf": [
401
+ {
402
+ "properties": {
403
+ "type": {"enum": ["chatgptAuthTokens"]},
404
+ "accessToken": {},
405
+ "chatgptAccountId": {},
406
+ }
407
+ }
408
+ ]
409
+ }
410
+ )
411
+ )
412
+ for name, fields in {
413
+ "v2/TurnStartParams.json": ["threadId", "input"],
414
+ "v2/TurnCompletedNotification.json": ["threadId", "turn"],
415
+ "v2/GetAccountRateLimitsResponse.json": ["rateLimits"],
416
+ "ChatgptAuthTokensRefreshParams.json": ["reason", "previousAccountId"],
417
+ }.items():
418
+ (root / name).write_text(
419
+ json.dumps({"properties": dict.fromkeys(fields, {})})
420
+ )
421
+
422
+ def test_additive_protocol_changes_are_accepted(self):
423
+ with tempfile.TemporaryDirectory() as folder:
424
+ root = Path(folder)
425
+ self.fixture(root)
426
+ compatibility.validate_schema(root)
427
+ file = root / "v2/LoginAccountParams.json"
428
+ value = json.loads(file.read_text())
429
+ value["oneOf"][0]["properties"]["newOptionalField"] = {"type": "string"}
430
+ file.write_text(json.dumps(value))
431
+ compatibility.validate_schema(root)
432
+
433
+ def test_breaking_auth_change_is_rejected(self):
434
+ with tempfile.TemporaryDirectory() as folder:
435
+ root = Path(folder)
436
+ self.fixture(root)
437
+ file = root / "v2/LoginAccountParams.json"
438
+ value = json.loads(file.read_text())
439
+ value["oneOf"][0]["required"] = ["newRequiredField"]
440
+ file.write_text(json.dumps(value))
441
+ with self.assertRaises(ValueError):
442
+ compatibility.validate_schema(root)
443
+
444
+
445
+ if __name__ == "__main__":
446
+ unittest.main()
@@ -0,0 +1,173 @@
1
+ import json
2
+ from pathlib import Path
3
+ import tempfile
4
+ import unittest
5
+ from unittest.mock import AsyncMock, patch
6
+
7
+ import broker
8
+ import commands
9
+ from pool import Pool
10
+ from router import Controller, parser, RouterError, selected_binary
11
+ import service
12
+ from settings import atomic_json
13
+ from test_router import FakeAccount, FakeBackend, FakeClient
14
+
15
+
16
+ class ModelRules(unittest.IsolatedAsyncioTestCase):
17
+ async def test_model_change_routes_to_an_eligible_account(self):
18
+ with tempfile.TemporaryDirectory() as directory:
19
+ pool = Pool(Path(directory))
20
+ pool.assign("one")
21
+ pool.set_options("one", models=["old-model"])
22
+ pool.set_options("two", models=["new-model"])
23
+ backend = FakeBackend()
24
+ c = Controller(
25
+ [FakeAccount("one"), FakeAccount("two")],
26
+ backend,
27
+ pool=pool,
28
+ report=lambda _: None,
29
+ )
30
+ c.client = FakeClient()
31
+ try:
32
+ await c.forward(
33
+ {
34
+ "id": 1,
35
+ "method": "turn/start",
36
+ "params": {
37
+ "threadId": "thread",
38
+ "model": "new-model",
39
+ "input": [],
40
+ },
41
+ },
42
+ {},
43
+ )
44
+ self.assertEqual(c.account.label, "two")
45
+ self.assertTrue(any(m == "turn/start" for m, _ in backend.calls))
46
+ finally:
47
+ pool.close()
48
+
49
+ async def test_disallowed_model_is_not_submitted_when_no_account_is_eligible(self):
50
+ with tempfile.TemporaryDirectory() as directory:
51
+ pool = Pool(Path(directory))
52
+ pool.set_options("one", models=["allowed"])
53
+ backend = FakeBackend()
54
+ c = Controller(
55
+ [FakeAccount("one")], backend, pool=pool, report=lambda _: None
56
+ )
57
+ c.client = FakeClient()
58
+ try:
59
+ await c.forward(
60
+ {
61
+ "id": 1,
62
+ "method": "turn/start",
63
+ "params": {
64
+ "threadId": "thread",
65
+ "model": "disallowed",
66
+ "input": [],
67
+ },
68
+ },
69
+ {},
70
+ )
71
+ self.assertFalse(any(m == "turn/start" for m, _ in backend.calls))
72
+ self.assertIn("error", c.client.messages[-1])
73
+ finally:
74
+ pool.close()
75
+
76
+
77
+ class Lifecycle(unittest.IsolatedAsyncioTestCase):
78
+ def test_helper_does_not_require_global_codex_when_runtime_is_provided(self):
79
+ with tempfile.TemporaryDirectory() as directory:
80
+ with patch("router.codex_path", side_effect=RouterError("not installed")):
81
+ args = parser().parse_args(["_broker", "--binary", "/explicit/runtime"])
82
+ self.assertEqual(
83
+ selected_binary(args, Path(directory)), "/explicit/runtime"
84
+ )
85
+
86
+ def test_saved_worker_and_metadata_commands_survive_missing_global_codex(self):
87
+ with tempfile.TemporaryDirectory() as directory:
88
+ state = Path(directory)
89
+ atomic_json(
90
+ service.folder(state) / "saved.job.json",
91
+ {"binary": "/saved/native/runtime"},
92
+ )
93
+ with patch("router.codex_path", side_effect=RouterError("not installed")):
94
+ self.assertEqual(
95
+ selected_binary(parser().parse_args(["_serve", "saved"]), state),
96
+ "/saved/native/runtime",
97
+ )
98
+ self.assertIsNone(
99
+ selected_binary(parser().parse_args(["status"]), state)
100
+ )
101
+
102
+ def test_verified_snapshot_is_used_when_global_codex_is_missing(self):
103
+ import hashlib
104
+
105
+ with tempfile.TemporaryDirectory() as directory:
106
+ state = Path(directory)
107
+ native = state / "native"
108
+ native.write_bytes(b"verified test snapshot")
109
+ atomic_json(
110
+ state / "runtimes.json",
111
+ {
112
+ "runtimes": [
113
+ {
114
+ "binary": str(native),
115
+ "sha256": hashlib.sha256(native.read_bytes()).hexdigest(),
116
+ }
117
+ ]
118
+ },
119
+ )
120
+ with patch("router.codex_path", side_effect=RouterError("not installed")):
121
+ self.assertEqual(
122
+ selected_binary(parser().parse_args(["check"]), state), str(native)
123
+ )
124
+
125
+ async def test_compatible_older_coordinator_is_reused_without_interrupting_it(self):
126
+ with tempfile.TemporaryDirectory() as directory:
127
+ with (
128
+ patch(
129
+ "broker.exchange",
130
+ new=AsyncMock(return_value={"revision": "older-build"}),
131
+ ) as exchange,
132
+ patch("broker.spawn") as spawn,
133
+ ):
134
+ await broker.ensure(Path(directory), "fake-native")
135
+ self.assertEqual(exchange.await_count, 1)
136
+ spawn.assert_not_called()
137
+
138
+ def test_failed_sessions_are_visible_without_exposing_prompts(self):
139
+ with tempfile.TemporaryDirectory() as directory:
140
+ state = Path(directory)
141
+ atomic_json(
142
+ service.folder(state) / "failed.job.json",
143
+ {
144
+ "id": "failed",
145
+ "name": "Project",
146
+ "state": "needs_attention",
147
+ "prompt": "PRIVATE_PROMPT",
148
+ },
149
+ )
150
+ result = commands.snapshot(state)
151
+ self.assertEqual(result["sessions"][0]["state"], "needs_attention")
152
+ self.assertNotIn("PRIVATE_PROMPT", json.dumps(result))
153
+
154
+ def test_restart_refuses_a_live_session(self):
155
+ with tempfile.TemporaryDirectory() as directory:
156
+ state = Path(directory)
157
+ pool = Pool(state)
158
+ pool.session = "live"
159
+ pool.assign("one")
160
+ atomic_json(
161
+ service.folder(state) / "live.job.json",
162
+ {"id": "live", "cwd": directory, "state": "running"},
163
+ )
164
+ try:
165
+ with self.assertRaises(RouterError):
166
+ service.restart(state, "live")
167
+ finally:
168
+ pool.close()
169
+
170
+ def test_internal_helpers_are_not_in_public_help(self):
171
+ help_text = parser().format_help()
172
+ self.assertNotIn("_supervise", help_text)
173
+ self.assertNotIn("_broker", help_text)