File size: 117,726 Bytes
4df9a41 | 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 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 | """Multi-provider authentication system for Hermes Agent.
- ``ProviderConfig`` / ``PROVIDER_REGISTRY`` describe every known inference provider.
- The auth store (``~/.hermes/auth.json``) holds per-provider state, the credential pool and
suppression markers; ``_auth_store_lock`` / ``_load_auth_store`` / ``_save_auth_store`` are the
only I/O primitives (cross-process flock, atomic 0o600 writes).
- ``resolve_provider()`` picks the active provider via the documented priority chain.
- ``OAUTH_PROVIDER_FLOWS`` maps each OAuth provider to its resolver/status builder; the flows live in
``auth_nous``/``auth_codex``/``auth_xai``/``auth_qwen``/``auth_minimax``/``auth_spotify``/``auth_openrouter`` and are
re-imported here so ``hermes_cli.auth.<name>`` stays the public/patchable surface."""
from __future__ import annotations
import json
import logging
import os
import shutil
import shlex
import threading
import time
import webbrowser # noqa: F401 (tests patch auth_mod.webbrowser.open; same module object)
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass, field
from functools import partial
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Optional, Tuple
from urllib.parse import urlparse
from hermes_cli.config import (
get_hermes_home, get_config_path, read_raw_config, require_readable_config_before_write)
from hermes_constants import OPENROUTER_BASE_URL, hermes_home_key, secure_parent_dir
from agent.credential_persistence import sanitize_borrowed_credential_payload
from utils import atomic_json_write, atomic_yaml_write, env_float, is_truthy_value # noqa: F401 (env_float: agent.credential_pool reads auth_mod.env_float)
from hermes_cli.auth_zai_kimi import ( # noqa: F401 re-exported
KIMI_CODE_BASE_URL, ZAI_ENDPOINTS, _normalize_lmstudio_runtime_base_url, _resolve_kimi_base_url,
_resolve_zai_base_url, detect_zai_endpoint)
from hermes_cli.auth_model_picker import ( # noqa: F401 re-exported
_prompt_model_selection, _save_model_choice)
from hermes_cli.auth_device_flow import ( # noqa: F401 re-exported
_can_open_graphical_browser, _default_verify, _is_remote_session,
_nous_device_auth_timeout_message, _offer_existing_oauth_credentials,
_poll_device_token_generic, _poll_for_token, _print_device_code_instructions,
_print_login_success, _print_loopback_ssh_hint, _prompt_yes_no, _request_device_code,
_resolve_verify, _ssh_user_at_host)
from hermes_cli.auth_oauth_grants import ( # noqa: F401 re-exported
SINGLE_USE_REFRESH_POOL_PROVIDERS, _oauth_heal_clean_marks, _oauth_heal_notices,
consume_oauth_heal_notices, heal_forked_single_use_oauth_grants,
strip_cloned_single_use_oauth_grants)
from hermes_cli.auth_nous import ( # noqa: F401 re-exported
NOUS_SESSION_TERMINAL, NOUS_SESSION_UNKNOWN, NOUS_SESSION_VALID, _ALLOWED_NOUS_INFERENCE_HOSTS,
_agent_key_is_usable, _apply_nous_refreshed_tokens, _assert_nous_inference_jwt_usable,
_compute_nous_auth_status, _format_nous_entitlement_auth_error, _healed_nous_inference_url,
_login_nous, _merge_shared_nous_oauth_state, _migrate_stale_nous_portal_url,
_nous_device_code_login, _nous_inference_env_override, _nous_invoke_jwt_is_usable,
_nous_invoke_jwt_status, _nous_portal_env_override, _nous_shared_store_lock,
_nous_shared_store_path, _pool_first_oauth_status, _quarantine_nous_oauth_state,
_quarantine_nous_pool_entries, _read_shared_nous_state, _refresh_access_token,
_refresh_nous_or_quarantine, _select_nous_invoke_jwt, _sync_nous_pool_from_auth_store,
_token_fingerprint, _try_import_shared_nous_state, _validate_nous_inference_url_from_network,
_write_shared_nous_state, fetch_nous_models, get_nous_auth_status_local,
get_nous_session_validity, persist_nous_credentials, refresh_nous_oauth_from_state,
resolve_nous_runtime_credentials, step_up_nous_billing_scope)
from hermes_cli.auth_minimax import ( # noqa: F401 re-exported
_MINIMAX_OAUTH_ERROR_BODY_LIMIT, _login_minimax_oauth, _minimax_oauth_login, _minimax_pkce_pair,
_minimax_poll_token, _minimax_post_form, _minimax_request_user_code,
_minimax_resolve_token_expiry_unix, _minimax_response_error_text, _minimax_save_auth_state,
_refresh_minimax_oauth_state, build_minimax_oauth_token_provider,
resolve_minimax_oauth_runtime_credentials)
from hermes_cli.auth_xai import ( # noqa: F401 re-exported
_login_xai_oauth, _read_xai_oauth_tokens, _refresh_xai_oauth_tokens, _save_xai_oauth_tokens,
_write_through_xai_oauth_to_global_root, _xai_access_token_is_expiring,
_xai_oauth_device_code_login, _xai_oauth_discovery, _xai_oauth_poll_device_token,
_xai_oauth_request_device_code, _xai_proactive_refresh_skew_seconds,
_xai_validate_inference_base_url, refresh_xai_oauth_pure, resolve_xai_oauth_runtime_credentials)
from hermes_cli.auth_codex import ( # noqa: F401 re-exported
_codex_access_token_is_expiring, _codex_device_code_login, _codex_http_client,
_codex_pool_rate_limit_status, _codex_quota_probe_cache, _codex_usage_probe_url,
_import_codex_cli_tokens, _is_codex_rate_limit_shaped, _login_openai_codex,
_probe_codex_quota_restored, _read_codex_tokens, _refresh_codex_auth_tokens, _save_codex_tokens,
clear_codex_pool_quota_cooldowns, refresh_codex_oauth_pure, resolve_codex_runtime_credentials)
from hermes_cli.auth_spotify import ( # noqa: F401 re-exported
_refresh_spotify_oauth_state, get_spotify_auth_status, login_spotify_command,
resolve_spotify_runtime_credentials)
from hermes_cli.auth_openrouter import _openrouter_pkce_login # noqa: F401 re-exported
from hermes_cli.auth_qwen import ( # noqa: F401 re-exported
_qwen_access_token_is_expiring, _qwen_cli_auth_path, _read_qwen_cli_tokens,
_refresh_qwen_cli_tokens, _save_qwen_cli_tokens, get_qwen_auth_status,
resolve_qwen_runtime_credentials)
from hermes_cli.auth_constants import ( # noqa: F401 re-exported
_decode_jwt_claims, AUTH_STORE_VERSION, AUTH_LOCK_TIMEOUT_SECONDS, DEFAULT_NOUS_PORTAL_URL,
DEFAULT_NOUS_INFERENCE_URL, DEFAULT_NOUS_CLIENT_ID, NOUS_BILLING_MANAGE_SCOPE,
DEFAULT_NOUS_SCOPE, NOUS_DEVICE_CODE_SOURCE, NOUS_AUTH_PATH_INVOKE_JWT,
ACCESS_TOKEN_REFRESH_SKEW_SECONDS, NOUS_INVOKE_JWT_MIN_TTL_SECONDS, DEFAULT_CODEX_BASE_URL,
DEFAULT_XAI_OAUTH_BASE_URL, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_OAUTH_SCOPE,
MINIMAX_OAUTH_GLOBAL_BASE, MINIMAX_OAUTH_CN_BASE, MINIMAX_OAUTH_GLOBAL_INFERENCE,
MINIMAX_OAUTH_CN_INFERENCE, MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, DEFAULT_QWEN_BASE_URL,
DEFAULT_GITHUB_MODELS_BASE_URL, DEFAULT_COPILOT_ACP_BASE_URL, DEFAULT_OLLAMA_CLOUD_BASE_URL,
DEFAULT_ACTUAL_BASE_URL, DEFAULT_ACTUAL_LOCAL_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL,
STEPFUN_STEP_PLAN_CN_BASE_URL, CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL,
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, XAI_OAUTH_CLIENT_ID, XAI_OAUTH_SCOPE,
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, DEFAULT_SPOTIFY_API_BASE_URL, SPOTIFY_DOCS_URL,
DEFAULT_SPOTIFY_SCOPE, SERVICE_PROVIDER_NAMES, LMSTUDIO_NOAUTH_PLACEHOLDER,
ACTUAL_LOCAL_NOAUTH_PLACEHOLDER, CODEX_RATE_LIMITED_CODE, AuthError, _nous_err, httpx)
logger = logging.getLogger(__name__)
try:
import fcntl
except Exception:
fcntl = None
try:
import msvcrt
except Exception:
msvcrt = None
def is_actual_local_base_url(base_url: str) -> bool:
"""Return True for Actual's loopback local API endpoint."""
try:
host = (urlparse(base_url or "").hostname or "").lower().rstrip(".")
except Exception:
return False
return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
def normalize_actual_base_url(base_url: str) -> str:
"""Return Actual's OpenAI-compatible base URL (hosted api.actual.inc or the loopback local server;
both expose a /v1 surface for the selected OpenAI-compatible transport)."""
url = str(base_url or "").strip().rstrip("/")
if not url:
return DEFAULT_ACTUAL_BASE_URL
try:
parsed = urlparse(url)
host = (parsed.hostname or "").lower().rstrip(".")
path = parsed.path.rstrip("/")
except Exception:
return url
if path in {"", "/"} and (host == "api.actual.inc" or is_actual_local_base_url(url)):
return url + "/v1"
return url
# ββ Provider Registry βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@dataclass
class ProviderConfig:
"""Describes a known inference provider."""
id: str
name: str
auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", "api_key", ...
portal_base_url: str = ""
inference_base_url: str = ""
client_id: str = ""
scope: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
api_key_env_vars: tuple = () # API-key providers: env vars to check, in priority order
base_url_env_var: str = "" # optional env var overriding the base URL
def _api_key_provider(
id: str, name: str, inference_base_url: str, api_key_env_vars: tuple,
base_url_env_var: str = "", auth_type: str = "api_key") -> ProviderConfig:
"""Compact constructor for the common env-var-keyed provider shape."""
return ProviderConfig(
id=id, name=name, auth_type=auth_type, inference_base_url=inference_base_url,
api_key_env_vars=api_key_env_vars, base_url_env_var=base_url_env_var)
# Registry rows in priority order (resolve_provider() scans api_key rows in this order). A tuple
# row is ``_api_key_provider(id, name, inference_base_url, api_key_env_vars[, base_url_env_var
# [, auth_type]])``; OAuth / bespoke rows are full ``ProviderConfig`` objects.
_REGISTRY_ROWS: Tuple[Any, ...] = (
ProviderConfig(
"nous", "Nous Portal", "oauth_device_code", portal_base_url=DEFAULT_NOUS_PORTAL_URL,
inference_base_url=DEFAULT_NOUS_INFERENCE_URL, client_id=DEFAULT_NOUS_CLIENT_ID,
scope=DEFAULT_NOUS_SCOPE),
ProviderConfig("openai-codex", "OpenAI Codex", "oauth_external", inference_base_url=DEFAULT_CODEX_BASE_URL),
("openai-api", "OpenAI API", "https://api.openai.com/v1", ("OPENAI_API_KEY",), "OPENAI_BASE_URL"),
ProviderConfig(
"xai-oauth", "xAI Grok OAuth (SuperGrok / Premium+)", "oauth_external",
inference_base_url=DEFAULT_XAI_OAUTH_BASE_URL),
ProviderConfig("qwen-oauth", "Qwen OAuth", "oauth_external", inference_base_url=DEFAULT_QWEN_BASE_URL),
("lmstudio", "LM Studio", "http://127.0.0.1:1234/v1", ("LM_API_KEY",), "LM_BASE_URL"),
("copilot", "GitHub Copilot", DEFAULT_GITHUB_MODELS_BASE_URL,
("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), "COPILOT_API_BASE_URL"),
ProviderConfig(
"copilot-acp", "GitHub Copilot ACP", "external_process",
inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, base_url_env_var="COPILOT_ACP_BASE_URL"),
("gemini", "Google AI Studio", "https://generativelanguage.googleapis.com/v1beta",
("GOOGLE_API_KEY", "GEMINI_API_KEY"), "GEMINI_BASE_URL"),
("zai", "Z.AI / GLM", "https://api.z.ai/api/paas/v4",
("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "GLM_BASE_URL"),
# Legacy platform.moonshot.ai keys use this endpoint (OpenAI-compat); sk-kimi- (Kimi Code)
# keys are auto-redirected to api.kimi.com/coding by _resolve_kimi_base_url().
("kimi-coding", "Kimi / Moonshot", "https://api.moonshot.ai/v1",
("KIMI_API_KEY", "KIMI_CODING_API_KEY"), "KIMI_BASE_URL"),
("kimi-coding-cn", "Kimi / Moonshot (China)", "https://api.moonshot.cn/v1", ("KIMI_CN_API_KEY",)),
("stepfun", "StepFun Step Plan", STEPFUN_STEP_PLAN_INTL_BASE_URL, ("STEPFUN_API_KEY",), "STEPFUN_BASE_URL"),
("arcee", "Arcee AI", "https://api.arcee.ai/api/v1", ("ARCEEAI_API_KEY",), "ARCEE_BASE_URL"),
("gmi", "GMI Cloud", "https://api.gmi-serving.com/v1", ("GMI_API_KEY",), "GMI_BASE_URL"),
("actual", "Actual Computer", DEFAULT_ACTUAL_BASE_URL, ("ACTUAL_API_KEY",), "ACTUAL_BASE_URL"),
("minimax", "MiniMax", "https://api.minimax.io/anthropic", ("MINIMAX_API_KEY",), "MINIMAX_BASE_URL"),
ProviderConfig(
"minimax-oauth", "MiniMax (OAuth \u00b7 minimax.io)", "oauth_minimax",
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE,
client_id=MINIMAX_OAUTH_CLIENT_ID, scope=MINIMAX_OAUTH_SCOPE,
extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE,
"cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE}),
# CLAUDE_CODE_OAUTH_TOKEN is NOT an API key despite auth_type="api_key": `claude setup-token`
# yields an `sk-ant-oat01β¦` OAuth token (401s as x-api-key, 429s as bare Bearer). It stays in
# this tuple because the tuple doubles as the credential-DISCOVERY list
# (agent/credential_pool.py builds its env scan from it); the adapter routes it down the OAuth
# path by prefix. Only ANTHROPIC_API_KEY and ANTHROPIC_TOKEN are usable as literal API keys.
("anthropic", "Anthropic", "https://api.anthropic.com",
("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), "ANTHROPIC_BASE_URL"),
("alibaba", "Qwen Cloud", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
("DASHSCOPE_API_KEY",), "DASHSCOPE_BASE_URL"),
("alibaba-coding-plan", "Alibaba Cloud (Coding Plan)", "https://coding-intl.dashscope.aliyuncs.com/v1",
("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY"), "ALIBABA_CODING_PLAN_BASE_URL"),
("minimax-cn", "MiniMax (China)", "https://api.minimaxi.com/anthropic", ("MINIMAX_CN_API_KEY",),
"MINIMAX_CN_BASE_URL"),
("deepseek", "DeepSeek", "https://api.deepseek.com/v1", ("DEEPSEEK_API_KEY",), "DEEPSEEK_BASE_URL"),
("xai", "xAI", "https://api.x.ai/v1", ("XAI_API_KEY",), "XAI_BASE_URL"),
("nvidia", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", ("NVIDIA_API_KEY",), "NVIDIA_BASE_URL"),
("ai-gateway", "Vercel AI Gateway", "https://ai-gateway.vercel.sh/v1", ("AI_GATEWAY_API_KEY",),
"AI_GATEWAY_BASE_URL"),
("opencode-zen", "OpenCode Zen", "https://opencode.ai/zen/v1", ("OPENCODE_ZEN_API_KEY",),
"OPENCODE_ZEN_BASE_URL"),
# OpenCode Go mixes API surfaces by model (GLM/Kimi: OpenAI chat under /v1; MiniMax and
# Qwen 3.7: Anthropic Messages under /v1/messages). Keep the base at /v1; api_mode is per-model.
("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", ("OPENCODE_GO_API_KEY",),
"OPENCODE_GO_BASE_URL"),
# Deliberately NO api_key_env_vars: the free tier is served anonymously (any unrecognized bearer
# is a 401), so there is no secret to configure. Select via `hermes model` / `/model free`.
("opencode-free", "OpenCode Free", "https://opencode.ai/zen/v1", ()),
("kilocode", "Kilo Code", "https://api.kilo.ai/api/gateway", ("KILOCODE_API_KEY",), "KILOCODE_BASE_URL"),
("huggingface", "Hugging Face", "https://router.huggingface.co/v1", ("HF_TOKEN",), "HF_BASE_URL"),
("xiaomi", "Xiaomi MiMo", "https://api.xiaomimimo.com/v1", ("XIAOMI_API_KEY",), "XIAOMI_BASE_URL"),
("tencent-tokenhub", "Tencent TokenHub", "https://tokenhub.tencentmaas.com/v1", ("TOKENHUB_API_KEY",),
"TOKENHUB_BASE_URL"),
("tencent-tokenplan", "Tencent TokenPlan", "https://api.lkeap.cloud.tencent.com/plan/anthropic",
("TOKENPLAN_API_KEY",), "TOKENPLAN_BASE_URL"),
("ollama-cloud", "Ollama Cloud", DEFAULT_OLLAMA_CLOUD_BASE_URL, ("OLLAMA_API_KEY",), "OLLAMA_BASE_URL"),
("bedrock", "AWS Bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", (), "BEDROCK_BASE_URL",
"aws_sdk"),
# No static inference_base_url: Vertex's endpoint is computed per request from project_id +
# region (agent/vertex_adapter.py build_vertex_base_url), not a fixed host.
("vertex", "Google Vertex AI", "", (), "", "vertex"),
("azure-foundry", "Azure Foundry", "", ("AZURE_FOUNDRY_API_KEY",), "AZURE_FOUNDRY_BASE_URL"))
PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
p.id: p for p in (r if isinstance(r, ProviderConfig) else _api_key_provider(*r) for r in _REGISTRY_ROWS)
}
# Providers handled outside the registry: copilot/kimi/zai have bespoke token refresh here;
# openrouter/custom are aggregator/user-supplied and runtime_provider relies on
# ``openrouter not in PROVIDER_REGISTRY``.
_REGISTRY_PLUGIN_SKIP = frozenset({"copilot", "kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"})
def _register_plugin_provider(pp: Any) -> None:
"""Auto-register one providers/ profile (plugins/model-providers/<name>/) not declared above.
External-process (ACP) providers have no API-key env vars; registering them is what lets an
out-of-tree provider pass ``resolve_provider()``'s known-provider gate ("Unknown provider")."""
if pp.auth_type == "external_process":
pconfig = ProviderConfig(
pp.name, pp.display_name or pp.name, "external_process", inference_base_url=pp.base_url)
elif pp.auth_type == "api_key" and pp.env_vars and pp.name not in _REGISTRY_PLUGIN_SKIP:
is_url = lambda v: v.endswith("_BASE_URL") or v.endswith("_URL") # noqa: E731
pconfig = _api_key_provider(
pp.name, pp.display_name or pp.name, pp.base_url,
tuple(v for v in pp.env_vars if not is_url(v)) or pp.env_vars,
next((v for v in pp.env_vars if is_url(v)), None) or "")
else:
return
PROVIDER_REGISTRY[pp.name] = pconfig
for alias in pp.aliases: # so resolve_provider() resolves them too
PROVIDER_REGISTRY.setdefault(alias, pconfig)
try:
from providers import list_providers as _list_providers_for_registry
for _pp in _list_providers_for_registry():
if _pp.name not in PROVIDER_REGISTRY:
_register_plugin_provider(_pp)
except Exception:
pass
def get_anthropic_key() -> str:
"""First usable Anthropic credential (``.env`` preferred over a stale shell export), or ``""``.
Order mirrors ``PROVIDER_REGISTRY["anthropic"].api_key_env_vars``.
Checks both the ``.env`` file and the process environment, preferring ``~/.hermes/.env`` so a deliberate
key rotation isn't shadowed by a stale shell export (matches the api-key resolution path β see #20591).
"""
from hermes_cli.config import get_env_value_prefer_dotenv
env_vars = PROVIDER_REGISTRY["anthropic"].api_key_env_vars
return next((v for v in (get_env_value_prefer_dotenv(var) or "" for var in env_vars) if v), "")
# ββ Secret validation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_PLACEHOLDER_SECRET_VALUES = {
"*", "**", "***", "changeme", "your_api_key", "your_api_key_here", "your-api-key",
"placeholder", "example", "dummy", "null", "none"}
def has_usable_secret(value: Any, *, min_length: int = 4) -> bool:
"""Return True when a configured secret looks usable, not empty/placeholder."""
if not isinstance(value, str):
return False
cleaned = value.strip()
return len(cleaned) >= min_length and cleaned.lower() not in _PLACEHOLDER_SECRET_VALUES
# Known API-key prefixes per provider. Only listed providers get prefix validation; everyone else
# is fail-open. Keeps an obviously malformed key in .env (truncated paste, wrong provider's key)
# from silently shadowing a valid credential-pool entry and producing opaque 401s.
# See #93593.
KNOWN_PROVIDER_KEY_PREFIXES: Dict[str, tuple] = {
"openrouter": ("sk-or-",), # all OpenRouter keys are sk-or-... (currently sk-or-v1-)
}
def _usable_declared_secret(provider_id: str, value: Any, source: str) -> Optional[str]:
"""*value* stripped when it is a usable, prefix-valid secret; None (after warning on a provable
prefix mismatch, so it never shadows a later credential source) otherwise. Providers without a
declared prefix are fail-open."""
val = str(value or "").strip()
if not has_usable_secret(val):
return None
prefixes = KNOWN_PROVIDER_KEY_PREFIXES.get(provider_id)
if prefixes and not any(val.startswith(p) for p in prefixes):
logger.warning(
"Ignoring %s for provider %r: value does not match the expected key "
"prefix (%s). Falling back to the next credential source. Fix or "
"remove the malformed key to silence this warning.",
source, provider_id, " or ".join(prefixes))
return None
return val
def _model_level_key_env(provider_id: str) -> str:
"""``model.key_env`` when config.yaml's main model targets *provider_id*, else ``""``.
The Desktop settings UI saves registry-provider keys as a credential pointer
(``model.key_env`` β ``$HERMES_HOME/.env``) instead of the registry's canonical env var,
so credential resolution must consult it (#106336).
"""
try:
from hermes_cli.config import load_config
model_cfg = (load_config() or {}).get("model")
except Exception:
return ""
if not isinstance(model_cfg, dict):
return ""
if str(model_cfg.get("provider") or "").strip().lower() != provider_id:
return ""
return str(model_cfg.get("key_env") or model_cfg.get("api_key_env") or "").strip()
def _resolve_api_key_provider_secret(provider_id: str, pconfig: ProviderConfig) -> tuple[str, str]:
"""Resolve an API-key provider's token and indicate where it came from."""
if provider_id == "copilot":
# The dedicated copilot auth module does proper token validation/exchange.
try:
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
token, source = resolve_copilot_token()
if token:
api_token, _base_url = get_copilot_api_token(token)
return api_token, source
except ValueError as exc:
logger.warning("Copilot token validation failed: %s", exc)
except Exception:
pass
return "", ""
# Prefer ~/.hermes/.env over os.environ so a deliberate key rotation in .env isn't shadowed by
# a stale shell export inherited from a parent process (Codex CLI, test runners, etc.).
from hermes_cli.config import get_env_value_prefer_dotenv
# Desktop-saved credential pointer: the settings UI persists registry-provider keys as
# model.key_env β $HERMES_HOME/.env (e.g. HERMES_CUSTOM_LMSTUDIO_API_KEY) while keeping
# model.provider on the registry id, so the pointer must be honored here or the UI-saved
# key is silently ignored and lmstudio falls through to its no-auth placeholder (#106336).
key_env = _model_level_key_env(provider_id)
if key_env:
val = _usable_declared_secret(provider_id, get_env_value_prefer_dotenv(key_env), key_env)
if val:
return val, key_env
for env_var in pconfig.api_key_env_vars:
val = _usable_declared_secret(provider_id, get_env_value_prefer_dotenv(env_var), env_var)
if val:
# A provably malformed key (declared prefix mismatch) must not shadow a valid credential-pool
# entry (#93593). Warn and keep looking instead of returning it.
return val, env_var
# Fallback: credential pool (e.g. zai key stored via auth.json). Prefer the pool's own
# selection (peek) but try the rest too so one malformed entry doesn't block a valid one.
pool_source = f"credential_pool:{provider_id}"
try:
from agent.credential_pool import load_pool
pool = load_pool(provider_id)
if pool and pool.has_credentials():
entry = pool.peek()
candidates = [entry] if entry is not None else []
try:
for extra in pool.entries():
if extra is not None and all(extra is not c for c in candidates):
candidates.append(extra)
except Exception:
pass
for entry in candidates:
key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "")
val = _usable_declared_secret(provider_id, key, pool_source)
if val:
return val, pool_source
except Exception:
pass
return "", ""
# ββ Error formatting (AuthError itself lives in auth_constants) βββββββββββββββββββββββββββββββββββββ
def is_rate_limited_auth_error(error: Exception) -> bool:
"""True when an :class:`AuthError` is upstream rate-limiting / quota: transient, and
re-authenticating cannot fix it, so callers should say "retry later", not ``hermes auth``."""
return (isinstance(error, AuthError) and not error.relogin_required
and error.code == CODEX_RATE_LIMITED_CODE)
# Entitlement failures: Nous gets a Portal-aware message; other providers a fixed generic one (or
# the raw error when no generic text exists for the code).
_GENERIC_ENTITLEMENT_MESSAGES = {
"subscription_required": "No active paid subscription found. Please purchase/activate a subscription, then retry.",
"insufficient_credits": "Subscription credits are exhausted. Top up/renew credits, then retry."}
_ENTITLEMENT_ERROR_CODES = frozenset(_GENERIC_ENTITLEMENT_MESSAGES) | {
"subscription_expired", "no_usable_credits", "account_missing", "member_spend_cap_exceeded"}
def format_auth_error(error: Exception) -> str:
"""Map auth failures to concise user-facing guidance."""
if not isinstance(error, AuthError) or is_rate_limited_auth_error(error):
# Rate-limit / quota errors are not credential problems: never append "re-authenticate".
return str(error)
if error.relogin_required:
return f"{error} Run `hermes model` to re-authenticate."
if error.code in _ENTITLEMENT_ERROR_CODES:
if error.provider == "nous":
return _format_nous_entitlement_auth_error(error)
generic = _GENERIC_ENTITLEMENT_MESSAGES.get(error.code)
if generic:
return generic
if error.code == "temporarily_unavailable":
return f"{error} Please retry in a few seconds."
return str(error)
def _nonempty_str(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
# ββ Auth Store β persistence layer for ~/.hermes/auth.json ββββββββββββββββββββββββββββββββββββββββββ
def _auth_file_path() -> Path:
path = get_hermes_home() / "auth.json"
# Seat belt: under pytest, refuse to touch the real user's auth store (tests that forgot to
# monkeypatch HERMES_HOME or escaped the hermetic conftest). In production: one dict lookup.
if (os.environ.get("PYTEST_CURRENT_TEST")
and _same_path(path, Path.home() / ".hermes" / "auth.json")):
raise RuntimeError(
f"Refusing to touch real user auth store during test run: {path}. "
"Set HERMES_HOME to a tmp_path in your test fixture, or run "
"via scripts/run_tests.sh for hermetic CI-parity env.")
return path
def _global_auth_file_path() -> Optional[Path]:
"""Global-root auth.json in profile mode; None when profile and global root are the same dir.
Read-only fallback path, so no pytest seat belt here (it lives on ``_auth_file_path()``)."""
try:
from hermes_constants import get_default_hermes_root
global_root = get_default_hermes_root()
except Exception:
return None
return None if _same_path(get_hermes_home(), global_root) else global_root / "auth.json"
def _load_global_auth_store() -> Dict[str, Any]:
"""Load the global-root auth store (read-only fallback, mtime-memoised); ``{}`` when absent or
unreadable β a malformed global store must never break profile reads."""
global _global_auth_store_cache
global_path = _global_auth_file_path()
if global_path is None or not global_path.exists():
_global_auth_store_cache = None
return {}
try:
cache_key: Optional[Tuple[str, int]] = (
str(global_path.resolve(strict=False)), global_path.stat().st_mtime_ns)
except Exception:
cache_key = None
cached = _global_auth_store_cache
if cache_key is not None and cached is not None and cached[:2] == cache_key:
return cached[2]
if os.environ.get("PYTEST_CURRENT_TEST") and os.environ.get("HOME"):
real_root = Path(os.environ["HOME"]) / ".hermes" / "auth.json"
try:
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
_global_auth_store_cache = None
return {}
except Exception:
pass
try:
store = _load_auth_store(global_path)
except Exception:
_global_auth_store_cache = None
return {}
if cache_key is not None:
_global_auth_store_cache = (*cache_key, store)
return store
_auth_target_lock_holders: Dict[str, threading.local] = {}
_auth_target_lock_holders_guard = threading.Lock()
def _same_path(left: Path, right: Path) -> bool:
try:
return left.resolve(strict=False) == right.resolve(strict=False)
except Exception:
return left == right
def _is_same_auth_store(left: Path, right: Path) -> bool:
"""True when two auth paths name ONE store rather than two copies.
``_same_path`` resolves symlinks and ``..``; ``samefile`` adds hardlinks and bind-mounts
(same inode under two resolved names). Used by the forked-grant heal: a shared store has
no "other side" to consolidate.
See #101356.
"""
if _same_path(left, right):
return True
try:
return left.samefile(right)
except OSError:
return False
def _resolved_key(path: Path) -> str:
"""Canonical string for *path* (resolved when possible) used as a cache / lock-holder key."""
try:
return str(path.resolve(strict=False))
except Exception:
return str(path)
def _auth_lock_holder_for(target_path: Path) -> threading.local:
"""Return a reentrancy tracker keyed to one canonical auth-store path."""
with _auth_target_lock_holders_guard:
return _auth_target_lock_holders.setdefault(_resolved_key(target_path), threading.local())
def _kernel_lock(lock_file: Any, acquire: bool) -> None:
"""Non-blocking exclusive flock (fcntl) or 1-byte msvcrt lock at offset 0; ``acquire=False`` releases."""
if fcntl:
fcntl.flock(lock_file.fileno(), (fcntl.LOCK_EX | fcntl.LOCK_NB) if acquire else fcntl.LOCK_UN)
else:
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK if acquire else msvcrt.LK_UNLCK, 1)
@contextmanager
def _file_lock(
lock_path: Path, holder: threading.local, timeout_seconds: float, timeout_message: str):
"""Cross-process advisory flock helper, reentrant per-thread via ``holder.depth``.
Falls back to a depth-only guard when neither ``fcntl`` nor ``msvcrt`` is available. Callers
supply their own ``threading.local`` so independent locks (profile store vs global root vs the
shared Nous store) track reentrancy separately."""
if getattr(holder, "depth", 0) > 0:
holder.depth += 1
try:
yield
finally:
holder.depth -= 1
return
lock_path.parent.mkdir(parents=True, exist_ok=True)
with ExitStack() as stack:
lock_file = None
if fcntl is not None or msvcrt is not None:
# msvcrt.locking needs a non-empty file with the pointer at 0. This convenience write can
# race another holder's byte-range lock and raise PermissionError (reproduced with 20
# concurrent processes on Windows); losing the race just means the file already has
# content, so swallow it.
if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):
try:
lock_path.write_text(" ", encoding="utf-8")
except (OSError, PermissionError):
pass
lock_file = stack.enter_context(lock_path.open("r+" if msvcrt else "a+", encoding="utf-8"))
deadline = time.monotonic() + max(1.0, timeout_seconds)
while True:
try:
_kernel_lock(lock_file, True)
break
except (BlockingIOError, OSError, PermissionError):
if time.monotonic() >= deadline:
raise TimeoutError(timeout_message)
time.sleep(0.05)
holder.depth = 1
try:
yield
finally:
holder.depth = 0
if lock_file is not None:
try:
_kernel_lock(lock_file, False)
except (OSError, IOError):
pass
@contextmanager
def _auth_store_lock(
timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, *, target_path: Optional[Path] = None):
"""Cross-process advisory lock for one auth.json read/write transaction.
``target_path`` is required for profile-to-global write-throughs: each path has its own
reentrancy tracker and kernel lock. Lock ordering invariant: ``_auth_store_lock`` FIRST (outer),
``_nous_shared_store_lock`` SECOND (inner), else deadlock against a concurrent shared import."""
auth_path = target_path if target_path is not None else _auth_file_path()
with _file_lock(
auth_path.with_suffix(".lock"), _auth_lock_holder_for(auth_path), timeout_seconds,
"Timed out waiting for auth store lock"):
yield
def _empty_auth_store() -> Dict[str, Any]:
return {"version": AUTH_STORE_VERSION, "providers": {}}
def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
auth_file = auth_file or _auth_file_path()
if not auth_file.exists():
return _empty_auth_store()
try:
raw = json.loads(auth_file.read_text(encoding="utf-8-sig"))
except OSError:
# Exists but unreadable (EMFILE, EACCES, EIO, stalled mount): contents are not bad, and this
# module read-modify-writes everywhere, so an empty store here is one _save_auth_store()
# away from erasing every credential. Fail loudly.
logger.warning(
"auth: could not read %s, leaving the store on disk untouched "
"rather than degrading to an empty one",
auth_file, exc_info=True)
raise
except Exception as exc:
# Genuine corruption: unparseable JSON or non-UTF-8 bytes. Preserve a copy, but never
# advertise a backup that was not written.
corrupt_path = auth_file.with_suffix(".json.corrupt")
try:
shutil.copy2(auth_file, corrupt_path)
preserved = True
except Exception:
preserved = False
logger.debug("auth: could not preserve a copy of the corrupt store at %s", corrupt_path,
exc_info=True)
logger.warning(
"auth: failed to parse %s (%s), starting with empty store. %s %s",
auth_file, exc,
"Corrupt file preserved at" if preserved else "A copy could NOT be preserved at",
corrupt_path)
return _empty_auth_store()
if isinstance(raw, dict) and (
isinstance(raw.get("providers"), dict) or isinstance(raw.get("credential_pool"), dict)):
raw.setdefault("providers", {})
if isinstance(raw.get("providers"), dict):
_migrate_stale_nous_portal_url(raw["providers"])
return raw
if isinstance(raw, dict) and isinstance(raw.get("systems"), dict): # legacy "systems" format
systems = raw["systems"]
providers = {"nous": systems["nous_portal"]} if "nous_portal" in systems else {}
return {**_empty_auth_store(), "providers": providers,
"active_provider": "nous" if providers else None}
return _empty_auth_store()
def _save_private_json(target: Path, data: Any, *, fsync_dir: bool = False, **dump_kwargs: Any) -> None:
"""0600 credential JSON under a 0700 parent (``secure_parent_dir`` refuses ``/``, top-level dirs
and the install tree). ``atomic_json_write`` creates the temp file 0600 before any byte lands."""
target.parent.mkdir(parents=True, exist_ok=True)
secure_parent_dir(target)
atomic_json_write(target, data, mode=0o600, fsync_dir=fsync_dir, **dump_kwargs)
def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path:
"""Atomically persist *auth_store* (0o600, parent tightened to 0o700) to the active store, or to
an explicit *target_path* (e.g. the global-root write-through for rotating xAI OAuth grants)."""
auth_file = target_path if target_path is not None else _auth_file_path()
auth_store["version"] = AUTH_STORE_VERSION
auth_store["updated_at"] = datetime.now(timezone.utc).isoformat()
_save_private_json(auth_file, auth_store, fsync_dir=True)
if target_path is not None:
# A write-through to the global root must not be masked by the mtime memo: on coarse-mtime
# filesystems a read-after-write in the same tick would keep serving the pre-write store.
global _global_auth_store_cache
_global_auth_store_cache = None
return auth_file
def _store_section(auth_store: Dict[str, Any], key: str) -> Dict[str, Any]:
"""Return ``auth_store[key]`` as a dict, replacing a missing/non-dict value in place."""
section = auth_store.get(key)
if not isinstance(section, dict):
section = auth_store[key] = {}
return section
def _provider_state_in(store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]:
"""Shallow copy of ``store["providers"][provider_id]`` when it is a dict, else None."""
providers = store.get("providers") if store else None
state = providers.get(provider_id) if isinstance(providers, dict) else None
return dict(state) if isinstance(state, dict) else None
def _load_provider_state_with_source(
auth_store: Dict[str, Any], provider_id: str,
) -> tuple[Optional[Dict[str, Any]], Optional[Path]]:
"""Provider state plus the auth.json path it came from (profile first, then the global root).
Refresh paths that rotate single-use OAuth refresh tokens must write the updated chain back to
the same store they read."""
state = _provider_state_in(auth_store, provider_id)
if state is not None:
return state, _auth_file_path()
global_state = _provider_state_in(_load_global_auth_store(), provider_id)
return (global_state, _global_auth_file_path()) if global_state is not None else (None, None)
def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]:
"""Provider state; in profile mode falls back to the global-root ``auth.json`` per provider (same
shadowing as ``read_credential_pool``), so profile workers see globally-authed providers."""
return _load_provider_state_with_source(auth_store, provider_id)[0]
@contextmanager
def _provider_state_transaction(
provider_id: str, timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
"""Lock the active auth store and any global fallback source, in that order.
Re-reading the source after its lock is acquired prevents stale refreshes and whole-file lost
updates without inverting the documented auth -> shared lock order. ``timeout_seconds`` applies
to BOTH locks: a transaction that spans a network call must let waiters outlive that call."""
with _auth_store_lock(timeout_seconds):
auth_store = _load_auth_store()
state, source_path = _load_provider_state_with_source(auth_store, provider_id)
if source_path is None or _same_path(source_path, _auth_file_path()):
yield auth_store, state, source_path
return
with _auth_store_lock(timeout_seconds, target_path=source_path):
yield auth_store, _provider_state_in(_load_auth_store(source_path), provider_id), source_path
def _store_provider_state(
auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any], *, set_active: bool = True,
) -> None:
_store_section(auth_store, "providers")[provider_id] = state
if set_active:
auth_store["active_provider"] = provider_id
def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any]) -> None:
"""Write *state* under ``providers`` and make *provider_id* the active provider."""
_store_provider_state(auth_store, provider_id, state, set_active=True)
def _save_active_provider_state(provider_id: str, state: Dict[str, Any]) -> Path:
"""Lock, load, write *state* as the active provider, save. Returns the auth store path."""
with _auth_store_lock():
auth_store = _load_auth_store()
_save_provider_state(auth_store, provider_id, state)
return _save_auth_store(auth_store)
def _persist_provider_state_to_store(
provider_id: str, state: Dict[str, Any], target_path: Path, *, set_active: bool = False,
) -> Path:
"""Merge one provider into a specific auth store under that store's lock."""
with _auth_store_lock(target_path=target_path):
auth_store = _load_auth_store(target_path)
_store_provider_state(auth_store, provider_id, dict(state), set_active=set_active)
return _save_auth_store(auth_store, target_path=target_path)
def _save_provider_state_to_source(
auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any], source_path: Optional[Path],
) -> None:
"""Persist provider state back to the auth store it was read from.
A token refresh rewrites credentials, not the user's choice of provider: ``active_provider`` is
left as it is (a Nous free-tier identity refreshed for a connector call must not become the
inference provider of an install that has its own key)."""
if source_path is None or _same_path(source_path, _auth_file_path()):
_store_provider_state(auth_store, provider_id, state, set_active=False)
_save_auth_store(auth_store)
else:
_persist_provider_state_to_store(provider_id, state, source_path, set_active=False)
def mark_provider_active_if_unset(provider_id: str) -> None:
"""Set ``active_provider`` only when none is set yet: the first ``hermes auth add`` credential must
make its provider active (else setup reports "No inference provider configured"); later adds
leave the user's choice untouched."""
with _auth_store_lock():
auth_store = _load_auth_store()
if not (auth_store.get("active_provider") or "").strip():
auth_store["active_provider"] = provider_id
_save_auth_store(auth_store)
def is_known_auth_provider(provider_id: str) -> bool:
normalized = (provider_id or "").strip().lower()
return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES
def get_auth_provider_display_name(provider_id: str) -> str:
normalized = (provider_id or "").strip().lower()
if normalized in PROVIDER_REGISTRY:
return PROVIDER_REGISTRY[normalized].name
return SERVICE_PROVIDER_NAMES.get(normalized, provider_id)
def is_runtime_provider_routable(provider_id: str) -> bool:
"""Whether runtime resolution recognizes a provider identity (a capability check, not a credential
check): ``resolve_provider`` normalization plus the special runtime identities outside the registry."""
normalized = (provider_id or "").strip().lower()
if not normalized:
return False
if normalized in {"auto", "openrouter", "custom", "moa"} or normalized.startswith("custom:"):
return True
try:
resolve_provider(normalized)
except AuthError:
return False
return True
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
"""Return the persisted credential pool, or one provider slice.
In profile mode the global-root ``auth.json`` is a read-only fallback applied per provider ONLY
when the profile has zero entries for it (``hermes auth add`` in the profile shadows global)."""
pool = _load_auth_store().get("credential_pool")
pool = pool if isinstance(pool, dict) else {}
global_pool = _load_global_auth_store().get("credential_pool")
global_pool = global_pool if isinstance(global_pool, dict) else {}
if provider_id is None:
merged = dict(pool)
for gp_key, gp_entries in global_pool.items():
existing = merged.get(gp_key)
if not (isinstance(gp_entries, list) and gp_entries):
continue
if not (isinstance(existing, list) and existing): # profile wins when it has ANY entries
merged[gp_key] = list(gp_entries)
return merged
provider_entries = pool.get(provider_id)
if isinstance(provider_entries, list) and provider_entries:
return list(provider_entries)
global_entries = global_pool.get(provider_id)
return list(global_entries) if isinstance(global_entries, list) else []
_POOL_STATUS_FIELDS = (
"last_status", "last_status_at", "last_error_code", "last_error_reason", "last_error_message",
"last_error_reset_at")
def _merge_disk_cooldown_state(
entry: Dict[str, Any], disk_entry: Optional[Dict[str, Any]], provider_id: str,
) -> Dict[str, Any]:
"""Keep a newer on-disk cooldown/quarantine over a stale in-memory one.
``write_credential_pool`` persists an in-memory snapshot that may predate another process
marking the same credential exhausted/dead; without this merge the later rewrite resurrects a
rate-limited key as healthy and both processes resume hammering it."""
if not isinstance(disk_entry, dict):
return entry
try:
from agent.credential_pool import (
PooledCredential, STATUS_DEAD, STATUS_EXHAUSTED, _exhausted_until, _parse_absolute_timestamp,
)
disk_status = disk_entry.get("last_status")
if disk_status not in (STATUS_DEAD, STATUS_EXHAUSTED):
return entry
# A token change means the caller re-authed this entry and intentionally cleared its status:
# never resurrect the old cooldown onto fresh credentials.
mem_access = entry.get("access_token") or ""
disk_access = disk_entry.get("access_token") or ""
if mem_access and disk_access and mem_access != disk_access:
return entry
disk_ts = _parse_absolute_timestamp(disk_entry.get("last_status_at")) or 0.0
mem_ts = _parse_absolute_timestamp(entry.get("last_status_at")) or 0.0
if disk_ts <= mem_ts:
return entry
if disk_status == STATUS_EXHAUSTED:
until = _exhausted_until(PooledCredential.from_dict(provider_id, disk_entry))
if until is None or until <= time.time():
return entry
return {**entry, **{f: disk_entry.get(f) for f in _POOL_STATUS_FIELDS}}
except Exception: # pragma: no cover - best-effort merge
return entry
def _entry_ids(entries: Iterable[Any]) -> Dict[str, Dict[str, Any]]:
return {e.get("id"): e for e in entries if isinstance(e, dict) and e.get("id")}
def write_credential_pool(
provider_id: str, entries: List[Dict[str, Any]], *,
removed_ids: Optional[Iterable[str]] = None,
status_cleared_ids: Optional[Iterable[str]] = None,
) -> Path:
"""Persist one provider's credential pool under auth.json.
Final disk-boundary sanitizer for borrowed credentials (callers may pass raw dicts). Entries on
disk but missing from *entries* (added concurrently) are merged back unless in *removed_ids*,
so a rotation/exhaustion rewrite never drops a concurrent credential. Entries in
*status_cleared_ids* were cleared deliberately (``hermes auth reset``) and skip the
recency merge, which would otherwise read their cleared ``last_status_at`` (None ->
epoch 0) as a stale snapshot and copy a still-binding cooldown back."""
removed = {rid for rid in (removed_ids or ()) if rid}
with _auth_store_lock():
auth_store = _load_auth_store()
pool = _store_section(auth_store, "credential_pool")
sanitized = [
sanitize_borrowed_credential_payload(e, provider_id) if isinstance(e, dict) else e
for e in entries]
existing_list = pool.get(provider_id)
existing_list = existing_list if isinstance(existing_list, list) else []
existing_by_id = _entry_ids(existing_list)
new_ids = set(_entry_ids(sanitized))
status_cleared = {cid for cid in (status_cleared_ids or ()) if cid}
merged: List[Dict[str, Any]] = [
_merge_disk_cooldown_state(
e, None if e.get("id") in status_cleared else existing_by_id.get(e.get("id")), provider_id,
)
if isinstance(e, dict) else e
for e in sanitized]
for disk_entry in existing_list:
disk_id = disk_entry.get("id") if isinstance(disk_entry, dict) else None
if disk_id and disk_id not in new_ids and disk_id not in removed:
merged.append(sanitize_borrowed_credential_payload(disk_entry, provider_id))
pool[provider_id] = merged
return _save_auth_store(auth_store)
def _suppressed_source_list(suppressed: Dict[str, Any], provider_id: str) -> Optional[List[str]]:
"""Canonical (list-form) suppressed sources for *provider_id*; a legacy mapping (keys = source
names) is migrated to the list form in place."""
raw_sources = suppressed.get(provider_id)
if isinstance(raw_sources, list):
return raw_sources
if isinstance(raw_sources, dict):
suppressed[provider_id] = [str(name) for name in raw_sources]
return suppressed[provider_id]
return None
def suppress_credential_source(provider_id: str, source: str) -> None:
"""Mark a credential source as suppressed so it won't be re-seeded."""
with _auth_store_lock():
auth_store = _load_auth_store()
suppressed = _store_section(auth_store, "suppressed_sources")
provider_list = _suppressed_source_list(suppressed, provider_id)
if provider_list is None:
provider_list = suppressed[provider_id] = []
if source not in provider_list:
provider_list.append(source)
_save_auth_store(auth_store)
def is_source_suppressed(provider_id: str, source: str) -> bool:
"""Check if a credential source has been suppressed by the user."""
try:
return source in _load_auth_store().get("suppressed_sources", {}).get(provider_id, [])
except Exception:
return False
def unsuppress_credential_source(provider_id: str, source: str) -> bool:
"""Clear a suppression marker so the source will be re-seeded on the next load."""
with _auth_store_lock():
auth_store = _load_auth_store()
suppressed = auth_store.get("suppressed_sources")
if not isinstance(suppressed, dict):
return False
provider_list = _suppressed_source_list(suppressed, provider_id)
if provider_list is None or source not in provider_list:
return False
provider_list.remove(source)
if not provider_list:
suppressed.pop(provider_id, None)
if not suppressed:
auth_store.pop("suppressed_sources", None)
_save_auth_store(auth_store)
return True
def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
"""Persisted auth state for a provider (profile first, global-root fallback), or None."""
return _load_provider_state(_load_auth_store(), provider_id)
def nous_token_has_billing_scope() -> bool:
"""Return True if the currently-held Nous token carries ``billing:manage``.
Reads the persisted ``scope`` string saved at login (``_save_provider_state``
stores ``token_data.get("scope") or scope``). A space-delimited match. Used by
the lazy step-up: if False, the first billing call will 403 ``insufficient_scope``
anyway, but checking up front lets a surface skip a doomed round-trip.
"""
try:
state = get_provider_auth_state("nous") or {}
except Exception:
return False
scope = state.get("scope")
if not isinstance(scope, str):
return False
return NOUS_BILLING_MANAGE_SCOPE in scope.split()
def get_active_provider() -> Optional[str]:
"""Return the currently active provider ID from auth store."""
return _load_auth_store().get("active_provider")
def _active_provider_is(normalized: str) -> bool:
active = (_load_auth_store().get("active_provider") or "").strip().lower()
return bool(active) and active == normalized
def _slot_selects(slot: Any, normalized: str) -> bool:
return isinstance(slot, dict) and (slot.get("provider") or "").strip().lower() == normalized
def _config_selects_provider(normalized: str) -> bool:
"""config.yaml ``model.provider``, or a MoA advisor/aggregator slot naming the provider.
MoA presets are explicit model selections too: ``provider: anthropic`` in a MoA slot opts into
Anthropic credentials for that slot even when the main model is another provider; otherwise
Claude Code OAuth entries get pruned by ``load_pool("anthropic")`` and MoA advisors fail with
"no ANTHROPIC_API_KEY" while the picker says Anthropic is logged in."""
from hermes_cli.config import load_config
cfg = load_config()
if _slot_selects(cfg.get("model"), normalized):
return True
def _moa_block_matches(block: Any) -> bool:
return isinstance(block, dict) and (
any(_slot_selects(s, normalized) for s in block.get("reference_models") or [])
or _slot_selects(block.get("aggregator"), normalized))
moa_cfg = cfg.get("moa")
if not isinstance(moa_cfg, dict):
return False
presets = moa_cfg.get("presets")
presets = presets.values() if isinstance(presets, dict) else ()
return _moa_block_matches(moa_cfg) or any(_moa_block_matches(p) for p in presets)
def _explicit_pool_entry_present(normalized: str) -> bool:
"""Pool rows from EXPLICIT Hermes flows (manual add / device-code / PKCE) or live env keys;
ambient borrowed sources (gh_cli / claude_code / qwen-cli) are deliberately excluded."""
return any(_pool_entry_is_explicit(entry) for entry in read_credential_pool(normalized))
# Set by Claude Code itself, not by the user explicitly configuring anthropic in Hermes.
_IMPLICIT_ENV_VARS = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
_EXPLICIT_POOL_SOURCES = frozenset({"device_code", "loopback_pkce", "hermes_pkce", "manual"})
_VERTEX_PROVIDER_IDS = ("vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai")
def _env_secret(name: str) -> bool:
return has_usable_secret(os.getenv(name, ""))
def _explicit_env_credentials_present(normalized: str) -> bool:
"""True when the user has pasted an explicit credential env var for *normalized*.
Falls back to the models.dev ``ProviderDef`` (same shape) for non-registry providers such as
openrouter. AWS SDK providers are checked via explicit env vars only β NOT boto3's chain, so
ambient EC2 IMDS / SSO profiles never auto-surface."""
pconfig = PROVIDER_REGISTRY.get(normalized)
if pconfig is None:
from hermes_cli.providers import get_provider
pconfig = get_provider(normalized)
if not pconfig:
return False
if pconfig.auth_type == "api_key":
return any(_env_secret(v) for v in pconfig.api_key_env_vars if v not in _IMPLICIT_ENV_VARS)
if pconfig.auth_type == "aws_sdk":
return _env_secret("AWS_BEARER_TOKEN_BEDROCK") or (
_env_secret("AWS_ACCESS_KEY_ID") and _env_secret("AWS_SECRET_ACCESS_KEY"))
return False
def _pool_entry_is_explicit(entry: Any) -> bool:
"""True for pool rows the user created via an explicit Hermes flow (or a still-live env key)."""
if not isinstance(entry, dict):
return False
source = str(entry.get("source") or "").strip().lower()
if source.startswith("env:"):
# A stale env-seeded entry survives in auth.json after the user deletes the env var: only
# count it when the referenced var still resolves to a usable secret NOW.
# See #55790.
env_var = entry.get("source", "").split(":", 1)[1].strip()
return bool(env_var and _env_secret(env_var))
return bool(source) and (source in _EXPLICIT_POOL_SOURCES or source.startswith("manual:"))
def _keyless_provider_has_explicit_config(normalized: str) -> bool:
"""Vertex / Bedrock count as explicit when Hermes-scoped routing config is present.
Uses has_explicit_vertex_config(), NOT has_vertex_credentials(): the latter also counts an
ambient GOOGLE_APPLICATION_CREDENTIALS path (commonly set for unrelated GCP work). Only
Hermes-scoped signals (VERTEX_PROJECT_ID / vertex.project_id / VERTEX_CREDENTIALS_PATH) count
here."""
if normalized in _VERTEX_PROVIDER_IDS:
from agent.vertex_adapter import has_explicit_vertex_config
return bool(has_explicit_vertex_config())
if normalized == "bedrock":
from hermes_cli.config import load_config
bedrock_cfg = load_config().get("bedrock")
return isinstance(bedrock_cfg, dict) and bool(str(bedrock_cfg.get("region") or "").strip())
return False
# Ordered explicit-configuration checks: ``(check, best_effort)``. Best-effort checks treat an
# exception as "no"; the env-var check is NOT best-effort β a failure there must surface rather
# than let a later, weaker signal decide.
_EXPLICIT_CONFIG_CHECKS: Tuple[Tuple[Callable[[str], bool], bool], ...] = (
(_active_provider_is, True), (_config_selects_provider, True),
(_explicit_env_credentials_present, False), (_explicit_pool_entry_present, True),
(_keyless_provider_has_explicit_config, True))
def is_provider_explicitly_configured(provider_id: str) -> bool:
"""True only if the user explicitly configured this provider: auth.json ``active_provider``,
config.yaml ``model.provider`` / MoA slots, a pasted provider env var, a pool entry from a
Hermes-initiated flow, or Hermes-scoped routing config for keyless cloud-SDK providers. Ambient
borrowed credentials (gh CLI, qwen-cli, ~/.claude/.credentials.json) never count."""
normalized = (provider_id or "").strip().lower()
for check, best_effort in _EXPLICIT_CONFIG_CHECKS:
try:
if check(normalized):
return True
except Exception as exc:
if not best_effort:
raise
logger.debug("explicit-config check %s failed for %s: %s", check.__name__, provider_id, exc)
return False
def clear_provider_auth(provider_id: Optional[str] = None) -> bool:
"""Clear auth state for a provider (the active one when *provider_id* is None). Used by
``hermes logout``. Returns True if something was cleared."""
with _auth_store_lock():
auth_store = _load_auth_store()
target = provider_id or auth_store.get("active_provider")
if not target:
return False
cleared = False
for section in ("providers", "credential_pool"):
entries = _store_section(auth_store, section)
if target in entries:
del entries[target]
cleared = True
if auth_store.get("active_provider") == target:
auth_store["active_provider"] = None
cleared = True
if cleared:
_save_auth_store(auth_store)
return cleared
def deactivate_provider() -> None:
"""Clear active_provider without deleting credentials: used when the user switches to a non-OAuth
provider (OpenRouter, custom) so auto-resolution doesn't keep picking the OAuth provider."""
with _auth_store_lock():
auth_store = _load_auth_store()
auth_store["active_provider"] = None
_save_auth_store(auth_store)
# ββ Provider Resolution β picks which provider to use βββββββββββββββββββββββββββββββββββββββββββββββ
def _get_config_hint_for_unknown_provider(provider_name: str) -> str:
"""Return a helpful hint string when provider resolution fails."""
try:
from hermes_cli.config import validate_config_structure
issues = validate_config_structure()
if not issues:
return ""
lines = ["Config issue detected β run 'hermes doctor' for full diagnostics:"]
for ci in issues:
lines.append(f" [{'ERROR' if ci.severity == 'error' else 'WARNING'}] {ci.message}")
if ci.hint and ci.hint.splitlines()[0]:
lines.append(f" β {ci.hint.splitlines()[0]}")
return "\n".join(lines)
except Exception:
return ""
def _refuse_env_adoption_if_config_corrupt() -> None:
"""Refuse env-key/pool auto-adoption of openrouter while config.yaml is corrupt.
A corrupt config loads as ``DEFAULT_CONFIG`` (no ``model.provider``), so the env sniff would
silently adopt the PAID openrouter provider over whatever the broken config really names.
Fires ONLY on the auto path and clears itself once the file parses again."""
try:
from hermes_cli.config import get_active_config_parse_failure
err = get_active_config_parse_failure()
if not err:
return
path = get_config_path()
except Exception as e:
logger.debug("Could not probe config parse-failure state: %s", e)
return
raise AuthError(
f"config.yaml at {path} is corrupt ({err}) β refusing to auto-select "
f"an inference provider from environment keys. Fix the YAML (a backup "
f"was saved next to it) or run hermes setup.",
code="corrupt_config")
# Provider aliases accepted by resolve_provider(). Plugin-declared aliases
# (plugins/model-providers/<name>/) are layered on at call time; this hardcoded
# table remains authoritative for existing names.
_PROVIDER_ALIASES: Dict[str, str] = {
"glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
"google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
"x-ai": "xai", "x.ai": "xai", "grok": "xai",
"xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth",
"grok-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth",
"kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
"step": "stepfun", "stepfun-coding-plan": "stepfun",
"arcee-ai": "arcee", "arceeai": "arcee",
"gmi-cloud": "gmi", "gmicloud": "gmi",
"actual-computer": "actual", "actualcomputer": "actual", "aci": "actual",
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
"minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth",
"alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",
"alibaba_coding_plan": "alibaba-coding-plan",
"claude": "anthropic", "claude-code": "anthropic",
"github": "copilot", "github-copilot": "copilot",
"github-models": "copilot", "github-model": "copilot",
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
"opencode": "opencode-zen", "zen": "opencode-zen",
"free": "opencode-free", "opencode_free": "opencode-free",
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth",
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
"tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
"tencent-cloud": "tencent-tokenhub", "tencentmaas": "tencent-tokenhub",
"tokenplan": "tencent-tokenplan", "tencent-lkeap": "tencent-tokenplan",
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
"go": "opencode-go", "opencode-go-sub": "opencode-go",
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
"lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio",
# Local server aliases β route through the generic custom provider
"ollama": "custom", "ollama_cloud": "ollama-cloud",
"vllm": "custom", "llamacpp": "custom",
"llama.cpp": "custom", "llama-cpp": "custom"}
def _plugin_aliases() -> Dict[str, str]:
"""``_PROVIDER_ALIASES`` extended with aliases declared in plugins/model-providers/<name>/."""
aliases = dict(_PROVIDER_ALIASES)
try:
from providers import list_providers as _lp
for _pp in _lp():
for _alias in _pp.aliases:
aliases.setdefault(_alias, _pp.name)
except Exception:
pass
return aliases
def _scoped_key_env_reader() -> Callable[[str], str]:
"""Scope-aware key reader for provider auto-detection.
Under multiplex a secondary profile's keys live only in its secret scope, not os.environ. Catch
ONLY ImportError: any other auxiliary_client failure must propagate rather than silently
falling back to os.getenv (a traceless fail-open)."""
try:
# Scope-aware key reads: under multiplex a secondary profile's API keys live only in its secret
# scope, not os.environ β a bare getenv here would find nothing and auto-resolution would report "No
# LLM provider configured" for every secondary profile (same class as #86905).
from agent.auxiliary_client import _scoped_key_env
return _scoped_key_env
except ImportError:
logger.warning(
"agent.auxiliary_client unavailable (%s); provider auto-detection "
"will read keys from the process environment only β under "
"multiplex, secondary profiles may report 'No LLM provider'.",
"import failed")
return lambda name: os.getenv(name) or ""
def _openrouter_auto_detected(scoped_key_env: Callable[[str], str]) -> bool:
"""True when an OpenRouter credential exists via env key or the credential pool (a key added via
`hermes auth add openrouter` has no env var; without the pool check it is invisible to
auto-detection and requests go out with no Authorization header)."""
if any(has_usable_secret(scoped_key_env(v)) for v in ("OPENAI_API_KEY", "OPENROUTER_API_KEY")):
return True
try:
# Auto-detect an OpenRouter credential added via `hermes auth add openrouter` (manual pool entry, no
# env var). Without this, a key that only lives in the credential pool is invisible to
# auto-detection β the user sees `hermes auth list` showing the credential while requests go out
# with no Authorization header ("HTTP 401: Missing Authentication header"). The env-var check above
# only covers keys exported as OPENROUTER_API_KEY / OPENAI_API_KEY. See issue #42130.
from agent.credential_pool import load_pool as _load_pool
return bool(_load_pool("openrouter").has_credentials())
except Exception as e:
logger.debug("Could not check OpenRouter credential pool: %s", e)
return False
def _logged_in_oauth_active_provider(*, skip_free_tier: bool = False) -> Optional[str]:
"""auth.json ``active_provider`` when it is a registry provider that reports logged in."""
try:
_maybe = _load_auth_store().get("active_provider")
if _maybe == "nous":
from hermes_cli.anon_auth import guest_enabled, has_guest
if has_guest() and (skip_free_tier or not guest_enabled()):
return None # the free tier is off (or being discounted), so a guest is not a login
if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get("logged_in"):
return _maybe
except Exception as e:
logger.debug("Could not pre-read active auth provider: %s", e)
return None
def _config_model_provider() -> Tuple[Any, Optional[str]]:
"""``(model_cfg, provider)`` from config.yaml when ``model.provider`` names a registry provider
or a custom OpenAI-compatible endpoint (``custom``, ``custom:<name>``, ``vllm``/``ollama``/...).
The normal chat/gateway path resolves config.provider upstream in resolve_requested_provider();
this is the safety net for the direct ``resolve_provider("auto")`` callers. A configured custom
endpoint is explicit intent like any registry pin: without this rung the boot inventory
(``free_tier_bootstrap``) read a llama.cpp/vLLM install as "nothing configured" and the
dashboard's Ink chat parked every session on Setup Required while ``hermes chat`` worked
(#108383)."""
try:
from hermes_cli.config import load_config
model_cfg = (load_config() or {}).get("model")
provider = model_cfg.get("provider") if isinstance(model_cfg, dict) else None
provider = provider.strip().lower() if isinstance(provider, str) else ""
provider = _plugin_aliases().get(provider, provider)
if provider == "custom" or provider.startswith("custom:"):
return model_cfg, "custom"
if provider in PROVIDER_REGISTRY:
return model_cfg, provider
# No provider pin but a base_url the bare-custom runtime rung would honour (a loopback
# llama.cpp/vLLM/ollama server) β same explicit intent, spelled by URL.
base_url = str(model_cfg.get("base_url") or "").strip() if isinstance(model_cfg, dict) else ""
if base_url:
from hermes_cli.runtime_provider import _config_base_url_trustworthy_for_bare_custom
if _config_base_url_trustworthy_for_bare_custom(base_url, provider):
return model_cfg, "custom"
return model_cfg, None
except Exception as e:
logger.debug("Could not read config.yaml model.provider for auto-resolution: %s", e)
return None, None
# API-key providers never auto-selected from env: GitHub tokens are commonly present for repo/tool
# access and must not hijack inference; LM Studio is a local server whose availability isn't
# implied by LM_API_KEY (may be offline; no-auth setup uses a placeholder). Both need an explicit
# choice.
_NO_AUTO_DETECT_PROVIDERS = frozenset({"copilot", "lmstudio"})
def _env_key_auto_detected(
scoped_key_env: Callable[[str], str], oauth_active: Optional[str]) -> Optional[str]:
"""First registry api_key provider (registry order) with a usable env key, warning when it
preempts a logged-in OAuth provider so a stale key in ~/.hermes/.env never switches silently."""
for pid, pconfig in PROVIDER_REGISTRY.items():
if pconfig.auth_type != "api_key" or pid in _NO_AUTO_DETECT_PROVIDERS:
continue
for env_var in pconfig.api_key_env_vars:
if has_usable_secret(scoped_key_env(env_var)):
if oauth_active and oauth_active != pid:
logger.warning(
# An exported API key now wins over a logged-in OAuth provider (the #29285 fix).
# Surface that so a user who deliberately uses OAuth but has a stale key in
# ~/.hermes/.env isn't silently switched without knowing why.
"Provider resolved to %r via %s, preempting your "
"logged-in OAuth provider %r. If you meant to use the "
"OAuth login, unset %s or set `model.provider` "
"explicitly.",
pid, env_var, oauth_active, env_var)
return pid
return None
def resolve_provider(
requested: Optional[str] = None,
*,
explicit_api_key: Optional[str] = None,
explicit_base_url: Optional[str] = None,
skip_free_tier: bool = False) -> str:
"""Determine which inference provider to use.
"auto" priority (explicit intent beats a stale OAuth login): 1. CLI api_key/base_url ->
"openrouter"; 2. config.yaml ``model.provider``; 3. OPENAI_API_KEY / OPENROUTER_API_KEY ->
"openrouter"; 4. OpenRouter pool; 5. provider env keys; 6. auth.json ``active_provider``;
7. Nous free tier when it is on and its identity exists (never created here);
8. AWS Bedrock chain; 9. AuthError(no_provider_configured).
``skip_free_tier`` hides rungs 6-for-a-free-tier-identity and 7: the boot bootstrap asks
"what would carry inference if the free tier did not exist?" to decide whether a fresh identity
may become ``active_provider``.
1. 3. 4. 5. Provider-specific API keys (GLM, Kimi, MiniMax, ...) -> that provider 7. 8. Error (no
provider configured) See #29285.
"""
normalized = (requested or "auto").strip().lower()
normalized = _plugin_aliases().get(normalized, normalized)
if normalized in ("openrouter", "custom") or normalized in PROVIDER_REGISTRY:
return normalized
if normalized != "auto":
hint = _get_config_hint_for_unknown_provider(normalized)
tail = (f"\n\n{hint}" if hint else " Check 'hermes model' for available providers, "
"or run 'hermes doctor' to diagnose config issues.")
raise AuthError(f"Unknown provider '{normalized}'." + tail, code="invalid_provider")
if explicit_api_key or explicit_base_url: # one-off CLI creds always mean openrouter/custom
return "openrouter"
_model_cfg, cfg_provider = _config_model_provider()
if cfg_provider:
return cfg_provider
_scoped_key_env = _scoped_key_env_reader()
if _openrouter_auto_detected(_scoped_key_env):
_refuse_env_adoption_if_config_corrupt()
return "openrouter"
# Determined up front so the env-key tier can warn when an exported key preempts it; the actual
# OAuth fallback still happens after the env-key tier.
_oauth_active = _logged_in_oauth_active_provider(skip_free_tier=skip_free_tier)
env_pid = _env_key_auto_detected(_scoped_key_env, _oauth_active)
if env_pid:
return env_pid
# Logged-in OAuth provider is a LAST-RESORT fallback (it used to sit above the env/config
# checks, so a stale login silently overrode explicit intent).
# Logged-in OAuth provider (auth.json `active_provider`) β a LAST-RESORT fallback, chosen only when the
# user expressed no other preference above. Demoted here so explicit intent always wins. See #29285.
if _oauth_active:
if isinstance(_model_cfg, dict) and _model_cfg and not _model_cfg.get("provider"):
logger.warning(
"Provider resolved to logged-in OAuth provider %r because "
"config.yaml `model` has no `provider` key. If you meant a "
"different provider, set `model.provider` explicitly.",
_oauth_active)
return _oauth_active
# Nous free tier, when it is on and its identity already exists. This rung sits ABOVE the Bedrock
# chain on purpose: every rung above this line is explicit user intent (CLI creds, config, env
# keys, a sign-in); the boto chain below is implicit host state, and a leftover ~/.aws profile
# used to win the first turn of a fresh install (NS-829). The rung never CREATES the identity:
# that is the boot bootstrap's job (free_tier_bootstrap), so provider resolution stays free of
# network and a fresh install without the bootstrap resolves exactly as upstream does.
if not skip_free_tier:
try:
from hermes_cli.anon_auth import guest_enabled, has_guest
if guest_enabled() and has_guest():
return "nous"
except Exception as exc:
logger.debug("free tier check during provider resolution skipped: %s", exc)
# AWS Bedrock via the boto3 credential chain (IAM roles, SSO, env vars): implicit host state,
# below explicit keys and below the free tier.
try:
from agent.bedrock_adapter import has_aws_credentials
if has_aws_credentials():
return "bedrock"
except ImportError:
pass # boto3 not installed
raise AuthError(
"No inference provider configured. Run 'hermes model' to choose a "
"provider and model, or set an API key (OPENROUTER_API_KEY, "
"OPENAI_API_KEY, etc.) in ~/.hermes/.env.",
code="no_provider_configured")
# ββ Timestamp / TTL helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _utc_now_z() -> str:
"""Current UTC time as an ISO-8601 string with a ``Z`` suffix (last_refresh format)."""
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _parse_iso_timestamp(value: Any) -> Optional[float]:
text = value.strip() if isinstance(value, str) else ""
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except Exception:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
def _is_expiring(expires_at_iso: Any, skew_seconds: int) -> bool:
expires_epoch = _parse_iso_timestamp(expires_at_iso)
return expires_epoch is None or expires_epoch <= (time.time() + skew_seconds)
def _tls_state_from_verify(verify: Any) -> Dict[str, Any]:
"""Persistable ``tls`` block derived from an httpx ``verify`` value."""
return {"insecure": verify is False, "ca_bundle": verify if isinstance(verify, str) else None}
def _last_auth_error_marker(
provider: str, error: "AuthError", *, reason: str, default_code: Optional[str] = None,
) -> Dict[str, Any]:
"""The ``last_auth_error`` record persisted when dead OAuth material is quarantined."""
return {
"provider": provider, "message": str(error), "reason": reason, "relogin_required": True,
"code": error.code if default_code is None else (error.code or default_code),
"at": datetime.now(timezone.utc).isoformat()}
_FLAT_OAUTH_TOKEN_KEYS = ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at")
def _quarantine_flat_oauth_state(state: Dict[str, Any], provider: str, exc: "AuthError") -> None:
"""Strip dead tokens from a flat OAuth state after a terminal runtime refresh failure so
subsequent calls fail fast without a network retry (mirrors the Nous / xAI / Codex pattern)."""
for _k in _FLAT_OAUTH_TOKEN_KEYS:
state.pop(_k, None)
state["last_auth_error"] = _last_auth_error_marker(
provider, exc, reason="runtime_refresh_failure", default_code="refresh_failed")
def _coerce_ttl_seconds(expires_in: Any) -> int:
try:
return max(0, int(expires_in))
except Exception:
return 0
def _optional_base_url(value: Any) -> Optional[str]:
cleaned = value.strip().rstrip("/") if isinstance(value, str) else ""
return cleaned or None
# Valid Nous Portal hosts; a stored portal_base_url outside this set is a misconfiguration and falls
# back to the default. localhost / 127.0.0.1 are for local development and testing.
_NOUS_PORTAL_ALLOWED_HOSTS: FrozenSet[str] = frozenset({
"portal.nousresearch.com", "localhost", "127.0.0.1"})
# Per-process memo for resolve_nous_access_token: startup runs one check_fn per managed tool and
# each would trigger its own ~15s blocking refresh of an expired token; a short-TTL memo collapses
# the burst into one round-trip. Callers needing freshness use force_fresh/refresh_nous_oauth_pure.
# Keyed by hermes_home_key(): the resolution itself is profile-scoped (_auth_file_path reads the
# per-turn HERMES_HOME override a multiplex gateway sets), so a single slot would hand profile A's
# Portal bearer to profile B for up to the TTL.
_RESOLVE_TOKEN_CACHE_LOCK = threading.Lock()
_RESOLVE_TOKEN_CACHE: "dict[str, tuple[float, str]]" = {}
_RESOLVE_TOKEN_CACHE_TTL_S = 5.0
def _nous_portal_base_url(state: Dict[str, Any]) -> str:
"""HERMES_PORTAL_BASE_URL / NOUS_PORTAL_BASE_URL is the trusted operator override and wins
OUTRIGHT, bypassing the host allowlist (which exists to reject an untrusted network-provided
value, not one the operator configured). Otherwise the stored/default value, allowlist-gated."""
env_portal_override = _nous_portal_env_override()
if env_portal_override:
return env_portal_override.rstrip("/")
portal_base_url = _optional_base_url(state.get("portal_base_url")) or DEFAULT_NOUS_PORTAL_URL
portal_base_url = portal_base_url.rstrip("/")
host = urlparse(portal_base_url).hostname
if host and host not in _NOUS_PORTAL_ALLOWED_HOSTS:
logger.warning(
"auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default",
portal_base_url, host)
return DEFAULT_NOUS_PORTAL_URL
return portal_base_url
def resolve_nous_access_token(
*,
timeout_seconds: float = 15.0,
insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None,
refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> str:
"""Resolve a refresh-aware Nous Portal access token for managed tool gateways."""
# Only a default-TLS resolution is memoised; error paths never populate the memo.
memoable = not insecure and ca_bundle is None
cache_key = hermes_home_key()
if memoable:
with _RESOLVE_TOKEN_CACHE_LOCK:
cached = _RESOLVE_TOKEN_CACHE.get(cache_key)
if cached is not None and (time.monotonic() - cached[0]) < _RESOLVE_TOKEN_CACHE_TTL_S:
return cached[1]
def _memo(token: str) -> str:
if memoable:
with _RESOLVE_TOKEN_CACHE_LOCK:
_RESOLVE_TOKEN_CACHE[cache_key] = (time.monotonic(), token)
return token
with _provider_state_transaction("nous") as (auth_store, state, state_source_path):
if not state:
raise _nous_err("Hermes is not logged into Nous Portal.", relogin=True)
portal_base_url = _nous_portal_base_url(state)
client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
persist = lambda: _save_provider_state_to_source( # noqa: E731
auth_store, "nous", state, state_source_path)
lock_timeout = max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)
with _nous_shared_store_lock(timeout_seconds=lock_timeout):
from hermes_cli.anon_auth import is_guest_state, refresh_guest_state
if is_guest_state(state):
# Guest seam: the anon_ credential is the identity; a first use has no access token
# yet and an expired one is re-exchanged. No refresh token, no quarantine.
access_token = state.get("access_token")
if isinstance(access_token, str) and access_token and not _is_expiring(
state.get("expires_at"), refresh_skew_seconds):
return _memo(access_token)
with httpx.Client(timeout=httpx.Timeout(timeout_seconds or 15.0),
headers={"Accept": "application/json"}, verify=verify) as client:
refresh_guest_state(state, client)
persist()
_write_shared_nous_state(state)
return _memo(state["access_token"])
merged_shared = _merge_shared_nous_oauth_state(state)
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
if not isinstance(access_token, str) or not access_token:
raise _nous_err("No access token found for Nous Portal login.", relogin=True)
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
if merged_shared:
persist()
# Memoise the valid-token fast path too: each check_fn otherwise pays two
# cross-process file locks to get here. The token has >= refresh_skew_seconds (>=
# 120s) of life, so a 5s memo can never serve an expired token.
return _memo(access_token)
if not isinstance(refresh_token, str) or not refresh_token:
raise _nous_err("Session expired and no refresh token is available.", relogin=True)
with httpx.Client(timeout=httpx.Timeout(timeout_seconds or 15.0),
headers={"Accept": "application/json"}, verify=verify) as client:
refreshed = _refresh_nous_or_quarantine(
client=client, auth_store=auth_store, state=state, portal_base_url=portal_base_url,
client_id=client_id, refresh_token=refresh_token,
reason="managed_access_token_refresh_failure", persist=persist)
_apply_nous_refreshed_tokens(state, refreshed, refresh_token)
state["portal_base_url"] = portal_base_url
state["client_id"] = client_id
state["tls"] = _tls_state_from_verify(verify)
persist()
_write_shared_nous_state(state)
return _memo(state["access_token"])
# ββ Status helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Process-level memo for get_nous_auth_status(): it validates via a synchronous refresh POST
# (~350ms) and read-only UI surfaces call it many times per render (~31x per menu paint), burning
# single-use refresh tokens. Keyed on auth.json path + mtime so profile switches don't share a memo
# and login/logout/add/remove invalidate naturally.
_NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds
_nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None
# mtime-keyed memo for _load_global_auth_store(): (path, mtime_ns, store); same invalidation rule.
_global_auth_store_cache: Optional[Tuple[str, int, Dict[str, Any]]] = None
def _auth_file_cache_key() -> Tuple[str, Optional[float]]:
auth_file = _auth_file_path()
try:
return _resolved_key(auth_file), auth_file.stat().st_mtime
except Exception: # missing file included: key without an mtime
return _resolved_key(auth_file), None
def invalidate_nous_auth_status_cache() -> None:
"""Clear the get_nous_auth_status() memo (for code paths that mutate Nous auth state without
touching auth.json, e.g. tests; login/logout invalidate via the mtime check automatically)."""
global _nous_auth_status_cache
_nous_auth_status_cache = None
def get_nous_auth_status() -> Dict[str, Any]:
"""Status snapshot for Nous auth, memoised ~15s keyed on the auth.json mtime.
Prefers the auth-store provider state (the live source of truth for refresh) and validates it by
resolving runtime credentials so revoked refresh sessions do not show up as a healthy login."""
global _nous_auth_status_cache
now = time.monotonic()
auth_file_key, mtime = _auth_file_cache_key()
cached = _nous_auth_status_cache
if (cached is not None and cached[1:3] == (auth_file_key, mtime)
and (now - cached[0]) < _NOUS_AUTH_STATUS_CACHE_TTL):
return dict(cached[3])
status = _compute_nous_auth_status()
_nous_auth_status_cache = (now, auth_file_key, mtime, dict(status))
return status
@dataclass(frozen=True)
class OAuthProviderFlow:
"""Per-provider OAuth plumbing, keyed by provider id in ``OAUTH_PROVIDER_FLOWS``.
Callables are named (strings) and looked up in this module at call time so
``monkeypatch.setattr("hermes_cli.auth.resolve_codex_runtime_credentials", ...)`` applies."""
provider_id: str
resolve_fn: str
status_fn: str
terminal_refresh_codes: FrozenSet[str] = frozenset() # retrying the same refresh token cannot succeed
# ``hermes logout`` with no active provider falls back to config.yaml ``model.provider`` only
# for providers whose credentials live in auth.json.
logout_from_config: bool = False
def resolve(self, **kwargs: Any) -> Dict[str, Any]:
return globals()[self.resolve_fn](**kwargs)
def status(self) -> Dict[str, Any]:
return globals()[self.status_fn]()
def is_terminal_refresh_error(self, exc: Exception) -> bool:
return (
isinstance(exc, AuthError) and exc.provider == self.provider_id
and exc.code in self.terminal_refresh_codes and bool(exc.relogin_required))
_OAUTH_GRANT_DEAD_CODES = frozenset({"invalid_grant", "invalid_token", "refresh_token_reused"})
OAUTH_PROVIDER_FLOWS: Dict[str, OAuthProviderFlow] = {
"nous": OAuthProviderFlow(
"nous", "resolve_nous_runtime_credentials", "get_nous_auth_status",
terminal_refresh_codes=_OAUTH_GRANT_DEAD_CODES, logout_from_config=True),
"openai-codex": OAuthProviderFlow(
"openai-codex", "resolve_codex_runtime_credentials", "get_codex_auth_status",
terminal_refresh_codes=_OAUTH_GRANT_DEAD_CODES | {"codex_refresh_failed", "codex_auth_missing_refresh_token"},
logout_from_config=True),
"xai-oauth": OAuthProviderFlow(
"xai-oauth", "resolve_xai_oauth_runtime_credentials", "get_xai_oauth_auth_status",
terminal_refresh_codes=frozenset({"xai_refresh_failed", "xai_auth_missing_refresh_token"}),
logout_from_config=True),
"qwen-oauth": OAuthProviderFlow(
"qwen-oauth", "resolve_qwen_runtime_credentials", "get_qwen_auth_status"),
"minimax-oauth": OAuthProviderFlow(
"minimax-oauth", "resolve_minimax_oauth_runtime_credentials", "get_minimax_oauth_auth_status"),
}
def _is_terminal_refresh_error(exc: Exception, provider: str) -> bool:
"""True when retrying the same *provider* refresh token cannot succeed."""
return OAUTH_PROVIDER_FLOWS[provider].is_terminal_refresh_error(exc)
_is_terminal_nous_refresh_error = partial(_is_terminal_refresh_error, provider="nous")
_is_terminal_xai_oauth_refresh_error = partial(_is_terminal_refresh_error, provider="xai-oauth")
_is_terminal_codex_oauth_refresh_error = partial(
_is_terminal_refresh_error, provider="openai-codex")
def _codex_pool_rate_limited_status() -> Optional[Dict[str, Any]]:
rate_limit = _codex_pool_rate_limit_status()
if not rate_limit:
return None
return {
"logged_in": True, "auth_store": str(_auth_file_path()),
"last_refresh": rate_limit.get("last_refresh"), "auth_mode": "chatgpt",
"source": f"pool:{rate_limit.get('label') or 'unknown'}", "rate_limited": True,
"error_code": CODEX_RATE_LIMITED_CODE,
"error": (rate_limit.get("message")
or "Codex provider quota exhausted; retry after the usage limit resets."),
"reset_at": rate_limit.get("reset_at")}
def get_codex_auth_status() -> Dict[str, Any]:
"""Status snapshot for Codex auth (pool first, then legacy provider state)."""
return _pool_first_oauth_status(
"openai-codex", is_expiring=_codex_access_token_is_expiring, auth_mode="chatgpt",
resolve=resolve_codex_runtime_credentials, on_pool_miss=_codex_pool_rate_limited_status)
def get_xai_oauth_auth_status() -> Dict[str, Any]:
# auth_mode is display/telemetry only; device-code is the only xAI OAuth flow, so report it
# unconditionally (auth.json may still carry a legacy ``oauth_pkce`` label).
return _pool_first_oauth_status(
"xai-oauth", is_expiring=_xai_access_token_is_expiring, auth_mode="oauth_device_code",
resolve=resolve_xai_oauth_runtime_credentials)
def _provider_env_base_url(pconfig: ProviderConfig) -> str:
if pconfig.id == "actual":
from hermes_cli.providers import normalize_provider
model = read_raw_config().get("model")
if isinstance(model, dict) and normalize_provider(str(model.get("provider") or "")) == "actual":
configured_url = str(model.get("base_url") or "").strip()
if configured_url:
return configured_url
return os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
def _provider_is_keyless(provider_id: str) -> bool:
"""HermesOverlay keyless flag β the same source the provider catalog and GUI contract tests use."""
try:
from hermes_cli.providers import HERMES_OVERLAYS
return bool(getattr(HERMES_OVERLAYS.get(provider_id), "keyless", False))
except Exception:
return False
def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]:
"""Status snapshot for API-key providers (z.ai, Kimi, MiniMax)."""
pconfig = PROVIDER_REGISTRY.get(provider_id)
if not pconfig or pconfig.auth_type != "api_key":
return {"configured": False}
status = {
"configured": True, "provider": provider_id, "name": pconfig.name, "key_source": "keyless",
"base_url": pconfig.inference_base_url, "logged_in": True}
if _provider_is_keyless(provider_id):
# Keyless providers (opencode-free) are served anonymously: every install counts as
# configured.
return status
api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
env_url = _provider_env_base_url(pconfig)
if provider_id in {"kimi-coding", "kimi-coding-cn"}:
base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url)
else:
base_url = env_url or pconfig.inference_base_url
actual_local_noauth = False
if provider_id == "actual":
base_url = normalize_actual_base_url(base_url)
actual_local_noauth = not api_key and is_actual_local_base_url(base_url)
configured = bool(api_key) or actual_local_noauth
status.update( # logged_in mirrors configured for compat with the OAuth status shape
configured=configured, base_url=base_url, logged_in=configured,
key_source=key_source or ("local-offline" if actual_local_noauth else ""))
return status
def _external_process_auth_evidence(provider_id: str) -> tuple[bool, Optional[str]]:
"""Best-effort POSITIVE evidence ``(verified, source)`` that an external-process CLI is authed.
False means "not verifiable from here", NOT "signed out" (the Copilot CLI may use an OS keychain
Hermes can't read). Deliberately subprocess-free: spawning ``gh auth token`` from status
endpoints/pickers re-creates the cold-start stall copilot_auth.py avoids."""
if provider_id != "copilot-acp":
return False, None
# 1. Supported env tokens β the same vars the Copilot CLI itself honors.
try:
from hermes_cli.copilot_auth import COPILOT_ENV_VARS, validate_copilot_token
for env_var in COPILOT_ENV_VARS:
val = os.getenv(env_var, "").strip()
if val and validate_copilot_token(val)[0]:
return True, f"env: {env_var}"
except Exception as exc:
logger.debug("copilot-acp env token evidence check failed: %s", exc)
# 2. The Copilot CLI's own plaintext token store (written by `copilot login` when no OS keychain
# is available). The file is JSONC β strip //-comment lines before parsing.
try:
cli_config = os.path.expanduser("~/.copilot/config.json")
if os.path.isfile(cli_config):
with open(cli_config, "r", encoding="utf-8", errors="ignore") as fh:
raw = "\n".join(
line for line in fh.read().splitlines() if not line.lstrip().startswith("//"))
tokens = (json.loads(raw) if raw.strip() else {}).get("copilotTokens")
if isinstance(tokens, dict) and any(
isinstance(v, str) and v.strip() for v in tokens.values()):
return True, "~/.copilot/config.json"
except Exception as exc:
logger.debug("copilot-acp CLI config evidence check failed: %s", exc)
# 3. Known on-disk GitHub Copilot credential stores (the same files models.py fingerprints).
for cred_path in ("~/.config/github-copilot/hosts.json", "~/.config/github-copilot/apps.json"):
try:
expanded = os.path.expanduser(cred_path)
if os.path.isfile(expanded) and os.path.getsize(expanded) > 2:
return True, cred_path
except OSError:
continue
return False, None
def _external_process_spec(
pconfig: ProviderConfig) -> tuple[str, List[str], str, Optional[str], tuple[str, ...]]:
"""``(command, args, base_url, resolved_command, command_env_vars)`` for an ACP provider.
Launch details come from the provider's own profile (copilot-acp: HERMES_COPILOT_ACP_COMMAND /
COPILOT_CLI_PATH / HERMES_COPILOT_ACP_ARGS), so out-of-tree providers describe their binary."""
base_url = _provider_env_base_url(pconfig) or pconfig.inference_base_url
try:
from providers import get_provider_profile as _get_provider_profile
profile = _get_provider_profile(pconfig.id)
except Exception:
profile = None
command_env_vars = tuple(getattr(profile, "process_command_env_vars", ()) or ())
args_env_var = str(getattr(profile, "process_args_env_var", "") or "")
command = (next((v for v in (os.getenv(var, "").strip() for var in command_env_vars) if v), "")
or str(getattr(profile, "process_command", "") or ""))
raw_args = os.getenv(args_env_var, "").strip() if args_env_var else ""
args = shlex.split(raw_args) if raw_args else list(getattr(profile, "process_args", ()) or [])
return command, args, base_url, shutil.which(command) if command else None, command_env_vars
def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]:
"""Status snapshot for providers that run a local subprocess.
``configured``/``logged_in`` are structural (executable resolves or TCP endpoint set): the
subprocess owns real auth. ``auth_verified``/``auth_source`` carry positive evidence only."""
pconfig = PROVIDER_REGISTRY.get(provider_id)
if not pconfig or pconfig.auth_type != "external_process":
return {"configured": False}
command, args, base_url, resolved_command, _ = _external_process_spec(pconfig)
available = bool(resolved_command or base_url.startswith("acp+tcp://"))
auth_verified, auth_source = _external_process_auth_evidence(provider_id)
return {
"configured": available, "provider": provider_id, "name": pconfig.name, "command": command,
"args": args, "resolved_command": resolved_command, "base_url": base_url,
"logged_in": available, "auth_verified": auth_verified, "auth_source": auth_source}
def _get_aws_sdk_auth_status(target: str) -> Dict[str, Any]:
"""AWS SDK providers (Bedrock) β check via boto3 credential chain."""
try:
from agent.bedrock_adapter import has_aws_credentials
return {"logged_in": has_aws_credentials(), "provider": target}
except ImportError:
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
"""Generic auth status dispatcher: bespoke builders (``OAUTH_PROVIDER_FLOWS`` plus Spotify /
Azure Foundry) first, then the registry ``auth_type`` so a whole provider class (e.g. every
external-process ACP backend) gets a real status. Builders are looked up by NAME at call time so
tests that patch ``hermes_cli.auth.get_*_auth_status`` still apply."""
target = (provider_id or get_active_provider() or "").strip().lower()
if not target:
return {"logged_in": False}
status_fn_name = _BESPOKE_STATUS_FUNCTIONS.get(target)
if status_fn_name:
return globals()[status_fn_name]()
pconfig = PROVIDER_REGISTRY.get(target)
if pconfig and pconfig.auth_type in _STATUS_BY_AUTH_TYPE:
return globals()[_STATUS_BY_AUTH_TYPE[pconfig.auth_type]](target)
return {"logged_in": False}
# Bespoke status builders (name -> looked up in this module at call time) win over the
# auth_type-keyed fallbacks below.
_BESPOKE_STATUS_FUNCTIONS: Dict[str, str] = {
**{pid: flow.status_fn for pid, flow in OAUTH_PROVIDER_FLOWS.items()},
"spotify": "get_spotify_auth_status",
"azure-foundry": "_get_azure_foundry_auth_status"}
_STATUS_BY_AUTH_TYPE: Dict[str, str] = {
"external_process": "get_external_process_provider_status",
"api_key": "get_api_key_provider_status",
"aws_sdk": "_get_aws_sdk_auth_status"}
def _get_azure_foundry_auth_status() -> Dict[str, Any]:
"""Structural auth status for Azure Foundry.
``entra_id``: ``azure-identity`` importable β never invokes the Entra credential chain (keeps
CLI startup flat; ``hermes doctor`` runs the live probe). ``api_key`` (default): usable
``AZURE_FOUNDRY_API_KEY``."""
info: Dict[str, Any] = {"provider": "azure-foundry"}
try:
from hermes_cli.config import load_config, get_env_value_prefer_dotenv
cfg = load_config()
except Exception:
cfg = {}
model_cfg = cfg.get("model") if isinstance(cfg, dict) else None
if not isinstance(model_cfg, dict):
model_cfg = {}
auth_mode = str(model_cfg.get("auth_mode") or "api_key").strip().lower() or "api_key"
info["auth_mode"] = auth_mode
info["base_url"] = str(model_cfg.get("base_url") or "").strip()
if auth_mode == "entra_id":
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed)
installed = has_azure_identity_installed()
entra_cfg = model_cfg["entra"] if isinstance(model_cfg.get("entra"), dict) else {}
identity_config = EntraIdentityConfig.from_dict(entra_cfg, default_scope=SCOPE_AI_AZURE_DEFAULT)
info.update(
azure_identity_installed=installed, scope=identity_config.scope, credential_probe="not_run",
credential_verified=False, logged_in=bool(installed),
hint=(
"azure-identity is installed; live credential validation "
"is skipped here. Run `hermes doctor` to verify token acquisition."
) if installed else (
"azure-identity not installed. Install with: "
"pip install azure-identity (or rely on Hermes' "
"lazy-install at first use)."))
except Exception as exc:
info["logged_in"] = False
info["error"] = f"azure-identity check failed: {exc}"
return info
try:
api_key = get_env_value_prefer_dotenv("AZURE_FOUNDRY_API_KEY") or ""
except Exception:
api_key = os.getenv("AZURE_FOUNDRY_API_KEY", "")
info["logged_in"] = has_usable_secret(api_key)
return info
def _default_api_key_base_url(api_key: str, default: str, env_url: str) -> str:
return env_url.rstrip("/") if env_url else default
def _copilot_runtime_base_url(api_key: str, default: str, env_url: str) -> str:
"""Copilot's API base comes from the token-exchange response (endpoints.api, proxy-ep fallback),
authoritative for Enterprise / proxied accounts; falls back to the registry default."""
base_url = _default_api_key_base_url(api_key, default, env_url)
try:
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
raw_token, _ = resolve_copilot_token()
if raw_token:
resolved = (get_copilot_api_token(raw_token)[1] or "").strip()
if resolved:
base_url = resolved
except Exception as exc:
logger.debug("Copilot base URL resolution fell back to default: %s", exc)
return base_url
# Providers whose runtime base URL is not simply env-override-or-registry-default:
# ``(api_key, registry_default, env_override) -> base_url``.
_API_KEY_BASE_URL_RESOLVERS: Dict[str, Callable[[str, str, str], str]] = {
"kimi-coding": _resolve_kimi_base_url,
"kimi-coding-cn": _resolve_kimi_base_url,
"zai": _resolve_zai_base_url,
"copilot": _copilot_runtime_base_url,
"lmstudio": lambda *a: _normalize_lmstudio_runtime_base_url(_default_api_key_base_url(*a)),
"actual": lambda *a: normalize_actual_base_url(_default_api_key_base_url(*a))}
def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
"""Resolve API key and base URL for an API-key provider."""
pconfig = PROVIDER_REGISTRY.get(provider_id)
if not pconfig or pconfig.auth_type != "api_key":
raise AuthError(
f"Provider '{provider_id}' is not an API-key provider.",
provider=provider_id, code="invalid_provider")
api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
# No-auth LM Studio: a placeholder so runtime / auxiliary_client see the local server as
# configured. doctor still reports unconfigured because the status path uses the raw secret.
if not api_key and provider_id == "lmstudio":
api_key = LMSTUDIO_NOAUTH_PLACEHOLDER
key_source = key_source or "default"
env_url = _provider_env_base_url(pconfig)
resolve_url = _API_KEY_BASE_URL_RESOLVERS.get(provider_id, _default_api_key_base_url)
base_url = resolve_url(api_key, pconfig.inference_base_url, env_url)
# An API-key provider must never hand back an empty base URL (a set-but-empty
# COPILOT_API_BASE_URL or similar env override otherwise wedges chat inference).
if not _nonempty_str(base_url):
base_url = pconfig.inference_base_url
if not api_key and provider_id == "actual" and is_actual_local_base_url(base_url):
api_key = ACTUAL_LOCAL_NOAUTH_PLACEHOLDER
key_source = key_source or "local-offline"
return {
"provider": provider_id, "api_key": api_key, "base_url": base_url.rstrip("/"),
"source": key_source or "default"}
def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]:
"""Resolve runtime details for local subprocess-backed providers."""
pconfig = PROVIDER_REGISTRY.get(provider_id)
if not pconfig or pconfig.auth_type != "external_process":
raise AuthError(
f"Provider '{provider_id}' is not an external-process provider.",
provider=provider_id, code="invalid_provider")
command, args, base_url, resolved_command, command_env_vars = _external_process_spec(pconfig)
if not resolved_command and not base_url.startswith("acp+tcp://"):
_hint = " or set " + "/".join(command_env_vars) if command_env_vars else ""
raise AuthError(
f"Could not find the '{provider_id}' CLI command "
f"'{command or '(none configured)'}'. Install it{_hint}.",
provider=provider_id,
code="missing_external_process_cli")
# api_key is a placeholder: the subprocess owns real auth. Keyed on the provider id so each
# external-process provider gets a distinct value.
return {
"provider": provider_id, "api_key": pconfig.id or provider_id,
"base_url": base_url.rstrip("/"), "command": resolved_command or command, "args": args,
"source": "process"}
# ββ CLI Commands β login / logout βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _update_config_for_provider(
provider_id: str, inference_base_url: str, default_model: Optional[str] = None,
*, clear_default: bool = False) -> Path:
"""Update config.yaml and auth.json to reflect the active provider.
*default_model*, when given, is written as ``model.default`` in the same step so the gateway
(which re-reads config per message) can't pick up the new provider before model selection
finishes and send an OpenRouter-style ``vendor/model`` name to a direct API. *clear_default*
removes ``model.default`` in that same write, for a caller that has no model to offer and must
not leave the previous provider's model paired with the new host."""
with _auth_store_lock(): # so auto-resolution picks this provider
auth_store = _load_auth_store()
auth_store["active_provider"] = provider_id
_save_auth_store(auth_store)
config_path = get_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
require_readable_config_before_write(config_path)
config = read_raw_config()
current_model = config.get("model")
if isinstance(current_model, dict):
model_cfg = dict(current_model)
else:
model_cfg = {"default": current_model.strip()} if _nonempty_str(current_model) else {}
model_cfg["provider"] = provider_id
if inference_base_url and inference_base_url.strip():
model_cfg["base_url"] = inference_base_url.rstrip("/")
else:
model_cfg.pop("base_url", None) # clear stale base_url when switching providers
# Built-in providers resolve credentials from env/auth state, not inline model.api_key left
# over from a previous custom provider.
from hermes_cli.config import clear_model_endpoint_credentials
clear_model_endpoint_credentials(model_cfg)
# An OpenRouter-formatted default like "anthropic/claude-opus-4.6" fails on direct-API
# providers.
if default_model:
cur_default = model_cfg.get("default", "")
if not cur_default or "/" in cur_default:
model_cfg["default"] = default_model
elif clear_default:
model_cfg.pop("default", None)
config["model"] = model_cfg
atomic_yaml_write(config_path, config, sort_keys=False)
return config_path
def _get_config_provider() -> Optional[str]:
"""Return model.provider from config.yaml, normalized, if present."""
try:
config = read_raw_config()
except Exception:
return None
model = config.get("model") if config else None
provider = model.get("provider") if isinstance(model, dict) else None
return (provider.strip().lower() or None) if isinstance(provider, str) else None
def _should_reset_config_provider_on_logout(provider_id: Optional[str]) -> bool:
"""True when logout should reset model.provider (a registry provider config.yaml selects)."""
normalized = (provider_id or "").strip().lower()
return normalized in PROVIDER_REGISTRY and _get_config_provider() == normalized
def _logout_default_provider_from_config() -> Optional[str]:
"""Fallback logout target when auth.json has no active provider but config.yaml still selects an
OAuth provider (e.g. openai-codex) β otherwise logout said "No provider is currently logged in"
and never reset model.provider."""
provider = _get_config_provider()
flow = OAUTH_PROVIDER_FLOWS.get(provider or "")
return provider if flow and flow.logout_from_config else None
def _reset_config_provider() -> Path:
"""Reset config.yaml provider back to auto after logout."""
config_path = get_config_path()
if not config_path.exists():
return config_path
require_readable_config_before_write(config_path)
config = read_raw_config()
if not config:
return config_path
model = config.get("model")
if isinstance(model, dict):
model["provider"] = "auto"
if "base_url" in model:
model["base_url"] = OPENROUTER_BASE_URL
atomic_yaml_write(config_path, config, sort_keys=False)
return config_path
def login_command(args) -> None:
"""Deprecated: use 'hermes model' or 'hermes setup' instead."""
print("The 'hermes login' command has been removed.\nUse 'hermes auth' to manage credentials,\n"
"'hermes model' to select a provider, or 'hermes setup' for full setup.")
raise SystemExit(0)
def get_minimax_oauth_auth_status() -> Dict[str, Any]:
"""Return auth status dict for MiniMax OAuth provider."""
state = get_provider_auth_state("minimax-oauth")
if not state or not state.get("access_token"):
return {"logged_in": False, "provider": "minimax-oauth"}
try:
token_valid = datetime.fromisoformat(state.get("expires_at", "")).timestamp() > time.time()
except Exception:
token_valid = True # access_token is known non-empty here
return {
"logged_in": token_valid, "provider": "minimax-oauth",
"region": state.get("region", "global"), "expires_at": state.get("expires_at")}
def logout_command(args) -> None:
"""Clear auth state for a provider."""
provider_id = getattr(args, "provider", None)
if provider_id and not is_known_auth_provider(provider_id):
print(f"Unknown provider: {provider_id}")
raise SystemExit(1)
target = provider_id or get_active_provider() or _logout_default_provider_from_config()
if not target:
print("No provider is currently logged in.")
return
if target == "nous":
from hermes_cli.anon_auth import FREE_TIER_NOT_SIGNED_IN, is_guest_state
if is_guest_state(get_provider_auth_state("nous")):
# Free tier is not a login; there is nothing to log out of and nothing is cleared.
print(FREE_TIER_NOT_SIGNED_IN)
return
should_reset_config = _should_reset_config_provider_on_logout(target)
provider_name = get_auth_provider_display_name(target)
if not (clear_provider_auth(target) or should_reset_config):
print(f"No auth state found for {provider_name}.")
return
if target == "nous":
# A profile logout must not be re-adopted from the cross-profile store on the next boot.
from hermes_cli.auth_nous import _clear_shared_nous_state
_clear_shared_nous_state("logout")
if should_reset_config:
_reset_config_provider()
print(f"Logged out of {provider_name}.")
if not should_reset_config:
print("Model provider configuration was unchanged.")
elif os.getenv("OPENROUTER_API_KEY"):
print("Hermes will use OpenRouter for inference.")
else:
print("Run `hermes model` or configure an API key to use Hermes.")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from http.server import BaseHTTPRequestHandler # noqa: F401,E402
from http.server import HTTPServer # noqa: F401,E402
from typing import TYPE_CHECKING # noqa: F401,E402
import base64 # noqa: F401,E402
import hashlib # noqa: F401,E402
from urllib.parse import parse_qs # noqa: F401,E402
import ssl # noqa: F401,E402
import subprocess # noqa: F401,E402
import sys # noqa: F401,E402
from urllib.parse import urlencode # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'CODEX_OAUTH_USER_AGENT': ('hermes_cli.auth_constants', 'CODEX_OAUTH_USER_AGENT'),
'CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS': ('hermes_cli.auth_codex', 'CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS'),
'DEFAULT_SPOTIFY_REDIRECT_URI': ('hermes_cli.auth_constants', 'DEFAULT_SPOTIFY_REDIRECT_URI'),
'DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS': ('hermes_cli.auth_constants', 'DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS'),
'MINIMAX_OAUTH_GRANT_TYPE': ('hermes_cli.auth_constants', 'MINIMAX_OAUTH_GRANT_TYPE'),
'NOUS_INFERENCE_INVOKE_SCOPE': ('hermes_cli.auth_constants', 'NOUS_INFERENCE_INVOKE_SCOPE'),
'NOUS_SHARED_STORE_FILENAME': ('hermes_cli.auth_nous', 'NOUS_SHARED_STORE_FILENAME'),
'OAUTH_OVER_SSH_DOCS_URL': ('hermes_cli.auth_constants', 'OAUTH_OVER_SSH_DOCS_URL'),
'QWEN_OAUTH_CLIENT_ID': ('hermes_cli.auth_constants', 'QWEN_OAUTH_CLIENT_ID'),
'QWEN_OAUTH_TOKEN_URL': ('hermes_cli.auth_constants', 'QWEN_OAUTH_TOKEN_URL'),
'SINGLE_USE_OAUTH_SINGLETON_FILES': ('hermes_cli.auth_oauth_grants', 'SINGLE_USE_OAUTH_SINGLETON_FILES'),
'SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS': ('hermes_cli.auth_constants', 'SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS'),
'SPOTIFY_DASHBOARD_URL': ('hermes_cli.auth_constants', 'SPOTIFY_DASHBOARD_URL'),
'XAI_OAUTH_DEVICE_CODE_URL': ('hermes_cli.auth_constants', 'XAI_OAUTH_DEVICE_CODE_URL'),
'XAI_OAUTH_DISCOVERY_URL': ('hermes_cli.auth_constants', 'XAI_OAUTH_DISCOVERY_URL'),
'XAI_OAUTH_ISSUER': ('hermes_cli.auth_constants', 'XAI_OAUTH_ISSUER'),
'refresh_nous_oauth_pure': ('hermes_cli.auth_nous', 'refresh_nous_oauth_pure'),
}
def __getattr__(name): # PEP 562 β lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
|