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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- 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()
|