pi-ai-python 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,127 @@
1
+ """RFC 8628 device authorization polling shared by the device-code login flows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass
8
+ from typing import Any, ClassVar, Generic, TypeAlias, TypeVar
9
+
10
+ from ...abort import AbortError, AbortSignal
11
+ from . import _common
12
+ from ._common import OAuthError
13
+
14
+ __all__ = [
15
+ "DeviceCodePending",
16
+ "DeviceCodeSlowDown",
17
+ "DeviceCodeFailed",
18
+ "DeviceCodeComplete",
19
+ "DeviceCodePollResult",
20
+ "abortable_sleep",
21
+ "poll_oauth_device_code_flow",
22
+ ]
23
+
24
+ T = TypeVar("T")
25
+
26
+ CANCEL_MESSAGE = "Login cancelled"
27
+ TIMEOUT_MESSAGE = "Device flow timed out"
28
+ SLOW_DOWN_TIMEOUT_MESSAGE = (
29
+ "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL "
30
+ "or VM environments. Please sync or restart the VM clock and try again."
31
+ )
32
+ MINIMUM_INTERVAL_MS = 1000
33
+ # RFC 8628 section 3.2: if the authorization server omits `interval`, the client must use 5 seconds.
34
+ DEFAULT_POLL_INTERVAL_SECONDS = 5
35
+ # RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds.
36
+ SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class DeviceCodePending:
41
+ status: ClassVar[str] = "pending"
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class DeviceCodeSlowDown:
46
+ #: New minimum interval reported by the server, if any.
47
+ interval_seconds: float | None = None
48
+ status: ClassVar[str] = "slow_down"
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class DeviceCodeFailed:
53
+ message: str
54
+ status: ClassVar[str] = "failed"
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class DeviceCodeComplete(Generic[T]):
59
+ value: T
60
+ status: ClassVar[str] = "complete"
61
+
62
+
63
+ DeviceCodePollResult: TypeAlias = "DeviceCodePending | DeviceCodeSlowDown | DeviceCodeFailed | DeviceCodeComplete[Any]"
64
+
65
+
66
+ async def abortable_sleep(ms: float, signal: AbortSignal, cancel_message: str) -> None:
67
+ """Sleep ``ms`` milliseconds; raise :class:`AbortError` with ``cancel_message`` on abort."""
68
+ if signal.aborted:
69
+ raise AbortError(cancel_message)
70
+ try:
71
+ await _common.sleep(ms / 1000, signal)
72
+ except AbortError:
73
+ raise AbortError(cancel_message) from None
74
+
75
+
76
+ async def poll_oauth_device_code_flow(
77
+ *,
78
+ poll: Callable[[], Awaitable[DeviceCodePollResult]],
79
+ signal: AbortSignal,
80
+ interval_seconds: float | None = None,
81
+ expires_in_seconds: float | None = None,
82
+ wait_before_first_poll: bool = False,
83
+ ) -> Any:
84
+ """Poll until ``poll`` completes, fails, or the device code expires.
85
+
86
+ Polls immediately unless ``wait_before_first_poll``; ``slow_down`` raises the
87
+ interval to the server-provided value, or by five seconds.
88
+ """
89
+ deadline = _common.now_ms() + expires_in_seconds * 1000 if _common.is_number(expires_in_seconds) else math.inf
90
+ interval_ms = max(
91
+ MINIMUM_INTERVAL_MS,
92
+ math.floor((interval_seconds if interval_seconds is not None else DEFAULT_POLL_INTERVAL_SECONDS) * 1000),
93
+ )
94
+
95
+ slow_down_responses = 0
96
+ if wait_before_first_poll:
97
+ remaining_ms = deadline - _common.now_ms()
98
+ if remaining_ms > 0:
99
+ await abortable_sleep(min(interval_ms, remaining_ms), signal, CANCEL_MESSAGE)
100
+
101
+ while _common.now_ms() < deadline:
102
+ if signal.aborted:
103
+ raise AbortError(CANCEL_MESSAGE)
104
+
105
+ result = await poll()
106
+ if isinstance(result, DeviceCodeComplete):
107
+ return result.value
108
+ if isinstance(result, DeviceCodeFailed):
109
+ raise OAuthError(result.message)
110
+ if isinstance(result, DeviceCodeSlowDown):
111
+ slow_down_responses += 1
112
+ # Use the server-provided interval when given (GitHub reports the new required minimum
113
+ # in `interval`); trusting only a client-tracked value risks polling early forever under
114
+ # WSL/VM clock drift. Otherwise apply RFC 8628 section 3.5: increase by 5 seconds.
115
+ server_interval = result.interval_seconds
116
+ if _common.is_number(server_interval) and math.isfinite(server_interval) and server_interval > 0:
117
+ interval_ms = max(MINIMUM_INTERVAL_MS, math.floor(server_interval * 1000))
118
+ else:
119
+ interval_ms = max(MINIMUM_INTERVAL_MS, interval_ms + SLOW_DOWN_INTERVAL_INCREMENT_MS)
120
+
121
+ remaining_ms = deadline - _common.now_ms()
122
+ if remaining_ms <= 0:
123
+ break
124
+
125
+ await abortable_sleep(min(interval_ms, remaining_ms), signal, CANCEL_MESSAGE)
126
+
127
+ raise OAuthError(SLOW_DOWN_TIMEOUT_MESSAGE if slow_down_responses > 0 else TIMEOUT_MESSAGE)
@@ -0,0 +1,508 @@
1
+ """GitHub Copilot OAuth flow (GitHub device code, then a Copilot API token)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import email.utils
8
+ import math
9
+ import re
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from ...abort import AbortController, AbortSignal
17
+ from ..types import AuthEvent, AuthPrompt, Credential, LoginOptions, ModelAuth, OAuthAuth
18
+ from ..types import ProviderAuthInteraction as Interaction
19
+ from . import _common
20
+ from ._common import OAuthError, form_encode, json_stringify, parse_url
21
+ from .device_code import (
22
+ DeviceCodeComplete,
23
+ DeviceCodeFailed,
24
+ DeviceCodePending,
25
+ DeviceCodePollResult,
26
+ DeviceCodeSlowDown,
27
+ poll_oauth_device_code_flow,
28
+ )
29
+
30
+ __all__ = [
31
+ "GitHubCopilotOAuth",
32
+ "github_copilot_oauth",
33
+ "get_github_copilot_base_url",
34
+ "normalize_domain",
35
+ ]
36
+
37
+ CLIENT_ID = base64.b64decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=").decode()
38
+
39
+ COPILOT_HEADERS: Mapping[str, str] = {
40
+ "User-Agent": "GitHubCopilotChat/0.35.0",
41
+ "Editor-Version": "vscode/1.107.0",
42
+ "Editor-Plugin-Version": "copilot-chat/0.35.0",
43
+ "Copilot-Integration-Id": "vscode-chat",
44
+ }
45
+ COPILOT_API_VERSION = "2026-06-01"
46
+ INDIVIDUAL_BASE_URL = "https://api.individual.githubcopilot.com"
47
+ _REQUEST_TIMEOUT_MS = 5000
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class _DeviceCodeResponse:
52
+ device_code: str
53
+ user_code: str
54
+ verification_uri: str
55
+ interval: float | None
56
+ expires_in: float
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class _RetryPolicy:
61
+ max_retries: int
62
+ max_elapsed_ms: int
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class _ModelCatalog:
67
+ available_model_ids: list[str]
68
+ policy_model_ids: list[str]
69
+
70
+
71
+ def normalize_domain(value: str) -> str | None:
72
+ """The hostname of a GitHub Enterprise URL or bare domain, or ``None`` when invalid."""
73
+ trimmed = value.strip()
74
+ if not trimmed:
75
+ return None
76
+ url = parse_url(trimmed if "://" in trimmed else f"https://{trimmed}")
77
+ return url.hostname if url is not None else None
78
+
79
+
80
+ def _get_urls(domain: str) -> dict[str, str]:
81
+ return {
82
+ "device_code_url": f"https://{domain}/login/device/code",
83
+ "access_token_url": f"https://{domain}/login/oauth/access_token",
84
+ "copilot_token_url": f"https://api.{domain}/copilot_internal/v2/token",
85
+ }
86
+
87
+
88
+ def _get_base_url_from_token(token: str) -> str | None:
89
+ """API base URL from the ``proxy-ep`` of a Copilot token.
90
+
91
+ Token format: ``tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;...``
92
+ returns ``https://api.individual.githubcopilot.com``.
93
+ """
94
+ match = re.search(r"proxy-ep=([^;]+)", token)
95
+ if match is None:
96
+ return None
97
+ # Convert proxy.xxx to api.xxx
98
+ api_host = re.sub(r"^proxy\.", "api.", match.group(1))
99
+ return f"https://{api_host}"
100
+
101
+
102
+ def get_github_copilot_base_url(token: str | None = None, enterprise_domain: str | None = None) -> str:
103
+ """Copilot API base URL: the token's proxy endpoint, the enterprise endpoint, or Individual."""
104
+ # If we have a token, extract the base URL from proxy-ep
105
+ if token:
106
+ url_from_token = _get_base_url_from_token(token)
107
+ if url_from_token:
108
+ return url_from_token
109
+ # Fallback for enterprise or if token parsing fails
110
+ if enterprise_domain:
111
+ return f"https://copilot-api.{enterprise_domain}"
112
+ return INDIVIDUAL_BASE_URL
113
+
114
+
115
+ def _record(value: Any) -> dict[str, Any] | None:
116
+ return value if isinstance(value, dict) else None
117
+
118
+
119
+ def _known_model_ids() -> frozenset[str]:
120
+ from ...providers.catalog import load_catalog
121
+
122
+ return frozenset(load_catalog("github-copilot")["chat"])
123
+
124
+
125
+ def _parse_model_catalog(raw: Any, allow_policy_fallback: bool) -> _ModelCatalog:
126
+ record = _record(raw)
127
+ data = record.get("data") if record is not None else None
128
+ if not isinstance(data, list):
129
+ raise OAuthError("Invalid Copilot models response")
130
+
131
+ account_models: list[tuple[str, bool, Any]] = []
132
+ for raw_item in data:
133
+ item = _record(raw_item)
134
+ model_id = item.get("id") if item is not None else None
135
+ if item is None or not isinstance(model_id, str):
136
+ continue
137
+ capabilities = _record(item.get("capabilities"))
138
+ supports = _record(capabilities.get("supports")) if capabilities is not None else None
139
+ if supports is not None and supports.get("tool_calls") is False:
140
+ continue
141
+ policy = _record(item.get("policy"))
142
+ account_models.append(
143
+ (model_id, item.get("model_picker_enabled") is True, policy.get("state") if policy is not None else None)
144
+ )
145
+
146
+ picker_model_ids = [
147
+ model_id for model_id, picker_enabled, state in account_models if picker_enabled and state != "disabled"
148
+ ]
149
+ use_policy_fallback = allow_policy_fallback and not picker_model_ids
150
+ if picker_model_ids or not allow_policy_fallback:
151
+ available_model_ids = picker_model_ids
152
+ else:
153
+ available_model_ids = [model_id for model_id, _, state in account_models if state == "enabled"]
154
+ known = _known_model_ids()
155
+ policy_model_ids = [
156
+ model_id
157
+ for model_id, picker_enabled, state in account_models
158
+ if state == "unconfigured" and model_id in known and (picker_enabled or use_policy_fallback)
159
+ ]
160
+ return _ModelCatalog(available_model_ids, policy_model_ids)
161
+
162
+
163
+ _FLOAT_PREFIX = re.compile(r"^\s*([+-]?(?:\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?|Infinity))")
164
+
165
+
166
+ def _parse_float(value: str) -> float:
167
+ """``Number.parseFloat``: the leading numeric prefix, or ``NaN``."""
168
+ match = _FLOAT_PREFIX.match(value)
169
+ if match is None:
170
+ return math.nan
171
+ return float(match.group(1).replace("Infinity", "inf"))
172
+
173
+
174
+ def _parse_http_date_ms(value: str) -> float:
175
+ try:
176
+ parsed = email.utils.parsedate_to_datetime(value)
177
+ except (TypeError, ValueError, IndexError):
178
+ return math.nan
179
+ if parsed is None:
180
+ return math.nan
181
+ return parsed.timestamp() * 1000
182
+
183
+
184
+ async def _fetch_with_rate_limit_retry(
185
+ url: str,
186
+ signal: AbortSignal,
187
+ retry_policy: _RetryPolicy,
188
+ *,
189
+ method: str = "GET",
190
+ headers: Mapping[str, str],
191
+ body: str | None = None,
192
+ ) -> httpx.Response:
193
+ use_budget = retry_policy.max_retries > 0 and retry_policy.max_elapsed_ms > 0
194
+ request_controller = AbortController()
195
+ remove = signal.add_listener(lambda: request_controller.abort(signal.reason))
196
+ budget_timer = None
197
+ retry_deadline: float | None = None
198
+ if use_budget:
199
+ budget_timer = asyncio.get_running_loop().call_later(
200
+ retry_policy.max_elapsed_ms / 1000,
201
+ request_controller.abort,
202
+ TimeoutError("The operation was aborted due to timeout"),
203
+ )
204
+ retry_deadline = _common.now_ms() + retry_policy.max_elapsed_ms
205
+ request_signal = request_controller.signal
206
+ try:
207
+ retry = 0
208
+ while True:
209
+ response = await _common.fetch(
210
+ url, method=method, headers=headers, body=body, signal=request_signal, timeout_ms=_REQUEST_TIMEOUT_MS
211
+ )
212
+ if response.status_code != 429 or retry == retry_policy.max_retries:
213
+ return response
214
+
215
+ retry_after = response.headers.get("retry-after")
216
+ delay_ms = 500 * 2**retry
217
+ if retry_after:
218
+ seconds = _parse_float(retry_after)
219
+ delay_ms = (
220
+ _parse_http_date_ms(retry_after) - _common.now_ms() if math.isnan(seconds) else seconds * 1000
221
+ )
222
+ if not math.isfinite(delay_ms):
223
+ return response
224
+ delay_ms = max(0, delay_ms)
225
+ if retry_deadline is not None and delay_ms >= retry_deadline - _common.now_ms():
226
+ return response
227
+ await response.aclose()
228
+ await _common.sleep(delay_ms / 1000, request_signal)
229
+ retry += 1
230
+ finally:
231
+ remove()
232
+ if budget_timer is not None:
233
+ budget_timer.cancel()
234
+
235
+
236
+ def _status_error(response: httpx.Response) -> OAuthError:
237
+ return OAuthError(f"{response.status_code} {_common.status_text(response)}: {response.text}")
238
+
239
+
240
+ async def _fetch_models(
241
+ copilot_token: str, enterprise_domain: str | None, signal: AbortSignal, retry_policy: _RetryPolicy
242
+ ) -> _ModelCatalog:
243
+ base_url = get_github_copilot_base_url(copilot_token, enterprise_domain)
244
+ # Some Individual accounts return false for every picker flag despite explicit enabled policies.
245
+ # Limit the fallback to that endpoint so other account types keep strict picker semantics.
246
+ allow_policy_fallback = base_url == INDIVIDUAL_BASE_URL
247
+ response = await _fetch_with_rate_limit_retry(
248
+ f"{base_url}/models",
249
+ signal,
250
+ retry_policy,
251
+ headers={
252
+ "Accept": "application/json",
253
+ "Authorization": f"Bearer {copilot_token}",
254
+ **COPILOT_HEADERS,
255
+ "X-GitHub-Api-Version": COPILOT_API_VERSION,
256
+ },
257
+ )
258
+ if not response.is_success:
259
+ raise _status_error(response)
260
+ return _parse_model_catalog(_common.parse_json(response), allow_policy_fallback)
261
+
262
+
263
+ async def _fetch_json(url: str, signal: AbortSignal, **kwargs: Any) -> Any:
264
+ response = await _common.fetch(url, signal=signal, **kwargs)
265
+ if not response.is_success:
266
+ raise _status_error(response)
267
+ return _common.parse_json(response)
268
+
269
+
270
+ async def _start_device_flow(domain: str, signal: AbortSignal) -> _DeviceCodeResponse:
271
+ urls = _get_urls(domain)
272
+ data = await _fetch_json(
273
+ urls["device_code_url"],
274
+ signal,
275
+ method="POST",
276
+ headers={
277
+ "Accept": "application/json",
278
+ "Content-Type": "application/x-www-form-urlencoded",
279
+ "User-Agent": "GitHubCopilotChat/0.35.0",
280
+ },
281
+ body=form_encode({"client_id": CLIENT_ID, "scope": "read:user"}),
282
+ )
283
+ if not isinstance(data, dict):
284
+ raise OAuthError("Invalid device code response")
285
+
286
+ device_code = data.get("device_code")
287
+ user_code = data.get("user_code")
288
+ verification_uri = data.get("verification_uri")
289
+ interval = data.get("interval")
290
+ expires_in = data.get("expires_in")
291
+ if (
292
+ not isinstance(device_code, str)
293
+ or not isinstance(user_code, str)
294
+ or not isinstance(verification_uri, str)
295
+ or ("interval" in data and not _common.is_number(interval))
296
+ or not _common.is_number(expires_in)
297
+ ):
298
+ raise OAuthError("Invalid device code response fields")
299
+
300
+ # The verification URI is opened in the user's browser and to prevent the launcher from
301
+ # opening an executable or similar, we force it to be a URL.
302
+ parsed_uri = parse_url(verification_uri)
303
+ if parsed_uri is None or parsed_uri.protocol not in ("https:", "http:"):
304
+ raise OAuthError("Untrusted verification_uri in device code response")
305
+
306
+ return _DeviceCodeResponse(device_code, user_code, parsed_uri.href, interval, expires_in)
307
+
308
+
309
+ async def _poll_for_github_access_token(domain: str, device: _DeviceCodeResponse, signal: AbortSignal) -> str:
310
+ urls = _get_urls(domain)
311
+
312
+ async def poll() -> DeviceCodePollResult:
313
+ raw = await _fetch_json(
314
+ urls["access_token_url"],
315
+ signal,
316
+ method="POST",
317
+ headers={
318
+ "Accept": "application/json",
319
+ "Content-Type": "application/x-www-form-urlencoded",
320
+ "User-Agent": "GitHubCopilotChat/0.35.0",
321
+ },
322
+ body=form_encode(
323
+ {
324
+ "client_id": CLIENT_ID,
325
+ "device_code": device.device_code,
326
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
327
+ }
328
+ ),
329
+ )
330
+ if isinstance(raw, dict) and isinstance(raw.get("access_token"), str):
331
+ return DeviceCodeComplete(raw["access_token"])
332
+
333
+ if isinstance(raw, dict) and isinstance(raw.get("error"), str):
334
+ error = raw["error"]
335
+ description = raw.get("error_description")
336
+ interval = raw.get("interval")
337
+ if error == "authorization_pending":
338
+ return DeviceCodePending()
339
+ if error == "slow_down":
340
+ return DeviceCodeSlowDown(interval if _common.is_number(interval) else None)
341
+ description_suffix = f": {description}" if description else ""
342
+ return DeviceCodeFailed(f"Device flow failed: {error}{description_suffix}")
343
+
344
+ return DeviceCodeFailed("Invalid device token response")
345
+
346
+ return await poll_oauth_device_code_flow(
347
+ poll=poll,
348
+ signal=signal,
349
+ interval_seconds=device.interval,
350
+ expires_in_seconds=device.expires_in,
351
+ wait_before_first_poll=True,
352
+ )
353
+
354
+
355
+ async def _refresh_copilot_access_token(
356
+ refresh_token: str, enterprise_domain: str | None, signal: AbortSignal
357
+ ) -> Credential:
358
+ domain = enterprise_domain or "github.com"
359
+ urls = _get_urls(domain)
360
+ raw = await _fetch_json(
361
+ urls["copilot_token_url"],
362
+ signal,
363
+ headers={"Accept": "application/json", "Authorization": f"Bearer {refresh_token}", **COPILOT_HEADERS},
364
+ )
365
+ if not isinstance(raw, dict):
366
+ raise OAuthError("Invalid Copilot token response")
367
+
368
+ token = raw.get("token")
369
+ expires_at = raw.get("expires_at")
370
+ if not isinstance(token, str) or not _common.is_number(expires_at):
371
+ raise OAuthError("Invalid Copilot token response fields")
372
+
373
+ credential: Credential = {
374
+ "type": "oauth",
375
+ "refresh": refresh_token,
376
+ "access": token,
377
+ "expires": int(expires_at * 1000) - 5 * 60 * 1000,
378
+ }
379
+ if enterprise_domain is not None:
380
+ credential["enterpriseUrl"] = enterprise_domain
381
+ return credential
382
+
383
+
384
+ async def _refresh_github_copilot_token(
385
+ refresh_token: str, enterprise_domain: str | None, signal: AbortSignal
386
+ ) -> Credential:
387
+ credentials = await _refresh_copilot_access_token(refresh_token, enterprise_domain, signal)
388
+ catalog = await _fetch_models(credentials["access"], enterprise_domain, signal, _RetryPolicy(0, 0))
389
+ return {**credentials, "availableModelIds": catalog.available_model_ids}
390
+
391
+
392
+ async def _enable_model(token: str, model_id: str, enterprise_domain: str | None, signal: AbortSignal) -> bool:
393
+ """Enable a model for the user's account (required for some models, like Claude and Grok)."""
394
+ base_url = get_github_copilot_base_url(token, enterprise_domain)
395
+ try:
396
+ response = await _fetch_with_rate_limit_retry(
397
+ f"{base_url}/models/{model_id}/policy",
398
+ signal,
399
+ _RetryPolicy(2, 5000),
400
+ method="POST",
401
+ headers={
402
+ "Content-Type": "application/json",
403
+ "Authorization": f"Bearer {token}",
404
+ **COPILOT_HEADERS,
405
+ "openai-intent": "chat-policy",
406
+ "x-interaction-type": "chat-policy",
407
+ },
408
+ body=json_stringify({"state": "enabled"}),
409
+ )
410
+ except Exception:
411
+ if signal.aborted:
412
+ raise
413
+ return False
414
+ if response.status_code == 429:
415
+ raise _status_error(response)
416
+ return response.is_success
417
+
418
+
419
+ async def _enable_models(
420
+ token: str, model_ids: Sequence[str], enterprise_domain: str | None, signal: AbortSignal
421
+ ) -> list[str]:
422
+ """Enable the requested models and return the successful ids.
423
+
424
+ Policy updates are best effort; exhausted rate limiting stops the batch.
425
+ """
426
+ enabled: list[str] = []
427
+ for model_id in model_ids:
428
+ try:
429
+ if await _enable_model(token, model_id, enterprise_domain, signal):
430
+ enabled.append(model_id)
431
+ except Exception:
432
+ if signal.aborted:
433
+ raise
434
+ break
435
+ return enabled
436
+
437
+
438
+ async def _login_github_copilot(interaction: Interaction) -> Credential:
439
+ entered = await interaction.prompt(
440
+ AuthPrompt(
441
+ type="text",
442
+ message="GitHub Enterprise URL/domain (blank for github.com)",
443
+ placeholder="company.ghe.com",
444
+ )
445
+ )
446
+ if interaction.signal.aborted:
447
+ raise _common.login_cancelled()
448
+
449
+ trimmed = entered.strip()
450
+ enterprise_domain = normalize_domain(entered)
451
+ if trimmed and not enterprise_domain:
452
+ raise OAuthError("Invalid GitHub Enterprise URL/domain")
453
+ domain = enterprise_domain or "github.com"
454
+
455
+ device = await _start_device_flow(domain, interaction.signal)
456
+ interaction.notify(
457
+ AuthEvent(
458
+ type="device_code",
459
+ user_code=device.user_code,
460
+ verification_uri=device.verification_uri,
461
+ interval_seconds=device.interval,
462
+ expires_in_seconds=device.expires_in,
463
+ )
464
+ )
465
+
466
+ github_access_token = await _poll_for_github_access_token(domain, device, interaction.signal)
467
+ credentials = await _refresh_copilot_access_token(github_access_token, enterprise_domain, interaction.signal)
468
+ catalog = await _fetch_models(credentials["access"], enterprise_domain, interaction.signal, _RetryPolicy(2, 5000))
469
+ enabled_model_ids: list[str] = []
470
+ if catalog.policy_model_ids:
471
+ interaction.notify(AuthEvent(type="progress", message="Enabling models..."))
472
+ enabled_model_ids = await _enable_models(
473
+ credentials["access"], catalog.policy_model_ids, enterprise_domain, interaction.signal
474
+ )
475
+ return {
476
+ **credentials,
477
+ "availableModelIds": list(dict.fromkeys([*catalog.available_model_ids, *enabled_model_ids])),
478
+ }
479
+
480
+
481
+ def _copilot_enterprise_domain(credential: Credential) -> str | None:
482
+ enterprise_url = credential.get("enterpriseUrl")
483
+ if not isinstance(enterprise_url, str) or not enterprise_url:
484
+ return None
485
+ return normalize_domain(enterprise_url)
486
+
487
+
488
+ class GitHubCopilotOAuth(OAuthAuth):
489
+ name = "GitHub Copilot"
490
+ is_subscription = True
491
+
492
+ async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
493
+ return await _login_github_copilot(interaction)
494
+
495
+ async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
496
+ return await _refresh_github_copilot_token(
497
+ credential["refresh"], _copilot_enterprise_domain(credential), signal
498
+ )
499
+
500
+ async def to_auth(self, credential: Credential) -> ModelAuth:
501
+ """Derive the credential-specific proxy endpoint for each request."""
502
+ return ModelAuth(
503
+ api_key=credential["access"],
504
+ base_url=get_github_copilot_base_url(credential["access"], _copilot_enterprise_domain(credential)),
505
+ )
506
+
507
+
508
+ github_copilot_oauth = GitHubCopilotOAuth()