Skip to content

Commit a838f91

Browse files
ampagentIsaac Miller
andcommitted
Keep managed routes cancellation and verification tied to their connection
Signed-off-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c095-4917-7617-9dac-e99f1ef22d41 Co-authored-by: Isaac Miller <isaac@cmpnd.ai>
1 parent cd7ef07 commit a838f91

2 files changed

Lines changed: 186 additions & 22 deletions

File tree

‎lm15/login/manager.py‎

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def __init__(self, store: Store, *, clock: Callable[[], float] = time.time,
158158
self._monotonic = monotonic
159159
self._opener = opener
160160
self._sleep = sleep
161-
self._active: dict[str, threading.Event] = {} # provider -> cancel event of a login in this process
161+
self._active: dict[str, tuple[str, threading.Event]] = {} # provider -> (attempt id, cancel event)
162162
self._lock = threading.RLock()
163163
self._closed = False
164164

@@ -213,6 +213,20 @@ def _view(self, document: dict[str, Any], provider: str) -> tuple[_Slot, Materia
213213
)
214214
return slot, material
215215

216+
def _route_view(self, document: dict[str, Any], provider: str) -> tuple[_Slot, Material | None]:
217+
"""An explicit route slot wins; otherwise use its advertised owner."""
218+
slots = (document.get(META_KEY) or {}).get("slots") or {}
219+
if provider in document or provider in slots:
220+
return self._view(document, provider)
221+
owners = [owner for owner, record in slots.items()
222+
if provider in record.get("routes", ()) and provider in self.descriptor(owner).routes]
223+
if len(owners) > 1:
224+
raise AuthOperationError(
225+
f"{provider}: multiple saved connections advertise this route; select a connection",
226+
reason="connection_changed", stage="resolution", recovery="select_connection", provider=provider,
227+
)
228+
return self._view(document, owners[0] if owners else provider)
229+
216230
@staticmethod
217231
def _put(document: dict[str, Any], slot: _Slot, material: Material | None) -> dict[str, Any]:
218232
meta = document.setdefault(META_KEY, {"version": STORE_VERSION, "slots": {}})
@@ -248,7 +262,7 @@ def connections(self) -> tuple[Connection, ...]:
248262

249263
def status(self, provider: str) -> ConnectionStatus:
250264
provider = self.descriptor(provider).id
251-
slot, material = self._view(self.store.read(), provider)
265+
slot, material = self._route_view(self.store.read(), provider)
252266
connection = slot.connection()
253267
verification = None
254268
if slot.verification:
@@ -332,10 +346,8 @@ def login(
332346
# Reservation (AUTH-17/18): storage proven writable, one active
333347
# attempt per slot, generation observed — before any browser opens.
334348
self.store.reserve()
335-
expected = self._reserve(provider, attempt_id, replace, lifetime_s)
336-
with self._lock:
337-
self._active[provider] = cancel
338349
try:
350+
expected = self._reserve(provider, attempt_id, replace, lifetime_s, cancel=cancel)
339351
for field_ in chosen.fields:
340352
if field_.id not in answers:
341353
if not field_.required and field_.type != "select":
@@ -379,6 +391,7 @@ def login(
379391
except LM15Error:
380392
self._release(provider, attempt_id)
381393
raise
394+
ctx.check()
382395
return self._commit(provider, attempt_id, expected, chosen, result, settings)
383396
except BaseException:
384397
# Any exit without a saved connection ends the attempt — Ctrl-C
@@ -390,7 +403,9 @@ def login(
390403
raise
391404
finally:
392405
with self._lock:
393-
self._active.pop(provider, None)
406+
active = self._active.get(provider)
407+
if active is not None and active[0] == attempt_id:
408+
self._active.pop(provider)
394409

395410
def _choose_method(self, descriptor: ProviderDescriptor, method: str | LoginMethod | None, ui: AuthUI, *,
396411
allow_unverified: bool) -> LoginMethod:
@@ -440,7 +455,8 @@ def _choose_method(self, descriptor: ProviderDescriptor, method: str | LoginMeth
440455
reason="invalid_login_state", stage="interaction", recovery="choose_method", provider=descriptor.id,
441456
)
442457

443-
def _reserve(self, provider: str, attempt_id: str, replace: str | None, lifetime_s: float) -> int:
458+
def _reserve(self, provider: str, attempt_id: str, replace: str | None, lifetime_s: float,
459+
*, cancel: threading.Event | None = None) -> int:
444460
"""Reserve the slot's single active attempt; return the generation
445461
the commit must find."""
446462
now = self._clock()
@@ -473,6 +489,11 @@ def reserve(document: dict[str, Any]) -> dict[str, Any] | None:
473489
)
474490
slot.attempt = {"id": attempt_id, "expected_generation": str(slot.generation),
475491
"started_at_s": now, "lifetime_s": float(lifetime_s)}
492+
if cancel is not None:
493+
with self._lock:
494+
if cancel.is_set():
495+
raise LoginCancelled("login cancelled before reservation")
496+
self._active[provider] = (attempt_id, cancel)
476497
return self._put(document, slot, material)
477498

478499
document = self.store.mutate(reserve)
@@ -552,17 +573,33 @@ def cancel(document: dict[str, Any]) -> dict[str, Any] | None:
552573
if not slot.attempt:
553574
outcome["result"] = "complete" if slot.connection_id else "none"
554575
return None
576+
outcome["attempt_id"] = slot.attempt["id"]
555577
slot.attempt = None
556578
outcome["result"] = "cancelled"
557579
return self._put(document, slot, material)
558580

559581
self.store.mutate(cancel) # the durable record first (AUTH-19) …
560582
with self._lock:
561-
event = self._active.get(provider)
562-
if event is not None:
563-
event.set() # … then the running attempt in this process
583+
active = self._active.get(provider)
584+
if active is not None and active[0] == outcome.get("attempt_id"):
585+
active[1].set() # … then the running attempt in this process
564586
return outcome["result"]
565587

588+
def _cancel_login_attempt(self, provider: str, event: threading.Event) -> None:
589+
"""Cancel only this async caller's reservation, ordered against commit."""
590+
provider = self.descriptor(provider).id
591+
592+
def cancel(document: dict[str, Any]) -> dict[str, Any] | None:
593+
slot, material = self._view(document, provider)
594+
with self._lock:
595+
active = self._active.get(provider)
596+
if active is None or active[1] is not event or not slot.attempt or slot.attempt.get("id") != active[0]:
597+
return None
598+
slot.attempt = None
599+
return self._put(document, slot, material)
600+
601+
self.store.mutate(cancel)
602+
566603
# ─── setup without a provider round-trip (AUTH-17) ─────────────────
567604

568605
def set_api_key(self, provider: str, key: str, *, replace: str | None = None) -> Connection:
@@ -650,9 +687,9 @@ def forget(document: dict[str, Any]) -> dict[str, Any] | None:
650687
)
651688
outcome.update(forgot=True, generation=new.generation, routes=new.routes)
652689
with self._lock:
653-
event = self._active.get(provider)
654-
if event is not None:
655-
event.set()
690+
active = self._active.get(provider)
691+
if active is not None:
692+
active[1].set()
656693
return self._put(document, new, material_after)
657694

658695
self.store.mutate(forget)
@@ -673,7 +710,9 @@ def _resolve_target(self, provider_or_connection: str) -> tuple[str, str | None]
673710
"no saved connection has that id", reason="attempt_unavailable", stage="resolution",
674711
recovery="select_connection",
675712
)
676-
return self.descriptor(provider_or_connection).id, None
713+
provider = self.descriptor(provider_or_connection).id
714+
slot, _ = self._route_view(self.store.read(), provider)
715+
return slot.provider, None
677716

678717
# ─── verification (AUTH-17) ────────────────────────────────────────
679718

@@ -689,11 +728,21 @@ def verify(self, provider: str, *, router_config: Any = None) -> Verification:
689728
if definition is None or not definition.supports.models:
690729
return Verification(result="unverified", check="models", detail="this route has no safe non-inference check")
691730
from ..router import LMRouter, RouterConfig
731+
from .bound import pinned_auth
732+
from .types import ModelSelection
692733

693734
config = router_config or RouterConfig()
694735
from dataclasses import replace as _replace
695736

696-
router = LMRouter(_replace(config, auth=self))
737+
slot, _ = self._route_view(self.store.read(), provider)
738+
connection = slot.connection()
739+
managed = self
740+
if connection is not None:
741+
selection = ModelSelection(provider=provider, model="verify", connection_id=connection.id,
742+
identity_generation=connection.identity_generation)
743+
managed = pinned_auth(self, selection)
744+
# Verify the saved connection, never an explicit router-config key.
745+
router = LMRouter(_replace(config, auth=managed, api_keys={}, credentials={}))
697746
checked = _now_iso(self._clock)
698747
try:
699748
lm = router.lm(f"{provider}:verify")
@@ -703,13 +752,14 @@ def verify(self, provider: str, *, router_config: Any = None) -> Verification:
703752
result = Verification(result="rejected", checked_at=checked, check="models", detail=exc.code)
704753
finally:
705754
router.close()
706-
self._record_verification(provider, result)
755+
if connection is not None:
756+
self._record_verification(connection, result)
707757
return result
708758

709-
def _record_verification(self, provider: str, result: Verification) -> None:
759+
def _record_verification(self, connection: Connection, result: Verification) -> None:
710760
def record(document: dict[str, Any]) -> dict[str, Any] | None:
711-
slot, material = self._view(document, provider)
712-
if slot.connection_id is None:
761+
slot, material = self._view(document, connection.provider)
762+
if slot.connection_id != connection.id or str(slot.generation) != connection.identity_generation:
713763
return None
714764
slot.verification = {"result": result.result, "checked_at": result.checked_at, "check": result.check,
715765
"detail": result.detail}
@@ -729,7 +779,8 @@ def request_auth(self, provider: str, *, pinned: tuple[str, str] | None = None)
729779
``connection_changed``, never a silent rebind (AUTH-20.1)."""
730780
provider = self.descriptor(provider).id
731781
document = self.store.read()
732-
slot, material = self._view(document, provider)
782+
slot, material = self._route_view(document, provider)
783+
provider = slot.provider
733784
if slot.renewal_in_flight and slot.state != "indeterminate":
734785
# A sibling may be renewing right now (it holds the lock), or may
735786
# have died mid-exchange. Only the lock can tell: wait for it,
@@ -901,7 +952,7 @@ def close(self) -> None:
901952
with self._lock:
902953
self._closed = True
903954
events = list(self._active.values())
904-
for event in events:
955+
for _, event in events:
905956
event.set()
906957

907958
def _check_open(self) -> None:
@@ -972,7 +1023,24 @@ async def status(self, provider: str):
9721023
return await self._run(self.sync.status, provider)
9731024

9741025
async def login(self, provider: str, method: Any = None, **kwargs: Any):
975-
return await self._run(self.sync.login, provider, method, **kwargs)
1026+
import asyncio
1027+
1028+
cancel = kwargs.get("cancel") or threading.Event()
1029+
kwargs["cancel"] = cancel
1030+
try:
1031+
return await self._run(self.sync.login, provider, method, **kwargs)
1032+
except asyncio.CancelledError:
1033+
cancel.set()
1034+
cleanup = asyncio.create_task(self._run(self.sync._cancel_login_attempt, provider, cancel))
1035+
# Repeated task cancellation must not let a reservation outlive
1036+
# CancelledError. Do not wait for the provider's worker to return.
1037+
while not cleanup.done():
1038+
try:
1039+
await asyncio.shield(cleanup)
1040+
except asyncio.CancelledError:
1041+
continue
1042+
cleanup.result()
1043+
raise
9761044

9771045
async def set_api_key(self, provider: str, key: str, **kwargs: Any):
9781046
return await self._run(self.sync.set_api_key, provider, key, **kwargs)

‎tests/test_login.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,48 @@ def test_one_active_attempt_per_slot(sandbox: Path) -> None:
458458
auth.login("xai", "device", ui=ScriptUI())
459459

460460

461+
@pytest.mark.parametrize("cooperative", [False, True])
462+
def test_async_cancellation_cannot_replace_saved_connection(sandbox: Path, monkeypatch, cooperative) -> None:
463+
import asyncio
464+
from lm15.login import AsyncAuth
465+
from lm15.login.flows.base import LoginResult
466+
from lm15.login.flows.xai import XaiFlow
467+
468+
entered, approval = threading.Event(), threading.Event()
469+
auth = make_auth(sandbox, Clock())
470+
original = auth.set_api_key("xai", "original")
471+
472+
def delayed_login(self, ctx, method, settings, answers):
473+
entered.set()
474+
assert approval.wait(5)
475+
if cooperative:
476+
ctx.check()
477+
return LoginResult(material={"type": "api_key", "key": "replacement"}, label="replacement")
478+
479+
monkeypatch.setattr(XaiFlow, "login", delayed_login)
480+
481+
async def run():
482+
task = asyncio.create_task(AsyncAuth(auth).login("xai", "device", ui=ScriptUI(), replace=original.id))
483+
try:
484+
assert await asyncio.to_thread(entered.wait, 3)
485+
task.cancel()
486+
with pytest.raises(asyncio.CancelledError):
487+
await task
488+
reopened = Auth.local(sandbox / "credentials.json")
489+
assert reopened.status("xai").connection.id == original.id
490+
# Cancellation releases the reservation before approval returns,
491+
# so a new operation can replace A immediately.
492+
replacement = reopened.set_api_key("xai", "newer", replace=original.id)
493+
return replacement.id
494+
finally:
495+
approval.set()
496+
497+
replacement_id = asyncio.run(run()) # joins the cancelled task's worker
498+
reopened = Auth.local(sandbox / "credentials.json")
499+
assert reopened.status("xai").connection.id == replacement_id
500+
assert reopened.store.read()["xai"]["key"] == "newer"
501+
502+
461503
def test_an_interrupt_ends_the_attempt_and_frees_the_slot(sandbox: Path) -> None:
462504
# Ctrl-C — a notebook's cancel — while waiting for the provider, or at
463505
# a prompt: the attempt is over, so its reservation must not block the
@@ -969,3 +1011,57 @@ def test_meta_without_a_plan_says_pay_as_you_go(sandbox: Path) -> None:
9691011
"is_subs_active": False}))
9701012
connection = auth.login("meta", "device", ui=ScriptUI(), allow_unverified=True)
9711013
assert connection.account_label == "no Muse Code subscription (pay-as-you-go)"
1014+
1015+
1016+
@pytest.mark.parametrize("route", ["meta", "meta-chat", "meta-anthropic"])
1017+
def test_meta_routes_share_connection_renewal_and_logout(sandbox: Path, route) -> None:
1018+
from lm15.login.flows.meta import KEY_LIFETIME_S
1019+
1020+
clock = Clock()
1021+
server = FakeMeta({"api_key": "key-a", "base_url": "https://eu.api.meta.ai/v1"})
1022+
auth = make_auth(sandbox, clock, server)
1023+
connection = auth.login("meta", "device", ui=ScriptUI(), allow_unverified=True)
1024+
transport = FakeTransport([_models_reply(), _models_reply()])
1025+
router = LMRouter(RouterConfig(auth=auth, transport=transport, env={"META_API_KEY": "payg"}))
1026+
lm = router.lm(f"{route}:muse-wonder-full-1.0")
1027+
assert lm.list_models()
1028+
server.mint = {"api_key": "key-b", "base_url": "https://us.api.meta.ai/v1"}
1029+
clock.advance(KEY_LIFETIME_S)
1030+
assert lm.list_models()
1031+
for sent, region, key in zip(transport.requests, ("eu", "us"), ("key-a", "key-b")):
1032+
assert sent.url.split("?", 1)[0] == f"https://{region}.api.meta.ai/v1/models"
1033+
headers = {k.lower(): v for k, v in sent.headers}
1034+
assert key in (headers.get("x-api-key"), headers.get("authorization", "").removeprefix("Bearer "))
1035+
assert auth.status(route).connection.id == connection.id
1036+
assert auth.status(route).connection.credential_revision == "2"
1037+
assert auth.logout(route).forgot
1038+
with pytest.raises(AuthOperationError, match="signed out"):
1039+
lm.list_models()
1040+
assert len(transport.requests) == 2
1041+
router.close()
1042+
1043+
1044+
@pytest.mark.parametrize("replace_connection", [False, True])
1045+
@pytest.mark.parametrize("rejected", [False, True])
1046+
def test_verification_stays_with_checked_connection(sandbox: Path, replace_connection, rejected) -> None:
1047+
auth = make_auth(sandbox, Clock())
1048+
original = auth.set_api_key("openai-chat", "key-a")
1049+
reply = FakeResponse(401, b'{"error":{"message":"rejected"}}') if rejected else _models_reply()
1050+
1051+
class ReplacingTransport(FakeTransport):
1052+
def stream(self, request):
1053+
if replace_connection:
1054+
auth.set_api_key("openai-chat", "key-b", replace=original.id)
1055+
return super().stream(request)
1056+
1057+
transport = ReplacingTransport([reply])
1058+
result = auth.verify("openai-chat", router_config=RouterConfig(transport=transport))
1059+
assert result.result == ("rejected" if rejected else "valid")
1060+
assert dict(transport.requests[0].headers)["Authorization"] == "Bearer key-a"
1061+
status = Auth.local(sandbox / "credentials.json").status("openai-chat")
1062+
if replace_connection:
1063+
assert status.connection.id != original.id
1064+
assert status.verification is None
1065+
else:
1066+
assert status.connection.id == original.id
1067+
assert status.verification == result

0 commit comments

Comments
 (0)