claude-multiacc 2.0.57 → 2.0.59
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/README.md +111 -3
- package/bin/claude +323 -84
- package/bin/claude-accounts +273 -42
- package/bin/codex +15 -0
- package/bin/codex-accounts +31 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +30 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +82 -10
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +131 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +111 -1
- package/tests/run-tests.sh +1028 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_origin.py +70 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +67 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_packaged_commands.py +41 -0
package/tests/test_autoresume.py
CHANGED
|
@@ -83,6 +83,17 @@ def cc_quota(ts, rtype="five_hour", resets=None, **extra):
|
|
|
83
83
|
return rec
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
def cc_concurrents(ts):
|
|
87
|
+
"""claude 2.1.304's record of the account turning away a request among too many in
|
|
88
|
+
flight at once (2026-10-20): the generic usage-limit words, and no reset."""
|
|
89
|
+
info = {"status": "rejected", "rateLimitType": "concurrents", "isUsingOverage": False}
|
|
90
|
+
return cc_api_error(ts, "rate_limit", "You've hit your usage limit", apiErrorStatus=429,
|
|
91
|
+
apiError="usage_limit_reached",
|
|
92
|
+
apiErrorParams={"rate_limit_info": info},
|
|
93
|
+
quotaLimits=dict(info, unifiedRateLimitFallbackAvailable=False,
|
|
94
|
+
upgradePaths=["overage"]))
|
|
95
|
+
|
|
96
|
+
|
|
86
97
|
def cc_system(ts):
|
|
87
98
|
return cc_base(ts, "system", subtype="informational", level="notice", isMeta=False,
|
|
88
99
|
content="Usage limit reached · continuing automatically at 3:36am "
|
|
@@ -104,6 +115,17 @@ def cc_reply(ts, text="Done.", **extra):
|
|
|
104
115
|
return rec
|
|
105
116
|
|
|
106
117
|
|
|
118
|
+
def cc_slash(ts, name="model"):
|
|
119
|
+
"""What a slash command run at the prompt leaves in the main transcript (claude 2.1.303,
|
|
120
|
+
/model in sessions 5 and 8 on 2026-10-19): a caveat, the command, its output."""
|
|
121
|
+
return [cc_user(ts, "<local-command-caveat>Caveat: The messages below were generated by "
|
|
122
|
+
"the user while running local commands.</local-command-caveat>",
|
|
123
|
+
isMeta=True),
|
|
124
|
+
cc_user(ts, "<command-name>/%s</command-name>\n<command-message>%s</command-message>"
|
|
125
|
+
"\n<command-args></command-args>" % (name, name)),
|
|
126
|
+
cc_user(ts, "<local-command-stdout>Kept model as Opus 5.5</local-command-stdout>")]
|
|
127
|
+
|
|
128
|
+
|
|
107
129
|
# ---- codex rollout records (codex 0.156.0 shapes) ------------------------------------
|
|
108
130
|
|
|
109
131
|
def cx_meta(ts, sid=CX_SID, session_id=None, cwd="/w", originator="codex-tui"):
|
|
@@ -217,6 +239,16 @@ class ClaudeClassifierTests(unittest.TestCase):
|
|
|
217
239
|
self.assertEqual(self.verdict(cc_quota(self.T, rtype, resets=self.T + 60))["class"],
|
|
218
240
|
"model", rtype)
|
|
219
241
|
|
|
242
|
+
def test_a_concurrency_throttle_is_transient(self):
|
|
243
|
+
# 2026-10-20: read as one model's limit, it kept two sessions off a serving account
|
|
244
|
+
# for five hours; the account served 700+ requests a minute around those 54
|
|
245
|
+
v = self.verdict(cc_concurrents(self.T))
|
|
246
|
+
self.assertEqual((v["class"], v["code"], v["reset"], v["rtype"]),
|
|
247
|
+
("transient", "rate_limit", None, ""))
|
|
248
|
+
# an agent's is its own: it never moves the session
|
|
249
|
+
self.assertIsNone(ar.classify_claude_sidechain(dict(cc_concurrents(self.T),
|
|
250
|
+
isSidechain=True)))
|
|
251
|
+
|
|
220
252
|
def test_model_limit_without_quota_limits(self):
|
|
221
253
|
for text in ("You've reached your Fable limit. Run /usage-credits to keep going.",
|
|
222
254
|
"You've reached your Fable limit. Switch to another model to continue."):
|
|
@@ -263,9 +295,26 @@ class ClaudeClassifierTests(unittest.TestCase):
|
|
|
263
295
|
def test_what_counts_as_the_user_typing(self):
|
|
264
296
|
cancel = ("cancel", "user")
|
|
265
297
|
self.assertEqual(ar.classify_claude(cc_user(self.T, "please continue")), cancel)
|
|
298
|
+
# a slash command that only shows or configures the session is the user too, but one
|
|
299
|
+
# the tracker tells apart (it ends only a model-scoped or transient verdict), in
|
|
300
|
+
# either order the client writes its tags
|
|
301
|
+
for text in ("<command-name>/model</command-name>\n<command-args></command-args>",
|
|
302
|
+
"<command-message>model</command-message>\n<command-name>/model"
|
|
303
|
+
"</command-name>",
|
|
304
|
+
[{"type": "text", "text": " <command-name>/usage</command-name>"}],
|
|
305
|
+
"<command-name>/Effort</command-name>\n<command-args>max</command-args>"):
|
|
306
|
+
self.assertEqual(ar.classify_claude(cc_user(self.T, text)), ("cancel", "command"))
|
|
307
|
+
for name in ("model", "effort", "mcp", "status", "context", "cost"):
|
|
308
|
+
self.assertEqual(ar.classify_claude(cc_slash(self.T, name)[1]), ("cancel", "command"))
|
|
309
|
+
# any other command may be what fixed the account: the user typing, as before
|
|
310
|
+
for name in ("login", "limit-reset", "usage-credits", "extra-usage", "upgrade",
|
|
311
|
+
"rate-limit-options", "config", "fast", "compact", "brand-new-command"):
|
|
312
|
+
self.assertEqual(ar.classify_claude(cc_slash(self.T, name)[1]), cancel, name)
|
|
313
|
+
for text in ("<command-message>model</command-message>", "<command-name></command-name>",
|
|
314
|
+
"<command-name>/model</command-name"):
|
|
315
|
+
self.assertEqual(ar.classify_claude(cc_user(self.T, text)), cancel, text)
|
|
266
316
|
self.assertEqual(ar.classify_claude(cc_user(
|
|
267
|
-
self.T, "<command-name
|
|
268
|
-
cancel)
|
|
317
|
+
self.T, "what does <command-name> mean here?")), cancel)
|
|
269
318
|
self.assertEqual(ar.classify_claude(cc_user(
|
|
270
319
|
self.T, [{"type": "text", "text": "look at this"},
|
|
271
320
|
{"type": "image", "source": {"type": "base64", "data": "AA=="}}])), cancel)
|
|
@@ -281,7 +330,7 @@ class ClaudeClassifierTests(unittest.TestCase):
|
|
|
281
330
|
cc_user(self.T, "This session is being continued from a previous conversation",
|
|
282
331
|
isCompactSummary=True),
|
|
283
332
|
cc_user(self.T, []),
|
|
284
|
-
]
|
|
333
|
+
] + [cc_slash(self.T)[0], cc_slash(self.T)[2]]
|
|
285
334
|
for rec in not_human:
|
|
286
335
|
self.assertIsNone(ar.classify_claude(rec), rec["message"])
|
|
287
336
|
|
|
@@ -334,6 +383,56 @@ class ClaudeTrackerTests(unittest.TestCase):
|
|
|
334
383
|
self.assertIsNone(tr.feed(cc_user(self.LAUNCHED - 60, "old"), 3))
|
|
335
384
|
self.assertIsNotNone(tr.pending)
|
|
336
385
|
|
|
386
|
+
def test_a_slash_command_ends_only_a_model_scoped_or_transient_verdict(self):
|
|
387
|
+
# the ACCOUNT's verdicts outlive /model (sessions 5 and 8, 2026-10-19): it is just as
|
|
388
|
+
# limited afterwards
|
|
389
|
+
for err in (cc_quota(self.LAUNCHED + 1, resets=self.LAUNCHED + 99),
|
|
390
|
+
cc_quota(self.LAUNCHED + 1, "seven_day"),
|
|
391
|
+
cc_api_error(self.LAUNCHED + 1, "authentication_failed"),
|
|
392
|
+
cc_api_error(self.LAUNCHED + 1, "oauth_org_not_allowed"),
|
|
393
|
+
cc_api_error(self.LAUNCHED + 1, "billing_error")):
|
|
394
|
+
with self.subTest(verdict=err["error"]):
|
|
395
|
+
tr = self.tracker()
|
|
396
|
+
tr.feed(err, 1)
|
|
397
|
+
pending = tr.pending
|
|
398
|
+
self.assertIn(pending["class"], ar.ACCOUNT_CLASSES)
|
|
399
|
+
for rec in cc_slash(self.LAUNCHED + 2) + cc_slash(self.LAUNCHED + 3, "usage"):
|
|
400
|
+
self.assertIsNone(tr.feed(rec, 2))
|
|
401
|
+
self.assertEqual((tr.pending, tr.pending_since, tr.cleared), (pending, 1, 0.0))
|
|
402
|
+
# a prompt the user types still ends it, as before
|
|
403
|
+
self.assertEqual(tr.feed(cc_user(self.LAUNCHED + 4, "wait, I'll switch"), 4),
|
|
404
|
+
("cancel", "user"))
|
|
405
|
+
self.assertIsNone(tr.pending)
|
|
406
|
+
# /model is how one handles a MODEL's limit; a failed turn ends with any input
|
|
407
|
+
for err in (cc_quota(self.LAUNCHED + 1, "seven_day_opus"),
|
|
408
|
+
cc_api_error(self.LAUNCHED + 1, "rate_limit", "You've reached your Fable "
|
|
409
|
+
"limit. Switch to another model to continue."),
|
|
410
|
+
cc_api_error(self.LAUNCHED + 1, "overloaded")):
|
|
411
|
+
with self.subTest(verdict=err["error"]):
|
|
412
|
+
tr = self.tracker()
|
|
413
|
+
tr.feed(err, 1)
|
|
414
|
+
self.assertIn(tr.pending["class"], ("model", "transient"))
|
|
415
|
+
self.assertEqual(tr.feed(cc_slash(self.LAUNCHED + 2)[1], 2), ("cancel", "command"))
|
|
416
|
+
self.assertIsNone(tr.pending)
|
|
417
|
+
|
|
418
|
+
def test_a_command_that_can_fix_the_account_ends_its_verdict(self):
|
|
419
|
+
# "Not logged in · Please run /login": /login, or /limit-reset after the limit, is
|
|
420
|
+
# the user fixing the ACCOUNT — like a typed prompt, and like any command not known
|
|
421
|
+
# to leave it as it was
|
|
422
|
+
for err, name in ((cc_api_error(self.LAUNCHED + 1, "authentication_failed",
|
|
423
|
+
"Not logged in · Please run /login"), "login"),
|
|
424
|
+
(cc_quota(self.LAUNCHED + 1, resets=self.LAUNCHED + 99), "limit-reset"),
|
|
425
|
+
(cc_quota(self.LAUNCHED + 1, "seven_day"), "extra-usage"),
|
|
426
|
+
(cc_api_error(self.LAUNCHED + 1, "oauth_org_not_allowed"), "login"),
|
|
427
|
+
(cc_quota(self.LAUNCHED + 1, resets=self.LAUNCHED + 99), "plugin:fix")):
|
|
428
|
+
with self.subTest(verdict=err["error"], command=name):
|
|
429
|
+
tr = self.tracker()
|
|
430
|
+
tr.feed(err, 1)
|
|
431
|
+
self.assertIsNone(tr.feed(cc_slash(self.LAUNCHED + 2, name)[0], 2)) # the caveat
|
|
432
|
+
self.assertEqual(tr.feed(cc_slash(self.LAUNCHED + 2, name)[1], 2),
|
|
433
|
+
("cancel", "user"))
|
|
434
|
+
self.assertIsNone(tr.pending)
|
|
435
|
+
|
|
337
436
|
def test_pending_since_restarts_only_on_a_class_change(self):
|
|
338
437
|
tr = self.tracker()
|
|
339
438
|
tr.feed(cc_quota(self.LAUNCHED + 1), 10)
|
|
@@ -506,8 +605,8 @@ class ArgvTests(unittest.TestCase):
|
|
|
506
605
|
self.assertEqual(self.rl("claude", []), ["--resume", SID, p])
|
|
507
606
|
|
|
508
607
|
def test_claude_model_is_only_the_launchs_own(self):
|
|
509
|
-
# Claude restores the session's model on --resume ([1m] included)
|
|
510
|
-
#
|
|
608
|
+
# Claude restores the session's model on --resume ([1m] included), so no --model is
|
|
609
|
+
# added, and an explicit one from the launch is kept as is.
|
|
511
610
|
p = ar.resume_prompt("model")
|
|
512
611
|
self.assertEqual(self.rl("claude", ["hello there"], "model"), ["--resume", SID, p])
|
|
513
612
|
self.assertEqual(self.rl("claude", ["--model", "sonnet"], "model"),
|
|
@@ -1248,6 +1347,40 @@ class ClaudeWatcherTests(PoolCase):
|
|
|
1248
1347
|
self.assertTrue(kv["hist"].startswith("quota:%d,auth:" % (self.t0 - 7200)))
|
|
1249
1348
|
self.assertIn("chain=ChainId1", self.log_lines()[0])
|
|
1250
1349
|
|
|
1350
|
+
def test_the_relaunch_names_the_credential_its_run_carried(self):
|
|
1351
|
+
# The shim launched this run on the portable token (via=token in its state). /clear
|
|
1352
|
+
# then has the same process run a new session id, which no capture of the shim ever
|
|
1353
|
+
# tagged: its auth failure is still the token's, and the relaunch file says so.
|
|
1354
|
+
state = self.write_state(["-c"])
|
|
1355
|
+
with open(state, "a") as fh:
|
|
1356
|
+
fh.write("via=token\n")
|
|
1357
|
+
self.registry()
|
|
1358
|
+
self.transcript([cc_user(self.launched + 1, "hello")])
|
|
1359
|
+
w, r = self.watcher(state)
|
|
1360
|
+
r.at(self.t0 + 1, lambda: self.registry(sid=SID2))
|
|
1361
|
+
r.at(self.t0 + 3, lambda: self.transcript([cc_api_error(
|
|
1362
|
+
self.t0 + 3, "authentication_failed", "Not logged in · Please run /login")], sid=SID2))
|
|
1363
|
+
w.run()
|
|
1364
|
+
kv, _ = self.relaunch_of(r)
|
|
1365
|
+
self.assertEqual((kv["class"], kv["sid"], kv["via"]), ("auth", SID2, "token"))
|
|
1366
|
+
|
|
1367
|
+
def test_the_state_names_a_credential_or_nothing(self):
|
|
1368
|
+
for raw, want in (("token", "token"), ("login", "login"), ("", ""), ("TOKEN", ""),
|
|
1369
|
+
("token x", "")):
|
|
1370
|
+
state = self.write_state(["-c"])
|
|
1371
|
+
with open(state, "a") as fh:
|
|
1372
|
+
fh.write("via=%s\n" % raw)
|
|
1373
|
+
self.assertEqual(ar.load_state(state)["via"], want, raw)
|
|
1374
|
+
# a shim from before the field: nothing is named, and the relaunch names nothing
|
|
1375
|
+
state = self.write_state(["-c"])
|
|
1376
|
+
self.assertEqual(ar.load_state(state)["via"], "")
|
|
1377
|
+
self.registry()
|
|
1378
|
+
self.transcript([cc_api_error(self.t0, "authentication_failed", "Not logged in")])
|
|
1379
|
+
w, r = self.watcher(state)
|
|
1380
|
+
w.run()
|
|
1381
|
+
kv, _ = self.relaunch_of(r)
|
|
1382
|
+
self.assertNotIn("via", kv)
|
|
1383
|
+
|
|
1251
1384
|
def test_old_rejection_in_a_resumed_transcript_does_not_fire(self):
|
|
1252
1385
|
state = self.write_state(["--resume", SID])
|
|
1253
1386
|
self.registry()
|
|
@@ -1314,6 +1447,68 @@ class ClaudeWatcherTests(PoolCase):
|
|
|
1314
1447
|
self.assertEqual(len(r.probes), 1)
|
|
1315
1448
|
self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
|
|
1316
1449
|
|
|
1450
|
+
def test_a_slash_command_moves_on_only_from_a_model_scoped_limit(self):
|
|
1451
|
+
reset = int(self.t0) + 999
|
|
1452
|
+
for err, moved in ((cc_quota(self.t0, resets=reset), True),
|
|
1453
|
+
(cc_api_error(self.t0, "rate_limit", "You've reached your Fable "
|
|
1454
|
+
"limit. Switch to another model"), False)):
|
|
1455
|
+
with self.subTest(moved=moved):
|
|
1456
|
+
log = self.root / "selection.log"
|
|
1457
|
+
if log.exists():
|
|
1458
|
+
log.unlink()
|
|
1459
|
+
state = self.write_state([])
|
|
1460
|
+
reg = self.registry()
|
|
1461
|
+
tpath = self.transcript([err])
|
|
1462
|
+
w, r = self.watcher(state)
|
|
1463
|
+
# the user runs /model before GRACE runs out, and later leaves with /exit
|
|
1464
|
+
r.at(self.t0 + 1.5, lambda tpath=tpath: append(tpath, cc_slash(self.t0 + 1)))
|
|
1465
|
+
r.at(self.t0 + 30, lambda reg=reg, r=r: (reg.unlink() if reg.exists() else None,
|
|
1466
|
+
r.exit(self.PID)))
|
|
1467
|
+
self.assertEqual(w.run(), 0)
|
|
1468
|
+
self.assertEqual(len(r.probes), int(moved))
|
|
1469
|
+
self.assertEqual(self.events(), ["watch", "detect"] + ["switch"] * moved)
|
|
1470
|
+
if moved:
|
|
1471
|
+
self.assertEqual(self.relaunch_of(r)[0]["class"], "quota")
|
|
1472
|
+
tpath.unlink()
|
|
1473
|
+
|
|
1474
|
+
def test_a_command_that_fixes_the_account_keeps_the_session(self):
|
|
1475
|
+
# /login 2 s after "Not logged in · Please run /login" (the TUI must not be stopped
|
|
1476
|
+
# mid-login, nor the account being logged back in parked); /limit-reset while the
|
|
1477
|
+
# watcher holds for want of room, before room appears; a watcher restarted onto a
|
|
1478
|
+
# limit the user reset half an hour ago
|
|
1479
|
+
reset = int(self.t0) + 9000
|
|
1480
|
+
cases = ((cc_api_error(self.t0, "authentication_failed",
|
|
1481
|
+
"Not logged in · Please run /login"), "login", 2, False),
|
|
1482
|
+
(cc_quota(self.t0, resets=reset), "limit-reset", 8, False),
|
|
1483
|
+
(cc_quota(self.t0 - 1800, resets=reset), "limit-reset", -1790, True))
|
|
1484
|
+
for err, name, after, restarted in cases:
|
|
1485
|
+
with self.subTest(command=name, restarted=restarted):
|
|
1486
|
+
log = self.root / "selection.log"
|
|
1487
|
+
if log.exists():
|
|
1488
|
+
log.unlink()
|
|
1489
|
+
self.launched = int(self.t0) - (3600 if restarted else 5)
|
|
1490
|
+
state = self.write_state([])
|
|
1491
|
+
reg = self.registry()
|
|
1492
|
+
tpath = self.transcript([cc_user(self.launched + 1, "go"),
|
|
1493
|
+
cc_reply(self.launched + 2, "ok"), err]
|
|
1494
|
+
+ (cc_slash(self.t0 + after, name) if after < 0 else []))
|
|
1495
|
+
w, r = self.watcher(state, {"CLAUDE_MULTIACC_AR_HOLD_REPROBE": "10"})
|
|
1496
|
+
held = name == "limit-reset" and not restarted
|
|
1497
|
+
if held:
|
|
1498
|
+
r.probe_out = "pick=acct-01 tier=soft\n" # nowhere to go: hold
|
|
1499
|
+
r.at(self.t0 + 9, lambda r=r: setattr(r, "probe_out",
|
|
1500
|
+
"pick=acct-02 tier=eligible\n"))
|
|
1501
|
+
if after > 0:
|
|
1502
|
+
r.at(self.t0 + after, lambda tpath=tpath, at=self.t0 + after, name=name:
|
|
1503
|
+
append(tpath, cc_slash(at, name)))
|
|
1504
|
+
r.at(self.t0 + 40, lambda reg=reg, r=r: (reg.unlink() if reg.exists() else None,
|
|
1505
|
+
r.exit(self.PID)))
|
|
1506
|
+
self.assertEqual(w.run(), 0)
|
|
1507
|
+
self.assertEqual((len(r.probes), r.signals, r.sends, self.relaunch_files()),
|
|
1508
|
+
(int(held), [], [], []))
|
|
1509
|
+
self.assertEqual(self.events(), ["watch", "detect"] + ["hold"] * held)
|
|
1510
|
+
tpath.unlink()
|
|
1511
|
+
|
|
1317
1512
|
def test_kill_switch_file_reaches_a_running_watcher(self):
|
|
1318
1513
|
state = self.write_state([])
|
|
1319
1514
|
self.registry()
|
|
@@ -1354,12 +1549,14 @@ class ClaudeWatcherTests(PoolCase):
|
|
|
1354
1549
|
self.assertEqual(w.run(), 0)
|
|
1355
1550
|
self.assertEqual((r.probes, r.signals), ([], []))
|
|
1356
1551
|
kv, argv = self.relaunch_of(r)
|
|
1552
|
+
# the transcript's last reply names its model and no effort: Claude restores the model
|
|
1357
1553
|
self.assertEqual(argv, ["--dangerously-skip-permissions", "--resume", SID,
|
|
1358
1554
|
ar.resume_prompt("crash")])
|
|
1359
1555
|
self.assertEqual((kv["class"], kv["acct"], kv["avoid"], kv["depth"]),
|
|
1360
1556
|
("crash", "acct-01", "acct-05:%d" % (self.t0 + 900), "1"))
|
|
1361
1557
|
self.assert_sequence(r)
|
|
1362
1558
|
self.assertEqual(self.events(), ["watch", "crash", "switch"])
|
|
1559
|
+
self.assertTrue(self.log_lines()[-1].endswith(" probe=-"))
|
|
1363
1560
|
|
|
1364
1561
|
def test_a_clean_exit_or_an_early_death_is_final(self):
|
|
1365
1562
|
state = self.write_state([])
|
|
@@ -2273,9 +2470,12 @@ class CliTests(PoolCase):
|
|
|
2273
2470
|
timeout=timeout, universal_newlines=True)
|
|
2274
2471
|
|
|
2275
2472
|
def test_classify(self):
|
|
2276
|
-
|
|
2473
|
+
# Resets in the future (the classifier drops a quota verdict whose reset has passed,
|
|
2474
|
+
# as the watcher does: the last case below).
|
|
2475
|
+
week = future_reset(self.t0)
|
|
2476
|
+
path = self.transcript([cc_quota(self.t0 - 10000, resets=week - 86400),
|
|
2277
2477
|
cc_api_error(self.t0, "invalid_request"),
|
|
2278
|
-
cc_quota(self.t0, "seven_day", resets=
|
|
2478
|
+
cc_quota(self.t0, "seven_day", resets=week),
|
|
2279
2479
|
cc_user(self.t0 + 1, "go"),
|
|
2280
2480
|
cc_api_error(self.t0 + 2, "authentication_failed")])
|
|
2281
2481
|
out = self.cli("classify", "--provider", "claude", str(path),
|
|
@@ -2285,10 +2485,17 @@ class CliTests(PoolCase):
|
|
|
2285
2485
|
self.assertEqual([e["kind"] for e in doc["events"]],
|
|
2286
2486
|
["never", "verdict", "cancel", "verdict"])
|
|
2287
2487
|
self.assertEqual((doc["events"][1]["class"], doc["events"][1]["rtype"],
|
|
2288
|
-
doc["events"][1]["reset"]), ("quota", "seven_day",
|
|
2488
|
+
doc["events"][1]["reset"]), ("quota", "seven_day", week))
|
|
2289
2489
|
self.assertEqual(doc["pending"]["class"], "auth")
|
|
2290
2490
|
doc = json.loads(self.cli("classify", "--provider", "claude", str(path)).stdout)
|
|
2291
2491
|
self.assertEqual(doc["events"][0]["kind"], "verdict") # no --since: everything counts
|
|
2492
|
+
# a limit whose reset has passed is no verdict; one with no reset still is
|
|
2493
|
+
lifted = self.tmp / "lifted.jsonl"
|
|
2494
|
+
lifted.write_text(jl([cc_quota(self.t0 - 600, resets=int(self.t0) - 60),
|
|
2495
|
+
cc_quota(self.t0 - 500)]))
|
|
2496
|
+
doc = json.loads(self.cli("classify", "--provider", "claude", str(lifted)).stdout)
|
|
2497
|
+
self.assertEqual([(e["kind"], e["class"], e["reset"]) for e in doc["events"]],
|
|
2498
|
+
[("verdict", "quota", None)])
|
|
2292
2499
|
|
|
2293
2500
|
def test_classify_codex(self):
|
|
2294
2501
|
reset = future_reset(self.t0)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""lib/model_catalog.py and lib/model_match.py: the catalog and the level matching."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(ROOT / "lib"))
|
|
14
|
+
import model_catalog as mc # noqa: E402
|
|
15
|
+
import model_match as mm # noqa: E402
|
|
16
|
+
|
|
17
|
+
LEVELS = ["low", "medium", "high", "xhigh", "max"]
|
|
18
|
+
CODEX_LEVELS = LEVELS + ["ultra"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def entry(family, version, efforts, default, listed=True, upgrade=None):
|
|
22
|
+
return {"family": family, "version": version, "efforts": efforts, "default_effort": default,
|
|
23
|
+
"listed": listed, "upgrade": upgrade}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
CATALOG = {"schema": 1,
|
|
27
|
+
"claude": {"efforts": LEVELS, "families": {"haiku": 2, "sonnet": 3, "opus": 4, "fable": 5, "mythos": 5},
|
|
28
|
+
"models": {"claude-fable-5-1": entry("fable", [5, 1], LEVELS, "high"),
|
|
29
|
+
"claude-opus-5-5": entry("opus", [5, 5], LEVELS, "medium"),
|
|
30
|
+
"claude-sonnet-5-5": entry("sonnet", [5, 5], LEVELS, "medium"),
|
|
31
|
+
"claude-haiku-5-5": entry("haiku", [5, 5], LEVELS, "medium"),
|
|
32
|
+
"claude-opus-4-6": entry("opus", [4, 6], LEVELS[:3] + ["max"], "high", False),
|
|
33
|
+
"claude-sonnet-4-6": entry("sonnet", [4, 6], LEVELS[:3] + ["max"], "high", False)}},
|
|
34
|
+
"codex": {"efforts": ["none", "minimal"] + CODEX_LEVELS,
|
|
35
|
+
"families": {"luna": 1, "terra": 2, "sol": 3, "astra": 4},
|
|
36
|
+
"models": {"gpt-6.1-sol": entry("sol", [6, 1], CODEX_LEVELS, "low"),
|
|
37
|
+
"gpt-6-astra": entry("astra", [6], CODEX_LEVELS, "medium"),
|
|
38
|
+
"gpt-6-sol": entry("sol", [6], CODEX_LEVELS, "medium"),
|
|
39
|
+
"gpt-6-luna": entry("luna", [6], LEVELS, "medium"),
|
|
40
|
+
"gpt-5.6-terra": entry("terra", [5, 6], CODEX_LEVELS, "medium"),
|
|
41
|
+
"gpt-5.5": entry(None, [5, 5], LEVELS[:4], "medium", False, "gpt-6.1-sol")}}}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MatchTests(unittest.TestCase):
|
|
45
|
+
def match(self, source, model, effort, target):
|
|
46
|
+
got = mm.resume_settings(source, {"model": model, "effort": effort}, target, CATALOG)
|
|
47
|
+
return got["model"], got["effort"]
|
|
48
|
+
|
|
49
|
+
def test_current_lineup_is_the_newest_listed_model_per_family(self):
|
|
50
|
+
# terra's newest is a 5.x: still the terra level (launches are staggered)
|
|
51
|
+
self.assertEqual(mm.current_models(CATALOG, "codex"),
|
|
52
|
+
{"sol": "gpt-6.1-sol", "astra": "gpt-6-astra", "luna": "gpt-6-luna", "terra": "gpt-5.6-terra"})
|
|
53
|
+
self.assertEqual(mm.current_models(CATALOG, "claude"),
|
|
54
|
+
{"fable": "claude-fable-5-1", "opus": "claude-opus-5-5", "sonnet": "claude-sonnet-5-5",
|
|
55
|
+
"haiku": "claude-haiku-5-5"})
|
|
56
|
+
|
|
57
|
+
def test_models_match_by_level(self):
|
|
58
|
+
# the operator's map (2026-10-14): astra -> opus, sol -> sonnet, terra and luna -> haiku ...
|
|
59
|
+
self.assertEqual(self.match("codex", "gpt-6-astra", "ultra", "claude"), ("claude-opus-5-5", "max"))
|
|
60
|
+
self.assertEqual(self.match("codex", "gpt-6.1-sol", "high", "claude"), ("claude-sonnet-5-5", "high"))
|
|
61
|
+
self.assertEqual(self.match("codex", "gpt-6-sol", "xhigh", "claude"), ("claude-sonnet-5-5", "xhigh"))
|
|
62
|
+
self.assertEqual(self.match("codex", "gpt-5.6-terra", "none", "claude"), ("claude-haiku-5-5", "low"))
|
|
63
|
+
self.assertEqual(self.match("codex", "gpt-6-luna", "minimal", "claude"), ("claude-haiku-5-5", "low"))
|
|
64
|
+
# ... and fable, mythos and opus -> astra, sonnet -> sol, haiku -> terra
|
|
65
|
+
self.assertEqual(self.match("claude", "claude-fable-5-1", "max", "codex"), ("gpt-6-astra", "max"))
|
|
66
|
+
self.assertEqual(self.match("claude", "claude-opus-5-5[1m]", "xhigh", "codex"), ("gpt-6-astra", "xhigh"))
|
|
67
|
+
self.assertEqual(self.match("claude", "claude-opus-4-6", "medium", "codex"), ("gpt-6-astra", "medium"))
|
|
68
|
+
self.assertEqual(self.match("claude", "claude-sonnet-5-5", "high", "codex"), ("gpt-6.1-sol", "high"))
|
|
69
|
+
self.assertEqual(self.match("claude", "claude-haiku-5-5", "low", "codex"), ("gpt-5.6-terra", "low"))
|
|
70
|
+
# a retiring model counts as its upgrade; an unknown one is left to the destination
|
|
71
|
+
self.assertEqual(self.match("codex", "gpt-5.5", "xhigh", "claude"), ("claude-sonnet-5-5", "xhigh"))
|
|
72
|
+
self.assertEqual(self.match("codex", "codex-auto-review", "high", "claude"), (None, "high"))
|
|
73
|
+
self.assertEqual(self.match("claude", "claude-mythos-9", "max", "codex"), ("gpt-6-astra", "max"))
|
|
74
|
+
self.assertEqual(self.match("codex", None, None, "claude"), (None, None))
|
|
75
|
+
|
|
76
|
+
def test_effort_is_clamped_to_what_the_destination_model_supports(self):
|
|
77
|
+
# gpt-6-luna has no ultra: max, the nearest level below
|
|
78
|
+
self.assertEqual(mm.match_effort(CATALOG, "claude", "max", "codex", "gpt-6-luna"), "max")
|
|
79
|
+
self.assertEqual(mm.match_effort(CATALOG, "codex", "ultra", "codex", "gpt-6-luna"), "max")
|
|
80
|
+
self.assertEqual(mm.match_effort(CATALOG, "codex", "xhigh", "claude", "claude-opus-4-6"), "high")
|
|
81
|
+
self.assertEqual(mm.match_effort(CATALOG, "codex", "ultra", "claude", "claude-sonnet-4-6"), "max")
|
|
82
|
+
self.assertEqual(mm.match_effort(CATALOG, "claude", "xhigh", "codex", "gpt-5.5"), "xhigh")
|
|
83
|
+
self.assertEqual(mm.match_effort(CATALOG, "codex", "persistent", "claude", "claude-opus-5-5"), None)
|
|
84
|
+
self.assertEqual(mm.match_effort(CATALOG, "claude", "high", "claude", "claude-unknown-9"), "high")
|
|
85
|
+
|
|
86
|
+
def test_same_provider_passes_the_session_through(self):
|
|
87
|
+
self.assertEqual(self.match("claude", "claude-opus-5-5[1m]", "max", "claude"), ("claude-opus-5-5[1m]", "max"))
|
|
88
|
+
self.assertEqual(self.match("claude", "claude-opus-5-5", None, "claude"), ("claude-opus-5-5", None))
|
|
89
|
+
# codex needs both flags or none: an unset effort becomes the model's catalog default
|
|
90
|
+
self.assertEqual(self.match("codex", "gpt-6-astra", None, "codex"), ("gpt-6-astra", "medium"))
|
|
91
|
+
self.assertEqual(self.match("codex", "gpt-unknown", None, "codex"), ("gpt-unknown", None))
|
|
92
|
+
|
|
93
|
+
def test_flags(self):
|
|
94
|
+
self.assertEqual(mm.settings_flags("claude", "claude-opus-5-5[1m]", "max"),
|
|
95
|
+
["--model", "claude-opus-5-5[1m]", "--effort", "max"])
|
|
96
|
+
self.assertEqual(mm.settings_flags("claude", None, "max"), ["--effort", "max"])
|
|
97
|
+
self.assertEqual(mm.settings_flags("claude", "claude-opus-5-5", None), ["--model", "claude-opus-5-5"])
|
|
98
|
+
self.assertEqual(mm.settings_flags("codex", "gpt-6-astra", "ultra"),
|
|
99
|
+
["-m", "gpt-6-astra", "-c", "model_reasoning_effort=ultra"])
|
|
100
|
+
self.assertEqual(mm.settings_flags("codex", "gpt-6-astra", None), [])
|
|
101
|
+
self.assertEqual(mm.settings_flags("codex", None, "high"), [])
|
|
102
|
+
self.assertEqual(mm.settings_flags("gemini", "x", "y"), [])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CatalogTests(unittest.TestCase):
|
|
106
|
+
def setUp(self):
|
|
107
|
+
self.tmp = Path(tempfile.mkdtemp(prefix="cat-"))
|
|
108
|
+
self.addCleanup(shutil.rmtree, str(self.tmp), True)
|
|
109
|
+
self.roots = {"claude": str(self.tmp / "claude"), "codex": str(self.tmp / "codex")}
|
|
110
|
+
for root in self.roots.values():
|
|
111
|
+
os.makedirs(os.path.join(root, "acct-01"))
|
|
112
|
+
|
|
113
|
+
def codex_cache(self, fetched, models, acct="acct-01"):
|
|
114
|
+
doc = {"fetched_at": fetched, "etag": "x", "client_version": "0.162.1", "identity": "y",
|
|
115
|
+
"models": [{"slug": slug, "visibility": "list" if listed else "hide", "priority": 1,
|
|
116
|
+
"default_reasoning_level": default,
|
|
117
|
+
"supported_reasoning_levels": [{"effort": e, "description": ""} for e in efforts],
|
|
118
|
+
"upgrade": {"model": upgrade} if upgrade else None, "model_messages": {"x": "y" * 10}}
|
|
119
|
+
for slug, listed, default, efforts, upgrade in models]}
|
|
120
|
+
path = Path(self.roots["codex"]) / acct / "models_cache.json"
|
|
121
|
+
path.write_text(json.dumps(doc))
|
|
122
|
+
return path
|
|
123
|
+
|
|
124
|
+
def claude_cache(self, fetched_ms, models, name="tok-abc-cc.json"):
|
|
125
|
+
rows = [{"id": mid, "section": section, "thinking": {"type": "effort",
|
|
126
|
+
"effort_options": [{"id": e, "name": e} for e in efforts]}} for mid, section, efforts, _ in models]
|
|
127
|
+
state = {"model": models[0][0], "thinking_by_model": [{"id": mid, "thinking": {"type": "effort", "effort": d}}
|
|
128
|
+
for mid, _, _, d in models]}
|
|
129
|
+
doc = {"version": 2, "fetchedAt": fetched_ms, "staleAt": fetched_ms + 3600000,
|
|
130
|
+
"catalog": {"surface": "cc", "config": {"id": "cc", "models": rows}, "state": state}}
|
|
131
|
+
directory = Path(self.roots["claude"]) / "acct-01" / "cache" / "model-catalog"
|
|
132
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
(directory / name).write_text(json.dumps(doc))
|
|
134
|
+
|
|
135
|
+
def test_shipped_catalog_is_well_formed(self):
|
|
136
|
+
shipped = json.loads(Path(mc.SHIPPED).read_text())
|
|
137
|
+
for provider in ("claude", "codex"):
|
|
138
|
+
self.assertEqual(shipped[provider]["efforts"][-5:], LEVELS if provider == "claude" else CODEX_LEVELS[-5:])
|
|
139
|
+
self.assertTrue(shipped[provider]["families"])
|
|
140
|
+
self.assertTrue(shipped[provider]["models"])
|
|
141
|
+
for model, item in shipped[provider]["models"].items():
|
|
142
|
+
self.assertEqual(sorted(item), ["default_effort", "efforts", "family", "listed", "upgrade", "version"],
|
|
143
|
+
model)
|
|
144
|
+
current = mm.current_models(shipped, "claude")
|
|
145
|
+
self.assertEqual(sorted(current), ["fable", "haiku", "opus", "sonnet"])
|
|
146
|
+
self.assertEqual(sorted(mm.current_models(shipped, "codex")), ["astra", "luna", "sol", "terra"])
|
|
147
|
+
|
|
148
|
+
def test_live_caches_win_over_the_shipped_file_when_newer(self):
|
|
149
|
+
self.codex_cache("2099-01-01T00:00:00.000000Z",
|
|
150
|
+
[("gpt-9-astra", True, "high", CODEX_LEVELS, None),
|
|
151
|
+
("gpt-6-astra", False, "medium", CODEX_LEVELS, "gpt-9-astra")])
|
|
152
|
+
self.claude_cache(4102444800000, [("claude-fable-9", "main", LEVELS, "xhigh"),
|
|
153
|
+
("claude-haiku-5-5", "overflow", LEVELS, "medium")])
|
|
154
|
+
catalog = mc.load(self.roots)
|
|
155
|
+
self.assertEqual(catalog["codex"]["fetched_at"], "2099-01-01T00:00:00Z")
|
|
156
|
+
self.assertEqual(catalog["codex"]["models"]["gpt-9-astra"],
|
|
157
|
+
entry("astra", [9], CODEX_LEVELS, "high"))
|
|
158
|
+
self.assertEqual(catalog["codex"]["models"]["gpt-6-astra"]["upgrade"], "gpt-9-astra")
|
|
159
|
+
self.assertEqual(catalog["claude"]["models"]["claude-fable-9"], entry("fable", [9], LEVELS, "xhigh"))
|
|
160
|
+
self.assertFalse(catalog["claude"]["models"]["claude-haiku-5-5"]["listed"])
|
|
161
|
+
# structure always comes from the shipped file
|
|
162
|
+
self.assertEqual(catalog["claude"]["efforts"], LEVELS)
|
|
163
|
+
self.assertEqual(catalog["codex"]["families"]["astra"], 4)
|
|
164
|
+
self.assertEqual(mm.current_models(catalog, "codex"), {"astra": "gpt-9-astra"})
|
|
165
|
+
got = mm.resume_settings("claude", {"model": "claude-fable-9", "effort": "max"}, "codex", catalog)
|
|
166
|
+
self.assertEqual(got, {"model": "gpt-9-astra", "effort": "max"})
|
|
167
|
+
|
|
168
|
+
def test_older_caches_and_junk_lose_to_the_shipped_file(self):
|
|
169
|
+
self.codex_cache("2000-01-01T00:00:00Z", [("gpt-1-astra", True, "low", LEVELS, None)])
|
|
170
|
+
(Path(self.roots["codex"]) / "acct-01" / "models_cache.json").write_text("{not json")
|
|
171
|
+
self.claude_cache(946684800000, [("claude-opus-1", "main", LEVELS, "high")])
|
|
172
|
+
catalog = mc.load(self.roots)
|
|
173
|
+
shipped = json.loads(Path(mc.SHIPPED).read_text())
|
|
174
|
+
self.assertEqual(catalog["codex"]["models"], shipped["codex"]["models"])
|
|
175
|
+
self.assertEqual(catalog["claude"]["models"], shipped["claude"]["models"])
|
|
176
|
+
self.assertEqual(mc.load(None)["claude"]["models"], shipped["claude"]["models"])
|
|
177
|
+
|
|
178
|
+
def test_refresh_writes_each_pool_root_atomically_and_honours_if_stale(self):
|
|
179
|
+
self.codex_cache("2099-01-01T00:00:00Z", [("gpt-9-astra", True, "high", CODEX_LEVELS, None)])
|
|
180
|
+
written = mc.refresh(self.roots)
|
|
181
|
+
self.assertEqual(sorted(written), sorted(os.path.join(r, mc.OVERLAY) for r in self.roots.values()))
|
|
182
|
+
doc = json.loads(Path(written[0]).read_text())
|
|
183
|
+
self.assertEqual(doc["codex"]["models"]["gpt-9-astra"]["default_effort"], "high")
|
|
184
|
+
self.assertIn("generated_at", doc)
|
|
185
|
+
self.assertEqual(sorted(os.listdir(self.roots["codex"])), ["acct-01", mc.OVERLAY])
|
|
186
|
+
self.assertEqual(mc.refresh(self.roots, if_stale=True), [])
|
|
187
|
+
os.utime(written[0], (0, 0))
|
|
188
|
+
self.assertEqual(mc.refresh(self.roots, if_stale=True), [written[0]])
|
|
189
|
+
# the overlay is itself a source for the next load, until a cache is newer
|
|
190
|
+
shutil.rmtree(os.path.join(self.roots["codex"], "acct-01"))
|
|
191
|
+
self.assertIn("gpt-9-astra", mc.load(self.roots)["codex"]["models"])
|
|
192
|
+
self.assertEqual(mc.refresh({"codex": str(self.tmp / "nope")}), [])
|
|
193
|
+
|
|
194
|
+
def test_an_unset_root_is_not_searched_from_the_cwd(self):
|
|
195
|
+
here = self.tmp / "cwd"
|
|
196
|
+
os.makedirs(str(here / "acct-01"))
|
|
197
|
+
(here / "acct-01" / "models_cache.json").write_text(json.dumps(
|
|
198
|
+
{"fetched_at": "2099-01-01T00:00:00Z", "models": [{"slug": "gpt-9-luna", "visibility": "list"}]}))
|
|
199
|
+
self.addCleanup(os.chdir, os.getcwd())
|
|
200
|
+
os.chdir(str(here))
|
|
201
|
+
shipped = json.loads(Path(mc.SHIPPED).read_text())
|
|
202
|
+
self.assertEqual(mc.load({"claude": self.roots["claude"]})["codex"]["models"], shipped["codex"]["models"])
|
|
203
|
+
|
|
204
|
+
def test_a_failed_write_leaves_no_scratch_file(self):
|
|
205
|
+
os.makedirs(os.path.join(self.roots["codex"], mc.OVERLAY)) # os.replace onto a directory fails
|
|
206
|
+
self.assertEqual(mc.refresh(self.roots), [os.path.join(self.roots["claude"], mc.OVERLAY)])
|
|
207
|
+
self.assertEqual(sorted(os.listdir(self.roots["codex"])), ["acct-01", mc.OVERLAY])
|
|
208
|
+
|
|
209
|
+
def test_cli(self):
|
|
210
|
+
script = str(ROOT / "lib/model_catalog.py")
|
|
211
|
+
out = subprocess.run([sys.executable, "-I", script, "refresh", "--codex-root", self.roots["codex"],
|
|
212
|
+
"--claude-root", self.roots["claude"]], capture_output=True, text=True)
|
|
213
|
+
self.assertEqual(out.returncode, 0, out.stderr)
|
|
214
|
+
self.assertEqual(sorted(out.stdout.split()), sorted(os.path.join(r, mc.OVERLAY) for r in self.roots.values()))
|
|
215
|
+
out = subprocess.run([sys.executable, "-I", script, "show"], capture_output=True, text=True)
|
|
216
|
+
self.assertEqual(out.returncode, 0)
|
|
217
|
+
self.assertEqual(json.loads(out.stdout)["claude"]["efforts"], LEVELS)
|
|
218
|
+
for args in ([], ["bogus"], ["refresh", "--codex-root"], ["refresh", "show"]):
|
|
219
|
+
self.assertEqual(subprocess.run([sys.executable, "-I", script] + args, capture_output=True).returncode, 2)
|
|
220
|
+
out = subprocess.run([sys.executable, "-I", script, "refresh", "--codex-root", str(self.tmp / "nope")],
|
|
221
|
+
capture_output=True, text=True)
|
|
222
|
+
self.assertEqual(out.returncode, 1)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
unittest.main()
|