File size: 56,981 Bytes
c61f7ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
"""Tool-resource teardown, wire-client lifecycle and credential refresh for ``AIAgent``.

``ClientLifecycleMixin`` owns task cleanup, the shared primary client, per-request client caches
(owner-thread close vs stranger-thread abort), credential rotation and route-derived headers.
"""
import logging
import threading
import time
from contextlib import suppress
from typing import Any, Optional

from agent.lazy_forward import forward as _forward, forward_static as _forward_static, lazy_attr as _lazy_attr
from hermes_cli.timeouts import get_provider_request_timeout
from utils import base_url_host_matches, env_float

logger = logging.getLogger("run_agent")  # origin module's logger name: log records / caplog filters unchanged
_QWEN_CODE_VERSION = "0.14.1"  # Qwen Portal mimics the QwenCode CLI
# Per-request cache slot attribute names (OpenAI-style and Anthropic clients).
_OPENAI_SLOT = "_request_client_cache"
_ANTHROPIC_SLOT = "_request_anthropic_client_cache"
_NO_SOCKETS_SUFFIX = " — no sockets found; in-flight request may keep running until the provider finishes"


def _routermint_headers() -> dict:
    """User-Agent RouterMint needs to avoid Cloudflare 1010 blocks."""
    from hermes_cli import __version__ as _HERMES_VERSION
    return {"User-Agent": f"HermesAgent/{_HERMES_VERSION}"}


def _qwen_portal_headers() -> dict:
    import platform as _plat
    _ua = f"QwenCode/{_QWEN_CODE_VERSION} ({_plat.system().lower()}; {_plat.machine()})"
    return {
        "User-Agent": _ua, "X-DashScope-CacheControl": "enable", "X-DashScope-UserAgent": _ua,
        "X-DashScope-AuthType": "qwen-oauth",
    }


# Route-specific default headers; first host match wins (order preserved from the original chain).
# Builders resolve their module lazily so run_agent keeps its import-time cost and avoids cycles.
_ROUTE_DEFAULT_HEADERS = (
    ("openrouter.ai", lambda self, url: _lazy_attr("agent.auxiliary_client", "build_or_headers")()),
    ("ai-gateway.vercel.sh", lambda self, url: dict(_lazy_attr("agent.auxiliary_client", "_AI_GATEWAY_HEADERS"))),
    ("integrate.api.nvidia.com", lambda self, url: _lazy_attr("agent.auxiliary_client", "build_nvidia_nim_headers")(url)),
    ("api.routermint.com", lambda self, url: _routermint_headers()),
    ("githubcopilot.com", lambda self, url: _lazy_attr("hermes_cli.models", "copilot_default_headers")()),
    ("api.kimi.com", lambda self, url: dict(_lazy_attr("agent.auxiliary_client", "_AI_GATEWAY_HEADERS"))),
    ("portal.qwen.ai", lambda self, url: _qwen_portal_headers()),
    ("chatgpt.com", lambda self, url: _lazy_attr("agent.codex_headers", "codex_cloudflare_headers")(
        self._client_kwargs.get("api_key", ""), base_url=url)),
    # Covers provider=xai and provider=xai-oauth (api.x.ai).
    ("x.ai", lambda self, url: _lazy_attr("tools.xai_http", "hermes_xai_default_headers")()),
)


def _reset_slot(cache: dict, *, in_use: bool = False) -> None:
    cache["client"] = None
    cache["key"] = None
    cache["poisoned"] = False
    cache["in_use"] = in_use


def _valid_credential_pair(api_key: Any, base_url: Any) -> bool:
    return bool(isinstance(api_key, str) and api_key.strip() and isinstance(base_url, str) and base_url.strip())


def _swap_fallback_clients(agent, fb_client, fb_provider: str, fb_model: str, fb_base_url: str, fb_api_mode: str) -> None:
    """Install the fallback client(s) in place, honoring request_timeout_seconds (None = SDK default)."""
    timeout = get_provider_request_timeout(fb_provider, fb_model)
    if fb_provider == "bedrock" and fb_api_mode in ("anthropic_messages", "bedrock_converse"):
        # Non-Mantle Bedrock: boto3-chain auth, no OpenAI/Anthropic SDK client to carry over.
        from agent.bedrock_adapter import bind_bedrock_runtime
        bind_bedrock_runtime(agent, fb_base_url, fb_api_mode)
        return
    # The SDK exposes an empty/stale api_key when a rotating source is installed.
    key_provider = vars(fb_client).get("_api_key_provider")
    credential = key_provider if callable(key_provider) else fb_client.api_key
    if fb_api_mode == "anthropic_messages":
        from agent.anthropic_adapter import build_anthropic_client
        from agent.anthropic_credentials import resolve_anthropic_token, _is_oauth_token
        is_anthropic = fb_provider == "anthropic"
        effective_key = credential or (resolve_anthropic_token() if is_anthropic else None) or ""
        agent.api_key = agent._anthropic_api_key = effective_key
        agent._anthropic_base_url = fb_base_url
        agent._anthropic_client = build_anthropic_client(effective_key, fb_base_url, timeout=timeout)
        agent._is_anthropic_oauth = (
            _is_oauth_token(effective_key)
            if is_anthropic and isinstance(effective_key, str) else False
        )
        agent.client, agent._client_kwargs = None, {}
        return
    agent.api_key = credential
    agent.client = fb_client
    # Keep provider headers resolve_provider_client() baked into fb_client (SDK: _custom_headers), else
    # later request-client rebuilds drop them and User-Agent-sentinel providers (Kimi Coding) 403.
    fb_headers = getattr(fb_client, "_custom_headers", None) or getattr(fb_client, "default_headers", None)
    agent._client_kwargs = {"api_key": credential, "base_url": fb_base_url}
    if fb_headers:
        agent._client_kwargs["default_headers"] = dict(fb_headers)
    if timeout is not None:
        agent._client_kwargs["timeout"] = timeout
        # Rebuild now so the timeout applies to the very next request, not only after a rotation rebuild.
        agent._replace_primary_openai_client(reason="fallback_timeout_apply")


class ClientLifecycleMixin:
    def _close_task_resources(self, task_id: str) -> None:
        """Release task resources without treating a shared environment as process ownership."""
        from run_agent import _quietly, cleanup_browser, cleanup_vm

        def kill_processes() -> None:
            from tools.process_registry import process_registry
            # A session can run several task IDs; delegated IDs also differ from session_id.
            # Never match the environment key (e.g. "default"), shared by parent and siblings.
            owners = getattr(self, "_process_owner_task_ids", ())
            for process in process_registry.list_sessions():
                if process["owner_task_id"] in owners and process["status"] == "running":
                    process_registry.kill_process(
                        process["session_id"], source="agent_close", consume_output=True,
                    )

        def release_computer_use() -> None:
            from tools.computer_use.tool import release_computer_use_session
            release_computer_use_session(task_id)

        for step in (kill_processes, lambda: cleanup_vm(task_id), lambda: cleanup_browser(task_id), release_computer_use):
            _quietly(step)

    def _client_log_context(self) -> str:
        thread = threading.current_thread()
        return (
            f"thread={thread.name}:{thread.ident} provider={getattr(self, 'provider', 'unknown')} "
            f"base_url={getattr(self, 'base_url', 'unknown')} model={getattr(self, 'model', 'unknown')}"
        )

    def _anthropic_log_context(self) -> str:
        return f"provider={getattr(self, 'provider', None)} model={getattr(self, 'model', None)}"

    def _openai_client_lock(self) -> threading.RLock:
        if getattr(self, "_client_lock", None) is None:
            self._client_lock = threading.RLock()
        return self._client_lock

    @staticmethod
    def _is_openai_client_closed(client: Any) -> bool:
        """Check if an OpenAI client is closed.

        Handles both property and method forms of is_closed:
        - httpx.Client.is_closed is a bool property
        - openai.OpenAI.is_closed is a method returning bool

        Prior bug: getattr(client, "is_closed", False) returned the bound method,
        which is always truthy, causing unnecessary client recreation on every call.
        """
        from unittest.mock import Mock

        if isinstance(client, Mock):
            return False

        is_closed_attr = getattr(client, "is_closed", None)
        if is_closed_attr is not None:
            # Handle method (openai SDK) vs property (httpx)
            if callable(is_closed_attr):
                if is_closed_attr():
                    return True
            elif bool(is_closed_attr):
                return True

        http_client = getattr(client, "_client", None)
        if http_client is not None:
            return bool(getattr(http_client, "is_closed", False))
        return False

    @staticmethod
    def _build_keepalive_http_client(base_url: str = "", *, verify: Any = True) -> Any:
        """Build the shared OpenAI httpx client used by main and aux paths."""
        from agent.process_bootstrap import build_keepalive_http_client
        return build_keepalive_http_client(base_url, verify=verify)

    _create_openai_client = _forward("agent.agent_runtime_helpers", "create_openai_client")
    _force_close_tcp_sockets = _forward_static("agent.agent_runtime_helpers", "force_close_tcp_sockets")
    _cleanup_dead_connections = _forward("agent.agent_runtime_helpers", "cleanup_dead_connections")
    _run_codex_stream = _forward("agent.codex_runtime", "run_codex_stream")
    _recover_with_credential_pool = _forward("agent.agent_runtime_helpers", "recover_with_credential_pool")

    def _close_openai_client(self, client: Any, *, reason: str, shared: bool) -> None:
        if client is None:
            return
        ctx = self._client_log_context()
        # Force-close TCP sockets first (no CLOSE-WAIT accumulation), then the graceful SDK close.
        force_closed = self._force_close_tcp_sockets(client)
        try:
            client.close()
            logger.info("OpenAI client closed (%s, shared=%s, tcp_force_closed=%d) %s", reason, shared, force_closed, ctx)
        except Exception as exc:
            logger.debug("OpenAI client close failed (%s, shared=%s) %s error=%s", reason, shared, ctx, exc)

    def _retire_shared_openai_client(self, client: Any, *, reason: str) -> None:
        """Ownership-safe retirement of a replaced shared OpenAI client: ``shutdown()`` sockets only, FDs to GC.

        ``close()`` releases FDs from the calling thread while other threads may still hold the fd in an SSL BIO;
        a recycled fd then gets a TLS record written into an unrelated file (SQLite-header corruption).

        The shared primary client has no single owning thread — worker threads from stale-killed attempts
        may still be unwinding their SSL BIOs, and the codex-direct / MoA paths stream on the shared client
        itself. If we release an FD while another thread's SSL layer still caches the raw integer fd, the
        kernel can recycle it into an unrelated ``open()`` (e.g. ``kanban.db``) and the unwinding TLS flush
        then writes an application-data record into that file — the SQLite-header corruption documented in
        #29507/#70773.
        """
        if client is None:
            return
        try:
            shutdown_count = self._force_close_tcp_sockets(client)
            logger.info(
                "Shared OpenAI client retired (%s, tcp_shutdown=%d, fd_release=deferred_to_gc) %s",
                reason, shutdown_count, self._client_log_context(),
            )
        except Exception as exc:
            logger.debug("Shared OpenAI client retire failed (%s) %s error=%s", reason, self._client_log_context(), exc)

    def _drain_transports_after_abandonment(self, *, reason: str) -> int:
        """FD-safe transport drain for an abandoned (timed-out) worker; returns sockets shut down.

        The worker may be blocked in an OpenSSL read; hard-closing from the timeout thread releases FDs under a
        live BIO (native corruption / SIGSEGV). Only ``shutdown()`` so the read sees EOF and the worker closes itself.

        See #94248.
        A delegation deadline abandons this agent's daemon worker while it may still be blocked inside an
        in-flight OpenSSL ``read`` (Codex Responses stream, httpx request). This helper only ``shutdown()``s
        pooled sockets (safe from any thread), settling blocked reads with EOF/EPIPE so the worker can
        unwind and run the real close from its own thread. See #70773, #94248.
        """
        drained = 0
        # Shared primary client (codex-direct / MoA stream on it directly).
        try:
            client = getattr(self, "client", None)
            if client is not None:
                drained += self._force_close_tcp_sockets(client)
        except Exception:
            logger.debug("Abandoned-worker drain: shared client sweep failed", exc_info=True)
        # Cached per-request wire clients: abort (shutdown + poison the reuse slot) so the unwinding
        # worker discards them instead of re-caching.
        for slot_attr, abort, label in (
            (_OPENAI_SLOT, self._abort_request_openai_client, "request"),
            (_ANTHROPIC_SLOT, self._abort_request_anthropic_client, "anthropic"),
        ):
            try:
                with self._openai_client_lock():
                    cache = getattr(self, slot_attr, None)
                    cached = cache["client"] if cache else None
                if cached is not None:
                    abort(cached, reason=reason)
            except Exception:
                logger.debug("Abandoned-worker drain: %s client abort failed", label, exc_info=True)
        # Codex app-server session watches a private interrupt event.
        try:
            request_interrupt = getattr(getattr(self, "_codex_session", None), "request_interrupt", None)
            if callable(request_interrupt):
                request_interrupt()
        except Exception:
            logger.debug("Abandoned-worker drain: codex interrupt failed", exc_info=True)
        # Inline (cron-style) request abort hook, when registered.
        try:
            abort_active = getattr(self, "_active_request_abort", None)
            if callable(abort_active):
                abort_active(reason)
        except Exception:
            logger.debug("Abandoned-worker drain: active request abort failed", exc_info=True)
        logger.info(
            "Abandoned-worker transports drained (%s, tcp_shutdown=%d, fd_release=deferred_to_worker) %s",
            reason, drained, self._client_log_context(),
        )
        return drained

    def _replace_primary_openai_client(self, *, reason: str) -> bool:
        with self._openai_client_lock():
            old_client = getattr(self, "client", None)
            try:
                # MoA's ``client`` is an in-process facade, not an SDK client; generic rebuilds must preserve that.
                if (getattr(self, "provider", "") or "").strip().lower() == "moa":
                    from agent.moa_loop import build_moa_facade
                    new_client = build_moa_facade(self, self.model)
                else:
                    new_client = self._create_openai_client(self._client_kwargs, reason=reason, shared=True)
            except Exception as exc:
                logger.warning(
                    "Failed to rebuild shared primary client (%s) %s error=%s", reason, self._client_log_context(), exc,
                )
                return False
            self.client = new_client
        # Never hard-close the replaced shared client (another thread may still be unwinding on the old pool).
        # #70773: never hard-close the replaced shared client from here — the caller may not be the thread
        # whose request is still unwinding on the old pool (credential rotation and dead-connection cleanup
        # run on the turn thread while stale-killed workers unwind; the codex-direct path streams on the
        # shared client itself). Retire it instead: sockets are shut down (FD-safe), FD release deferred to
        # GC.
        self._retire_shared_openai_client(old_client, reason=f"replace:{reason}")
        return True

    def _ensure_primary_openai_client(self, *, reason: str) -> Any:
        with self._openai_client_lock():
            client = getattr(self, "client", None)
            if client is not None and not self._is_openai_client_closed(client):
                return client
            try:
                new_client = self._create_openai_client(self._client_kwargs, reason=reason, shared=True)
            except Exception as exc:
                logger.warning(
                    "Failed to recreate closed OpenAI client (%s) %s error=%s", reason, self._client_log_context(), exc,
                )
                raise RuntimeError("Failed to recreate closed OpenAI client") from exc
            self.client = new_client
        logger.warning(
            "Detected closed shared OpenAI client; recreated before use (%s) %s", reason, self._client_log_context(),
        )
        self._close_openai_client(client, reason=f"replace:{reason}", shared=True)
        return new_client

    @staticmethod
    def _api_kwargs_have_image_parts(api_kwargs: dict) -> bool:
        """True when the outbound request still has native image parts (Chat ``messages`` / Responses ``input``)."""
        if not isinstance(api_kwargs, dict):
            return False

        def _contains_image(value: Any) -> bool:
            if isinstance(value, dict):
                return value.get("type") in {"image_url", "input_image"} or any(
                    _contains_image(v) for v in value.values()
                )
            return isinstance(value, list) and any(_contains_image(v) for v in value)
        return any(
            _contains_image(item)
            for field in ("messages", "input")
            if isinstance(api_kwargs.get(field), list)
            for item in api_kwargs[field]
        )

    # ------------------------------------------------------------------ per-request client slots
    # One single-slot cache per client kind: {"client", "key", "poisoned", "in_use"}. Reuse keeps the warm httpx
    # pool between sequential calls; ``in_use`` keeps concurrent calls off one pool; ``poisoned`` marks a pool
    # whose sockets were shut from a stranger thread (never reuse it).
    # Reuse reasons: closes from the FD-owning worker's own finally after a response — the only closes that
    # attest a healthy pool. Poisoning still wins.
    _REQUEST_CLIENT_REUSE_REASONS = frozenset({"request_complete", "stream_request_complete"})

    def _request_slot(self, slot_attr: str) -> dict:
        cache = getattr(self, slot_attr, None)  # lazy: tests build agents via AIAgent.__new__ without __init__
        if cache is None:
            setattr(self, slot_attr, cache := {})
            _reset_slot(cache)
        return cache

    def _checkout_request_slot(self, slot_attr: str, key: Any) -> tuple:
        """Return ``(reusable_client, stale_client)``; at most one is non-None."""
        with self._openai_client_lock():
            cache = self._request_slot(slot_attr)
            cached = cache["client"]
            if cached is None or cache["in_use"]:
                return None, None
            if not cache["poisoned"] and cache["key"] == key and not self._is_openai_client_closed(cached):
                cache["in_use"] = True
                return cached, None
            # Key changed / poisoned / externally closed — rebuild. in_use was False, so closing the stale
            # client from this thread is FD-safe (no worker owns it).
            _reset_slot(cache)
            return None, cached

    def _store_request_slot(self, slot_attr: str, client: Any, key: Any) -> None:
        with self._openai_client_lock():
            cache = self._request_slot(slot_attr)
            if cache["client"] is None:
                cache.update(client=client, key=key, poisoned=False, in_use=True)
            # else: a concurrent call holds the slot — hand this client out untracked (fully closed later).

    def _release_request_slot(self, slot_attr: str, client: Any, reason: str) -> bool:
        """Owner-thread release; True when the client stays cached (clean finish, not poisoned)."""
        with self._openai_client_lock():
            cache = self._request_slot(slot_attr)
            if cache["client"] is client:
                if reason in self._REQUEST_CLIENT_REUSE_REASONS and not cache["poisoned"]:
                    cache["in_use"] = False
                    return True
                _reset_slot(cache)
        return False

    def _take_request_slot(self, slot_attr: str) -> tuple:
        """Teardown: empty the slot and return ``(client, was_in_use)``."""
        with self._openai_client_lock():
            cache = getattr(self, slot_attr, None)
            client, in_use = (cache["client"], bool(cache["in_use"])) if cache else (None, False)
            if cache is not None:
                _reset_slot(cache)
        return client, in_use

    def _abort_request_slot_client(self, slot_attr: str, client: Any, *, reason: str) -> None:
        """Cross-thread abort (interrupt loop, stale detector): ``shutdown(SHUT_RDWR)`` without releasing FDs.

        ``close()`` from a non-owning thread races the live SSL BIO and corrupts unrelated FDs; shutdown unblocks
        the owner's recv/send so it closes from its own context. The slot is poisoned so the pool is never reused.
        """
        if client is None:
            return
        anthropic = slot_attr == _ANTHROPIC_SLOT
        label = "Anthropic" if anthropic else "OpenAI"
        context = self._anthropic_log_context() if anthropic else self._client_log_context()
        with self._openai_client_lock():
            cache = self._request_slot(slot_attr)
            if cache["client"] is client:
                cache["poisoned"] = True
        try:
            shutdown_count = self._force_close_tcp_sockets(client)
            # Zero sockets shut down means the worker stays blocked — WARN, not success.
            # tcp_force_closed=0 means the stranger-thread abort found no sockets to shut down — the worker
            # stays blocked in recv and the provider keeps the slot (#72975). Surface that as WARNING so it
            # cannot be mistaken for a successful abort in the logs.
            # See #72975.
            _log = logger.warning if shutdown_count == 0 else logger.info
            _log(
                "%s client aborted (%s, shared=False, tcp_force_closed=%d, deferred_close=stranger_thread) %s%s",
                label, reason, shutdown_count, context, _NO_SOCKETS_SUFFIX if shutdown_count == 0 else "",
            )
        except Exception as exc:
            logger.debug("%s client abort failed (%s, shared=False) %s error=%s", label, reason, context, exc)

    def _create_request_openai_client(self, *, reason: str, api_kwargs: Optional[dict] = None) -> Any:
        from unittest.mock import Mock
        primary_client = self._ensure_primary_openai_client(reason=reason)
        if self.provider == "moa" or isinstance(primary_client, Mock):
            return primary_client
        with self._openai_client_lock():
            request_kwargs = dict(self._client_kwargs)
        # No SDK retry loop: the outer loop owns retries/rotation/fallback, and SDK retries stretch a hung
        # request ~3x past our stale detector.
        request_kwargs["max_retries"] = 0
        is_copilot = base_url_host_matches(str(request_kwargs.get("base_url", "")), "githubcopilot.com")
        if is_copilot and self._api_kwargs_have_image_parts(api_kwargs or {}):
            from hermes_cli.copilot_auth import copilot_request_headers
            request_kwargs["default_headers"] = copilot_request_headers(is_agent_turn=True, is_vision=True)
        cached, stale = self._checkout_request_slot(_OPENAI_SLOT, request_kwargs)
        if cached is not None:
            return cached
        if stale is not None:
            self._close_openai_client(stale, reason=f"reuse_evict:{reason}", shared=False)
        client = self._create_openai_client(request_kwargs, reason=reason, shared=False)
        # Snapshot nested dicts (default_headers) so an aliased inner object can't mutate the cache key.
        snapshot = {k: dict(v) if isinstance(v, dict) else v for k, v in request_kwargs.items()}
        self._store_request_slot(_OPENAI_SLOT, client, snapshot)
        return client

    def _close_request_openai_client(self, client: Any, *, reason: str) -> None:
        if not self._release_request_slot(_OPENAI_SLOT, client, reason):
            self._close_openai_client(client, reason=reason, shared=False)

    def _close_cached_request_openai_client(self, *, reason: str) -> None:
        """Teardown hook: really close the cached per-request wire client."""
        client, in_use = self._take_request_slot(_OPENAI_SLOT)
        if client is None:
            return
        if in_use:
            # Checked out by a worker: close() here would release FDs from a stranger thread. Abort the
            # sockets; the worker's own finally does the real close.
            self._abort_request_openai_client(client, reason=f"{reason}_in_flight")
        else:
            self._close_openai_client(client, reason=reason, shared=False)

    def _abort_request_openai_client(self, client: Any, *, reason: str) -> None:
        self._abort_request_slot_client(_OPENAI_SLOT, client, reason=reason)

    def _request_anthropic_client_key(self) -> tuple:
        """Cache key over everything forcing a fresh client: credential, base URL/region, timeout, 1M-beta flag."""
        if getattr(self, "provider", None) == "bedrock":
            return ("bedrock", getattr(self, "_bedrock_region", "us-east-1") or "us-east-1")
        return (
            "direct", self._anthropic_api_key, getattr(self, "_anthropic_base_url", None),
            get_provider_request_timeout(self.provider, self.model), bool(getattr(self, "_oauth_1m_beta_disabled", False)),
        )

    def _build_direct_anthropic_client(self, token: str, base_url: Any) -> Any:
        """Native Anthropic client for ``token``/``base_url`` with the provider/model request timeout."""
        from agent.anthropic_adapter import build_anthropic_client
        return build_anthropic_client(token, base_url, timeout=get_provider_request_timeout(self.provider, self.model))

    def _anthropic_oauth_flag(self, token: str) -> bool:
        """OAuth flag only on native Anthropic; third-party Anthropic-protocol endpoints must not trip OAuth paths."""
        from agent.anthropic_credentials import _is_oauth_token
        return _is_oauth_token(token) if self.provider == "anthropic" else False

    def _build_anthropic_client_for_key(self, key: tuple) -> Any:
        from agent.anthropic_adapter import build_anthropic_bedrock_client, build_anthropic_client
        if key[0] == "bedrock":
            return build_anthropic_bedrock_client(key[1])
        return build_anthropic_client(key[1], key[2], timeout=key[3], drop_context_1m_beta=key[4])

    def _create_request_anthropic_client(self, *, reason: str) -> Any:
        """Build (or reuse) a request-local Anthropic client for one in-flight call.

        The watchdog must never ``close()`` a client a worker is still reading (fd recycled under a live SSL BIO →
        TLS record in a SQLite header); per-request clients let the stranger ``shutdown()`` while the owner closes.
        """
        if self.api_mode == "anthropic_messages":
            self._try_refresh_anthropic_client_credentials()
        key = self._request_anthropic_client_key()
        cached, stale = self._checkout_request_slot(_ANTHROPIC_SLOT, key)
        if cached is not None:
            return cached
        if stale is not None:
            self._close_request_anthropic_client(stale, reason=f"reuse_evict:{reason}")
        client = self._build_anthropic_client_for_key(key)
        logger.debug("Anthropic request client created (%s, shared=False) %s", reason, self._anthropic_log_context())
        self._store_request_slot(_ANTHROPIC_SLOT, client, key)
        return client

    def _close_request_anthropic_client(self, client: Any, *, reason: str) -> None:
        """Owner-thread close: clean finish keeps the pool warm; otherwise force-close sockets (CLOSE-WAIT) + SDK close."""
        if client is None or self._release_request_slot(_ANTHROPIC_SLOT, client, reason):
            return
        try:
            self._force_close_tcp_sockets(client)
            client.close()
            logger.info("Anthropic client closed (%s, shared=False) %s", reason, self._anthropic_log_context())
        except Exception as exc:
            logger.debug(
                "Anthropic client close failed (%s, shared=False) %s error=%s", reason, self._anthropic_log_context(), exc,
            )

    def _close_cached_request_anthropic_client(self, *, reason: str) -> None:
        """Teardown hook: really close the cached per-request Anthropic client."""
        client, in_use = self._take_request_slot(_ANTHROPIC_SLOT)
        if client is None:
            return
        if in_use:  # checked out by a worker — same reasoning as the OpenAI teardown hook
            self._abort_request_anthropic_client(client, reason=f"{reason}_in_flight")
            return
        with suppress(Exception):
            self._force_close_tcp_sockets(client)
            client.close()

    def _abort_request_anthropic_client(self, client: Any, *, reason: str) -> None:
        self._abort_request_slot_client(_ANTHROPIC_SLOT, client, reason=reason)

    # ------------------------------------------------------------------ credential refresh
    def _sync_client_kwargs_credentials(self) -> None:
        """Mirror ``self.api_key`` / ``self.base_url`` into the OpenAI-style client kwargs."""
        self._client_kwargs["api_key"] = self.api_key
        self._client_kwargs["base_url"] = self.base_url

    def _adopt_openai_credentials(self, api_key: str, base_url: str, *, reason: str) -> bool:
        """Apply a fresh key/base_url to the OpenAI-style kwargs and rebuild the shared client."""
        self.api_key, self.base_url = api_key.strip(), base_url.strip().rstrip("/")
        self._sync_client_kwargs_credentials()
        return self._replace_primary_openai_client(reason=reason)

    def _try_refresh_codex_client_credentials(self, *, force: bool = True) -> bool:
        if self.api_mode != "codex_responses" or self.provider not in {"openai-codex", "xai-oauth"}:
            return False
        # No silent account swap: a non-singleton credential (manual pool entry, explicit api_key=) must not be
        # replaced by the device_code singleton's tokens — the pool's reactive recovery owns that case.
        try:
            from hermes_cli import auth as _auth
            resolve = (
                _auth.resolve_codex_runtime_credentials if self.provider == "openai-codex"
                else _auth.resolve_xai_oauth_runtime_credentials
            )
            singleton_now = resolve(refresh_if_expiring=False)
        except Exception as exc:
            logger.debug("%s singleton read failed: %s", self.provider, exc)
            return False
        singleton_key = str(singleton_now.get("api_key") or "").strip()
        old_key = str(self.api_key or "").strip()
        if singleton_key and old_key and singleton_key != old_key:
            logger.debug(
                "%s singleton tokens differ from the active api_key; skipping singleton force-refresh to avoid "
                "silent account swap. Reactive credential rotation should go through the pool.", self.provider,
            )
            return False
        try:
            creds = resolve(force_refresh=force)
        except Exception as exc:
            logger.debug("%s credential refresh failed: %s", self.provider, exc)
            return False
        api_key, base_url = creds.get("api_key"), creds.get("base_url")
        if not _valid_credential_pair(api_key, base_url):
            return False
        # No NEW token minted (the resolver returns the same stale token when refresh fails) → False.
        if old_key and api_key.strip() == old_key:
            logger.debug("%s credential refresh returned the same token; refresh likely failed silently", self.provider)
            return False
        return self._adopt_openai_credentials(api_key, base_url, reason=f"{self.provider}_credential_refresh")

    def _try_refresh_nous_client_credentials(self, *, force: bool = True, require_account: str | None = None) -> bool:
        # Portal serves anthropic/* on the native Messages route, so either client kind may hold the expiring JWT.
        if self.provider != "nous" or self.api_mode not in ("chat_completions", "anthropic_messages"):
            return False
        try:
            from hermes_cli.auth import resolve_nous_runtime_credentials
            timeout = env_float("HERMES_NOUS_TIMEOUT_SECONDS", 15)
            # Pass the bearer that just 401'd so a refresh already done by a sibling process is
            # adopted instead of rotating the grant again.
            creds = resolve_nous_runtime_credentials(
                timeout_seconds=timeout, force_refresh=force, stale_access_token=self.api_key or None,
            )
        except Exception as exc:
            logger.debug("Nous credential refresh failed: %s", exc)
            return False
        api_key, base_url = creds.get("api_key"), creds.get("base_url")
        if not _valid_credential_pair(api_key, base_url):
            return False
        if str(api_key).strip() == str(self.api_key or "").strip():
            return False  # store holds the same key: nothing to adopt, no client rebuild
        if require_account is not None:
            try:
                from hermes_cli.auth_constants import _decode_jwt_claims
                new_account = _decode_jwt_claims(str(api_key)).get("sub")
            except Exception:
                new_account = None
            if str(new_account or "") != require_account:
                logger.info(
                    "Nous pre-expiry adoption skipped: the store's key belongs to a different account "
                    "than the one in hand; keeping the current credential."
                )
                return False
        if self.api_mode == "anthropic_messages":
            self.api_key, self.base_url = api_key.strip(), base_url.strip().rstrip("/")
            self._anthropic_api_key, self._anthropic_base_url = self.api_key, self.base_url
            self._rebuild_anthropic_client()
            return True
        # Nous requests should not inherit OpenRouter-only attribution headers.
        self._client_kwargs.pop("default_headers", None)
        return self._adopt_openai_credentials(api_key, base_url, reason="nous_credential_refresh")

    # Adopt a fresh key this many seconds before the one in hand expires. Wider than the store's
    # own refresh skew (120 s) so the keepalive has normally already minted the replacement.
    _NOUS_KEY_ADOPT_SKEW_S = 180

    def _adopt_nous_key_before_expiry(self) -> bool:
        """Swap in a fresh Nous agent key BEFORE the one in hand expires, so the request never 401s.

        The agent key is a JWT; its ``exp`` is read locally (no network). Inside the skew the store
        is re-read under the auth-store lock: the keepalive thread normally holds a fresh key already
        (adopt, no POST), otherwise ONE refresh runs and every peer adopts its result. Before this,
        every agent in a process learned about the hourly expiry from its own 401, all in the same
        minute (620 in one 200-subagent run), and the pool benched the sole credential for all of
        them. Returns True when a new key was adopted.

        Identity guard: the replacement must belong to the SAME account (``sub`` claim) as the key
        in hand. The store holds the logged-in singleton; an agent running on an explicitly supplied
        or pool-selected key for a different account must never be silently moved onto it (that
        changes who is billed). When either side lacks a ``sub`` nothing is adopted here; the
        reactive 401 path is unchanged.
        """
        if getattr(self, "provider", "") != "nous" or not getattr(self, "api_key", None):
            return False
        try:
            from hermes_cli.auth_constants import _decode_jwt_claims
            claims = _decode_jwt_claims(self.api_key)
        except Exception:
            return False
        exp, account = claims.get("exp"), claims.get("sub")
        if not account or not isinstance(exp, (int, float)) or exp - time.time() > self._NOUS_KEY_ADOPT_SKEW_S:
            return False
        return self._try_refresh_nous_client_credentials(force=False, require_account=str(account))


    def _resolve_env_credentials(self) -> Optional[tuple]:
        """Current ``.env``-sourced ``(api_key, base_url, default_base)`` for this provider, or ``None``.

        Covers registry api-key providers and named custom providers with ``key_env``.
        """
        try:
            from agent.credential_pool import get_env_prefer_dotenv
            from hermes_cli.auth import PROVIDER_REGISTRY
        except ImportError:
            return None
        pconfig = PROVIDER_REGISTRY.get(self.provider)
        if pconfig and getattr(pconfig, "auth_type", "") == "api_key" and getattr(pconfig, "api_key_env_vars", ()):
            # First non-empty env var wins (lazy: later vars are not read).
            api_key = next((k for k in (get_env_prefer_dotenv(v).strip() for v in pconfig.api_key_env_vars) if k), "")
            if not api_key:
                return None
            url_var = pconfig.base_url_env_var
            env_url = get_env_prefer_dotenv(url_var).strip().rstrip("/") if url_var else ""
            default_base = (pconfig.inference_base_url or "").strip().rstrip("/")
            base_url = env_url or default_base
            if self.provider == "actual":
                from hermes_cli.auth import normalize_actual_base_url
                from hermes_cli.runtime_provider import _config_base_url_for_provider, _get_model_config
                configured_base = _config_base_url_for_provider(_get_model_config(), "actual")
                base_url = normalize_actual_base_url(configured_base or base_url)
            elif self.provider in ("kimi-coding", "zai"):
                from hermes_cli import auth as _auth
                resolver = _auth._resolve_kimi_base_url if self.provider == "kimi-coding" else _auth._resolve_zai_base_url
                base_url = resolver(api_key, pconfig.inference_base_url, env_url).rstrip("/")
        elif self.provider == "custom":
            # Named custom provider: identity in config, credential in key_env; no key_env → nothing to watch.
            try:
                from hermes_cli.runtime_provider import _get_named_custom_provider
            except ImportError:
                return None
            custom_provider = _get_named_custom_provider(getattr(self, "requested_provider", "") or "")
            key_env = str((custom_provider or {}).get("key_env") or "").strip()
            api_key = get_env_prefer_dotenv(key_env).strip() if key_env else ""
            if not custom_provider or not api_key:
                return None
            # Custom providers pin base_url in config, so only key edits are adopted here.
            base_url = default_base = str(custom_provider.get("base_url") or "").strip().rstrip("/")
        else:
            return None
        if not base_url:
            return None
        return api_key, base_url, default_base

    def _should_adopt_env_credentials(self, api_key: str, base_url: str, default_base: str) -> bool:
        """Adopt only env *edits* (resolved value changed since last look), never drift from ``self.*``:
        pool rotation/failover and a config ``model.base_url`` legitimately move the session."""
        prev = getattr(self, "_env_creds_seen", None)
        current_base = (self.base_url or "").strip().rstrip("/")
        unchanged = base_url == current_base and api_key == self.api_key
        if prev is None:
            # First look: adopt only the boot-default case (anything else is unattributable on turn one), and
            # never stomp a pool-rotated key.
            pool_rotated = (
                api_key != self.api_key and getattr(self, "_credential_pool", None) is not None
                and getattr(self, "_credential_pool_entry_id", None)
            )
            return current_base == default_base and not unchanged and not pool_rotated
        # Adopt only while the session still runs on the registry default or the previously-seen env value.
        return (base_url, api_key) != prev and current_base in {default_base, prev[0]} and not unchanged

    def _try_refresh_env_client_credentials(self) -> bool:
        """Adopt ``~/.hermes/.env`` credential/base-url edits at the turn boundary (a Settings save updates ``.env``
        but a live worker keeps init-time values). Adoption rule: ``_should_adopt_env_credentials``.

        Covers api-key registry providers and named custom providers with a ``key_env`` (#67935) — the
        latter resolve to ``provider="custom"`` with no registry entry, so they are matched through the
        runtime provider's config lookup instead.
        """
        if self.api_mode != "chat_completions" or getattr(self, "_fallback_activated", False):
            return False
        resolved = self._resolve_env_credentials()
        if resolved is None:
            return False
        api_key, base_url, default_base = resolved
        if not self._should_adopt_env_credentials(api_key, base_url, default_base):
            self._env_creds_seen = (base_url, api_key)
            return False
        from hermes_cli.route_identity import normalize_route_base_url
        route_changed = normalize_route_base_url(self.base_url) != normalize_route_base_url(base_url)
        prior_api_key, prior_base_url = self.api_key, self.base_url
        prior_client_kwargs = dict(self._client_kwargs)
        self.api_key, self.base_url = api_key, base_url
        self._sync_client_kwargs_credentials()
        # A base-url change moves the route: recompute TLS material and default headers.
        self._reapply_route_client_config(route_changed=route_changed)
        if not self._replace_primary_openai_client(reason="env_credential_refresh"):
            # Leave the baseline un-advanced (retry next turn); roll the agent back to match the live client.
            self.api_key, self.base_url = prior_api_key, prior_base_url
            self._client_kwargs.clear()
            self._client_kwargs.update(prior_client_kwargs)
            return False
        # Rebind the pool entry id to the adopted key, or the next 429 quarantines the wrong credential.
        try:
            from agent.agent_runtime_helpers import sync_credential_pool_entry_id
            sync_credential_pool_entry_id(self)
        except Exception:
            logger.debug("sync_credential_pool_entry_id after env refresh failed", exc_info=True)
        self._env_creds_seen = (base_url, api_key)
        logger.info("Applied updated .env credentials for %s: endpoint %s", self.provider, self.base_url)
        return True

    def _try_refresh_vertex_client_credentials(self) -> bool:
        """Re-mint the Vertex OAuth2 token (~1h TTL; long sessions 401 on the expired bearer) and rebuild the client."""
        if self.api_mode != "chat_completions" or self.provider != "vertex":
            return False
        try:
            from agent.vertex_adapter import get_vertex_config
            token, base_url = get_vertex_config()
        except Exception as exc:
            logger.debug("Vertex credential refresh failed: %s", exc)
            return False
        ok = _valid_credential_pair(token, base_url) and self._adopt_openai_credentials(
            token, base_url, reason="vertex_credential_refresh",
        )
        if ok:
            logger.info("Vertex AI OAuth token refreshed")
        return ok

    def _apply_copilot_token(self, token: str, enterprise_base_url: Any, *, reason: str) -> bool:
        self.api_key = token
        if enterprise_base_url:
            self.base_url = enterprise_base_url.rstrip("/")
        self._sync_client_kwargs_credentials()
        self._apply_client_headers_for_base_url(str(self.base_url or ""))
        return self._replace_primary_openai_client(reason=reason)

    def _try_refresh_copilot_client_credentials(self) -> bool:
        """Refresh Copilot credentials and rebuild the shared OpenAI client (caller enforces the single-shot guard).

        The raw GitHub token is stable; the short-TTL *exchanged* IDE token is what expires mid-turn (``401 IDE
        token expired``), so force a fresh exchange rather than re-resolving the raw token.
        """
        if not self._is_copilot_provider():
            return False
        try:
            from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token, evict_cached_exchanged_token
            new_token, token_source = resolve_copilot_token()
        except Exception as exc:
            logger.debug("Copilot credential refresh failed: %s", exc)
            return False
        new_token, enterprise_base_url = (new_token.strip() if isinstance(new_token, str) else ""), None
        if not new_token:
            return False
        # Fall back to the raw token only if the exchange itself is unavailable.
        try:
            evict_cached_exchanged_token(new_token)
            api_token, exchanged_base_url = get_copilot_api_token(new_token)
            if isinstance(api_token, str) and api_token.strip():
                new_token, enterprise_base_url = api_token.strip(), exchanged_base_url
        except Exception as exc:
            logger.debug("Copilot 401 re-exchange failed, using resolved token: %s", exc)
        ok = self._apply_copilot_token(new_token, enterprise_base_url, reason="copilot_credential_refresh")
        if ok:
            logger.info("Copilot credentials refreshed from %s", token_source)
        return ok

    def _try_recover_stale_copilot_credential(self) -> bool:
        """Force a fresh Copilot token exchange + client rebuild after a 400 (single-shot, caller-guarded).

        Copilot surfaces a stale credential as ``400 model_not_available_for_integrator`` / ``model_not_supported``
        — typically a raw ``ghu_`` token cached when the startup exchange degraded (restricted integrator allowlist).
        """
        if not self._is_copilot_provider():
            return False
        try:
            from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token, evict_cached_exchanged_token
            raw_token, token_source = resolve_copilot_token()
            if not isinstance(raw_token, str) or not raw_token.strip():
                return False
            raw_token = raw_token.strip()
            # Evict the cached (possibly degraded/raw) exchanged token so the exchange hits the network.
            evict_cached_exchanged_token(raw_token)
            api_token, enterprise_base_url = get_copilot_api_token(raw_token)
        except Exception as exc:
            logger.debug("Copilot stale-credential recovery failed: %s", exc)
            return False
        if not isinstance(api_token, str) or not api_token.strip():
            return False
        # Exchange STILL degraded to the raw token: a rebuild won't help — don't burn the single-shot retry.
        if api_token == raw_token and not enterprise_base_url:
            logger.warning(
                "Copilot stale-credential recovery: exchange still degraded to raw token; skipping retry "
                "(network/exchange endpoint unavailable)."
            )
            return False
        ok = self._apply_copilot_token(api_token.strip(), enterprise_base_url, reason="copilot_stale_credential_recovery")
        if ok:
            logger.info("Copilot credentials re-exchanged after stale-credential 400 (source=%s)", token_source)
        return ok

    def _try_refresh_anthropic_client_credentials(self) -> bool:
        # Only native Anthropic rotates OAuth tokens; other anthropic_messages providers (MiniMax, Alibaba, ...)
        # and Azure use static keys — a refresh would pick up the ~/.claude OAuth token and break auth.
        if (
            self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key")
            or self.provider != "anthropic"
            or base_url_host_matches(getattr(self, "_anthropic_base_url", "") or "", "azure.com")
        ):
            return False
        try:
            from agent.anthropic_credentials import resolve_anthropic_token
            new_token = resolve_anthropic_token()
        except Exception as exc:
            logger.debug("Anthropic credential refresh failed: %s", exc)
            return False
        new_token = new_token.strip() if isinstance(new_token, str) else ""
        if not new_token or new_token == self._anthropic_api_key:
            return False
        with suppress(Exception):
            self._anthropic_client.close()
        try:
            base_url = getattr(self, "_anthropic_base_url", None)
            self._anthropic_client = self._build_direct_anthropic_client(new_token, base_url)
        except Exception as exc:
            logger.warning("Failed to rebuild Anthropic client after credential refresh: %s", exc)
            return False
        self._anthropic_api_key, self._is_anthropic_oauth = new_token, self._anthropic_oauth_flag(new_token)
        return True

    # ------------------------------------------------------------------ route-derived client config
    def _apply_client_headers_for_base_url(self, base_url: str, *, apply_user_headers: bool = True) -> None:
        for host, build in _ROUTE_DEFAULT_HEADERS:
            if base_url_host_matches(base_url, host):
                self._client_kwargs["default_headers"] = build(self, base_url)
                break
        else:
            # No URL-specific headers — fall back to profile.default_headers, else clear.
            self._client_kwargs.pop("default_headers", None)
            with suppress(Exception):
                from providers import get_provider_profile
                profile = get_provider_profile(self.provider)
                if profile and profile.default_headers and (profile_headers := dict(profile.default_headers)):
                    self._client_kwargs["default_headers"] = profile_headers
        # User overrides win over URL/profile defaults for the same route; a swap to another endpoint must not
        # inherit them.
        if apply_user_headers:
            self._apply_user_default_headers()
        # Per-provider extra_headers last so they survive swaps/rebuilds. SECURITY: may carry credentials; never log.
        if self.api_mode not in ("anthropic_messages", "bedrock_converse"):
            try:
                from hermes_cli.config import apply_custom_provider_extra_headers_to_client_kwargs
                apply_custom_provider_extra_headers_to_client_kwargs(self._client_kwargs, base_url)
            except Exception:
                logger.debug("custom-provider extra_headers skipped", exc_info=True)

    def _apply_user_default_headers(self) -> None:
        """Merge config ``model.default_headers`` onto the OpenAI client (user wins; WAFs rejecting SDK headers).
        Delegates to ``agent.auxiliary_client`` so main and aux clients cannot drift. No-op for Anthropic/Bedrock."""
        if self.api_mode in ("anthropic_messages", "bedrock_converse"):
            return
        from agent.auxiliary_client import _apply_user_default_headers as _merge_user_headers
        merged = _merge_user_headers(self._client_kwargs.get("default_headers"))
        if merged:
            self._client_kwargs["default_headers"] = merged

    def _swap_credential(self, entry) -> bool:
        """Adopt *entry* as the live credential. Returns False, changing nothing, when the entry's
        route cannot serve this conversation's model (a conversation's model is never rewritten by a
        rotation; the caller treats a refused swap as "no entry")."""
        runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
        runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url
        from hermes_cli.providers import is_actual_route
        actual_route = is_actual_route(getattr(self, "provider", ""), runtime_base)
        if actual_route:
            from hermes_cli.auth import normalize_actual_base_url
            runtime_base = normalize_actual_base_url(runtime_base)
        stripped_base = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base
        # Refuse BEFORE any state changes below: a refused swap must leave the agent exactly as it was.
        from hermes_cli.anon_auth import route_can_serve_model
        if not route_can_serve_model(getattr(self, "provider", None), stripped_base, getattr(self, "model", None)):
            logger.info("Credential %s skipped: its route cannot serve model %s", getattr(entry, "id", "?"), self.model)
            return False
        if actual_route:
            self.api_mode = "chat_completions"
            if hasattr(self, "_transport_cache"):
                self._transport_cache.clear()
        self._credential_pool_entry_id = getattr(entry, "id", None)
        from hermes_cli.route_identity import normalize_route_base_url
        route_changed = normalize_route_base_url(self.base_url) != normalize_route_base_url(runtime_base)
        if self.api_mode == "anthropic_messages":
            with suppress(Exception):
                self._anthropic_client.close()
            self._anthropic_api_key, self._anthropic_base_url = runtime_key, stripped_base
            self._anthropic_client = self._build_direct_anthropic_client(runtime_key, self._anthropic_base_url)
            self._is_anthropic_oauth = self._anthropic_oauth_flag(runtime_key)
            self.api_key, self.base_url = runtime_key, stripped_base
            return True
        self.api_key, self.base_url = runtime_key, stripped_base
        # Inlined (not _sync_client_kwargs_credentials): tests call this unbound on a SimpleNamespace agent.
        self._client_kwargs["api_key"] = self.api_key
        self._client_kwargs["base_url"] = self.base_url
        self._reapply_route_client_config(route_changed=route_changed)
        self._replace_primary_openai_client(reason="credential_rotation")
        return True

    def _reapply_route_client_config(self, *, route_changed: bool) -> None:
        """Recompute route-derived client kwargs (TLS material, default headers) for ``self.base_url``.

        Any rebuild that may have moved ``base_url`` must call this or the new endpoint inherits the old config.
        """
        self._client_kwargs.pop("ssl_verify", None)
        self._client_kwargs.pop("ssl_ca_cert", None)
        try:
            from hermes_cli.config import (
                apply_custom_provider_tls_to_client_kwargs, get_compatible_custom_providers, load_config_readonly,
            )
            apply_custom_provider_tls_to_client_kwargs(
                self._client_kwargs, str(self.base_url or ""), get_compatible_custom_providers(load_config_readonly()),
            )
        except Exception:
            logger.debug("custom-provider TLS resolution skipped on credential rotation", exc_info=True)
        self._apply_client_headers_for_base_url(self.base_url, apply_user_headers=not route_changed)

    def _anthropic_messages_create(self, api_kwargs: dict, *, client: Any = None):
        # A supplied request-local client was already refreshed in _create_request_anthropic_client.
        if client is None and self.api_mode == "anthropic_messages":
            self._try_refresh_anthropic_client_credentials()
        # Strips Responses-only kwargs that leak in under an api_mode-flip race.
        from agent.anthropic_adapter import create_anthropic_message
        # on_response: rate-limit + credits state live in response headers, which the parsed Message drops.
        return create_anthropic_message(
            client or self._anthropic_client, api_kwargs, log_prefix=getattr(self, "log_prefix", ""),
            prefer_stream=not bool(getattr(self, "_disable_streaming", False)),
            on_response=self._capture_anthropic_response_headers,
        )

    def _rebuild_anthropic_client(self) -> None:
        """Rebuild the Anthropic client after an interrupt/stale call (Bedrock SDK for bedrock; honors 1M-beta flag)."""
        self._anthropic_client = self._build_anthropic_client_for_key(self._request_anthropic_client_key())