@@ -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 )
0 commit comments