copilot-python 0.1.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.
@@ -0,0 +1,27 @@
1
+ from copilot_wrapper import (
2
+ CopilotAuthenticationError,
3
+ CopilotClient,
4
+ CopilotError,
5
+ CopilotTimeoutError,
6
+ DEFAULT_GITHUB_OAUTH_CLIENT_ID,
7
+ DeviceFlowInfo,
8
+ GitHubOAuthDeviceFlow,
9
+ OpenAI,
10
+ ask_copilot,
11
+ create_client,
12
+ login_device_flow,
13
+ )
14
+
15
+ __all__ = [
16
+ "CopilotAuthenticationError",
17
+ "CopilotClient",
18
+ "CopilotError",
19
+ "CopilotTimeoutError",
20
+ "DEFAULT_GITHUB_OAUTH_CLIENT_ID",
21
+ "DeviceFlowInfo",
22
+ "GitHubOAuthDeviceFlow",
23
+ "OpenAI",
24
+ "ask_copilot",
25
+ "create_client",
26
+ "login_device_flow",
27
+ ]
@@ -0,0 +1,5 @@
1
+ from copilot_wrapper.__main__ import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
copilot_python/cli.py ADDED
@@ -0,0 +1,3 @@
1
+ from copilot_wrapper.cli import build_parser, main
2
+
3
+ __all__ = ["build_parser", "main"]
@@ -0,0 +1 @@
1
+ from copilot_wrapper.client import *
@@ -0,0 +1,239 @@
1
+ Metadata-Version: 2.4
2
+ Name: copilot-python
3
+ Version: 0.1.0
4
+ Summary: A Python wrapper for GitHub Copilot and GitHub Models using the official openai SDK.
5
+ Author: copilot-python contributors
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 copilot-python contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Keywords: github,copilot,cli,wrapper,python
29
+ Classifier: Programming Language :: Python :: 3
30
+ Classifier: Programming Language :: Python :: 3 :: Only
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Operating System :: OS Independent
33
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
34
+ Requires-Python: >=3.9
35
+ Description-Content-Type: text/markdown
36
+ License-File: LICENSE
37
+ Requires-Dist: openai>=1.30.0
38
+ Dynamic: license-file
39
+
40
+ # copilot-python
41
+
42
+ 一個使用官方 `openai` 套件實作的 GitHub Copilot / GitHub Models 包裝庫。
43
+
44
+ ## 功能
45
+
46
+ - 直接使用官方 `openai.OpenAI`
47
+ - 支援 GitHub OAuth Device Flow 取得 Token
48
+ - 支援手動提供 Fine-grained PAT / API Key
49
+ - 支援 Windows / macOS / Linux
50
+
51
+ ## 前置需求
52
+
53
+ 若使用 GitHub Models,常見端點為:
54
+
55
+ - `https://models.github.ai/inference`
56
+ - 或你的 OpenAI 相容代理端點
57
+
58
+ **內建 OAuth Client ID**:本庫已內建公開的 GitHub OAuth App client_id,可直接使用 OAuth Device Flow,無需額外設定。
59
+
60
+ ## 安裝本庫
61
+
62
+ ```bash
63
+ pip install .
64
+ ```
65
+
66
+ 或直接安裝依賴:
67
+
68
+ ```bash
69
+ pip install openai
70
+ ```
71
+
72
+ ## 快速開始
73
+
74
+ ### 1. 手動提供 API Key / Token
75
+
76
+ ```python
77
+ from openai import OpenAI
78
+
79
+ client = OpenAI(
80
+ base_url="https://models.github.ai/inference",
81
+ api_key="github_pat_xxxxxxxxx",
82
+ )
83
+
84
+ response = client.chat.completions.create(
85
+ messages=[
86
+ {"role": "system", "content": "你是一個有用的助手。"},
87
+ {"role": "user", "content": "請解釋什麼是量子糾纏。"},
88
+ ],
89
+ model="gpt-4o",
90
+ temperature=1,
91
+ max_tokens=4096,
92
+ top_p=1,
93
+ )
94
+
95
+ print(response.choices[0].message.content)
96
+ ```
97
+
98
+ ### 2. 使用 CLI 命令取得 Token(推薦)
99
+
100
+ 最簡單的方式是使用命令列工具:
101
+
102
+ ```bash
103
+ copilot-python login
104
+ ```
105
+
106
+ 這會開啟瀏覽器進行 GitHub 授權,並輸出 access token。
107
+
108
+ ### 3. 程式碼中使用 OAuth Device Flow
109
+
110
+ ```python
111
+ from openai import OpenAI
112
+ from copilot_python import login_device_flow
113
+
114
+ # 使用內建 client_id,無需額外提供
115
+ token = login_device_flow()
116
+
117
+ client = OpenAI(
118
+ base_url="https://models.github.ai/inference",
119
+ api_key=token,
120
+ )
121
+ ```
122
+
123
+ ### 4. 使用 `CopilotClient`
124
+
125
+ ```python
126
+ from copilot_python import CopilotClient
127
+
128
+ client = CopilotClient(api_key="github_pat_xxxxxxxxx")
129
+
130
+ reply = client.ask(
131
+ "如何寫一個 HTTP 伺服器?",
132
+ model="gpt-4o",
133
+ system_prompt="你是一個有用的助手。",
134
+ )
135
+ print(reply.choices[0].message.content)
136
+ ```
137
+
138
+ ## 建議 Token 類型
139
+
140
+ 常見情境:
141
+
142
+ - OAuth token:`gho_...`
143
+ - Fine-grained PAT:`github_pat_...`
144
+ - GitHub App user-to-server token:`ghu_...`
145
+ - 視服務而定,可能需要 `models` 或 `Copilot Requests` 權限
146
+
147
+ ## 命令列工具
148
+
149
+ ### 取得 Token
150
+
151
+ ```bash
152
+ copilot-python login
153
+ ```
154
+
155
+ 這會:
156
+ 1. 顯示 GitHub 裝置授權連結
157
+ 2. 自動開啟瀏覽器(可用 `--no-open-browser` 停用)
158
+ 3. 等待授權完成
159
+ 4. 輸出 access token 到 stdout
160
+
161
+ 完整選項:
162
+
163
+ ```bash
164
+ copilot-python login [--client-id CLIENT_ID] [--scope SCOPE] [--no-open-browser]
165
+ ```
166
+
167
+ ## 主要 API
168
+
169
+ ### `login_device_flow()`
170
+
171
+ 程式碼中走 GitHub OAuth Device Flow,回傳 access token。
172
+
173
+ ```python
174
+ from copilot_python import login_device_flow
175
+
176
+ # 使用內建 client_id
177
+ token = login_device_flow()
178
+
179
+ # 或自訂 client_id 和 scope
180
+ token = login_device_flow(
181
+ client_id="你的 client_id",
182
+ scope="repo user",
183
+ )
184
+ ```
185
+
186
+ ### `create_client()`
187
+
188
+ 建立已填入 `base_url` 與 `api_key` 的官方 `OpenAI` client。
189
+
190
+ ```python
191
+ from copilot_python import create_client
192
+
193
+ client = create_client(base_url="https://models.github.ai/inference", api_key="...")
194
+ ```
195
+
196
+ ### `ask_copilot()`
197
+
198
+ 最方便的單次呼叫入口,回傳官方 SDK 的 completion 物件。
199
+
200
+ ```python
201
+ ask_copilot(
202
+ prompt,
203
+ *,
204
+ model,
205
+ api_key=None,
206
+ token=None,
207
+ base_url="https://models.github.ai/inference",
208
+ system_prompt=None,
209
+ )
210
+ ```
211
+
212
+ ### `CopilotClient.ask()`
213
+
214
+ 適合重複呼叫時使用。
215
+
216
+ ## 例外
217
+
218
+ - `CopilotError`
219
+ - `CopilotAuthenticationError`
220
+ - `CopilotHTTPError`
221
+ - `CopilotTimeoutError`
222
+
223
+ ## 注意事項
224
+
225
+ 1. 這個專案現在是 **基於官方 `openai` SDK** 呼叫 OpenAI 相容聊天 API。
226
+ 2. 已內建 GitHub OAuth App client_id,可直接使用 `copilot-python login` 或 `login_device_flow()`。
227
+ 3. `base_url` 可指向 GitHub Models 或其他 OpenAI 相容端點。
228
+ 4. 若手動提供 PAT,請確認 Token 權限符合目標服務需求。
229
+ 5. OAuth Device Flow 的 `scope` 參數取決於你使用的 GitHub OAuth App,內建的 client_id 支援標準 OAuth scopes(如 `repo`, `user` 等)。
230
+
231
+ ## 相容性
232
+
233
+ 舊命令 `copilot-wrapper login` 和舊導入 `from copilot_wrapper import ...` 仍保留向後相容。
234
+
235
+ ## 測試
236
+
237
+ ```bash
238
+ python -m unittest discover -s tests -v
239
+ ```
@@ -0,0 +1,14 @@
1
+ copilot_python/__init__.py,sha256=PqvArCBFbGmkqd_9WVnEXWws5nbFZ27YWG_AUp02Q8U,590
2
+ copilot_python/__main__.py,sha256=UPUNG8l4dNYQ_PQpzsDt_KRe2jvE_1bPKMwMR3w7wuc,103
3
+ copilot_python/cli.py,sha256=oDh4A9OIx9EbuGDUl5TohtHDIvPv01donIxdJEMJJis,88
4
+ copilot_python/client.py,sha256=iiRl2fEGZm3jkeiyvyG-YZHHq2o6aqayLMn-7cadc6I,36
5
+ copilot_python-0.1.0.dist-info/licenses/LICENSE,sha256=JQq3rndxy3V0ZL4hr0m6LjUhhwpS8mgbo9O-zbuhVNg,1105
6
+ copilot_wrapper/__init__.py,sha256=zlnJxyOgOkX4QsC7IxcGA_3Na_7rBU_XI5ltG42j5lA,584
7
+ copilot_wrapper/__main__.py,sha256=XfpjOGon2rVfLtuGychbmZ8s9aEMV4F1pVLAZEJa-OA,83
8
+ copilot_wrapper/cli.py,sha256=bb6cRm80Ue5Jh91Qpt50ZbOtmmrncTXkkijNpuxeNoo,2134
9
+ copilot_wrapper/client.py,sha256=55tcpfGIjsZkFFo5D0atRpCaabCSSIVuJAI-BXhSuu0,10016
10
+ copilot_python-0.1.0.dist-info/METADATA,sha256=BHV6Fh-3kVgG4kHZHgggx-J7DEzoqiZi-pZ8EMlwRIA,6452
11
+ copilot_python-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
12
+ copilot_python-0.1.0.dist-info/entry_points.txt,sha256=Wq1Te-18aIC4yVTh9YI2tZ2Z4RKuQ_qec9LRTshjKbk,102
13
+ copilot_python-0.1.0.dist-info/top_level.txt,sha256=h4iccI36DtPcza6z7co3Reo30pYszpr6W9j6wgqLLL4,31
14
+ copilot_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ copilot-python = copilot_python.cli:main
3
+ copilot-wrapper = copilot_wrapper.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 copilot-python contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ copilot_python
2
+ copilot_wrapper
@@ -0,0 +1,27 @@
1
+ from .client import (
2
+ CopilotClient,
3
+ CopilotError,
4
+ CopilotAuthenticationError,
5
+ DEFAULT_GITHUB_OAUTH_CLIENT_ID,
6
+ DeviceFlowInfo,
7
+ GitHubOAuthDeviceFlow,
8
+ OpenAI,
9
+ CopilotTimeoutError,
10
+ ask_copilot,
11
+ create_client,
12
+ login_device_flow,
13
+ )
14
+
15
+ __all__ = [
16
+ "CopilotClient",
17
+ "CopilotError",
18
+ "CopilotAuthenticationError",
19
+ "DEFAULT_GITHUB_OAUTH_CLIENT_ID",
20
+ "DeviceFlowInfo",
21
+ "GitHubOAuthDeviceFlow",
22
+ "OpenAI",
23
+ "CopilotTimeoutError",
24
+ "ask_copilot",
25
+ "create_client",
26
+ "login_device_flow",
27
+ ]
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
copilot_wrapper/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ from .client import DEFAULT_GITHUB_OAUTH_CLIENT_ID, GitHubOAuthDeviceFlow
8
+
9
+
10
+ def build_parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser()
12
+ subparsers = parser.add_subparsers(dest="command", required=True)
13
+
14
+ login_parser = subparsers.add_parser("login", help="Run GitHub OAuth Device Flow and print the token.")
15
+ login_parser.add_argument(
16
+ "--client-id",
17
+ default=os.environ.get("GITHUB_OAUTH_CLIENT_ID") or DEFAULT_GITHUB_OAUTH_CLIENT_ID,
18
+ help="GitHub OAuth App client ID. Defaults to the built-in public client ID, or can be overridden via GITHUB_OAUTH_CLIENT_ID.",
19
+ )
20
+ login_parser.add_argument(
21
+ "--scope",
22
+ default=None,
23
+ help="Optional GitHub OAuth scope, e.g. read:user.",
24
+ )
25
+ login_parser.add_argument(
26
+ "--timeout",
27
+ type=float,
28
+ default=30.0,
29
+ help="HTTP request timeout in seconds.",
30
+ )
31
+ login_parser.add_argument(
32
+ "--no-open-browser",
33
+ action="store_true",
34
+ help="Do not open the browser automatically.",
35
+ )
36
+ return parser
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ parser = build_parser()
41
+ args = parser.parse_args(argv)
42
+
43
+ if args.command == "login":
44
+ return _run_login(args)
45
+
46
+ parser.error("Unknown command.")
47
+ return 2
48
+
49
+
50
+ def _run_login(args: argparse.Namespace) -> int:
51
+ flow = GitHubOAuthDeviceFlow(
52
+ client_id=args.client_id,
53
+ scope=args.scope,
54
+ timeout=args.timeout,
55
+ )
56
+ device_flow = flow.start()
57
+
58
+ print(f"Verification URL: {device_flow.verification_uri}", file=sys.stderr)
59
+ print(f"User Code: {device_flow.user_code}", file=sys.stderr)
60
+ print("完成授權後,token 會輸出到 stdout。", file=sys.stderr)
61
+
62
+ if not args.no_open_browser:
63
+ import webbrowser
64
+
65
+ webbrowser.open(device_flow.verification_uri)
66
+
67
+ token = flow.poll(device_flow)
68
+ print(token)
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ raise SystemExit(main())
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ import webbrowser
10
+ from dataclasses import dataclass
11
+ from typing import Any, Mapping, Optional
12
+
13
+ from openai import OpenAI as _OpenAI
14
+
15
+
16
+ DEFAULT_BASE_URL = "https://models.github.ai/inference"
17
+ DEFAULT_GITHUB_OAUTH_CLIENT_ID = "Ov23liZUzHJhxMU267Fs"
18
+ DEVICE_CODE_URL = "https://github.com/login/device/code"
19
+ ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
20
+ DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
21
+
22
+ OpenAI = _OpenAI
23
+
24
+
25
+ class CopilotError(RuntimeError):
26
+ """Base exception for this package."""
27
+
28
+
29
+ class CopilotAuthenticationError(CopilotError):
30
+ """Raised when authentication is missing or rejected."""
31
+
32
+
33
+ class CopilotTimeoutError(CopilotError):
34
+ """Raised when a network request times out."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DeviceFlowInfo:
39
+ device_code: str
40
+ user_code: str
41
+ verification_uri: str
42
+ expires_in: int
43
+ interval: int
44
+
45
+
46
+ def create_client(
47
+ *,
48
+ api_key: Optional[str] = None,
49
+ token: Optional[str] = None,
50
+ base_url: str = DEFAULT_BASE_URL,
51
+ timeout: float = 180.0,
52
+ **kwargs: Any,
53
+ ) -> OpenAI:
54
+ resolved_api_key = api_key or token or _get_token_from_environment()
55
+ if not resolved_api_key:
56
+ raise CopilotAuthenticationError(
57
+ "未提供 `api_key`。請手動傳入,或設定 `COPILOT_GITHUB_TOKEN`、`GITHUB_TOKEN`、`GH_TOKEN`。"
58
+ )
59
+
60
+ return OpenAI(
61
+ base_url=base_url,
62
+ api_key=resolved_api_key,
63
+ timeout=timeout,
64
+ **kwargs,
65
+ )
66
+
67
+
68
+ class CopilotClient:
69
+ """Thin wrapper around the official `openai.OpenAI` client."""
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ api_key: Optional[str] = None,
75
+ token: Optional[str] = None,
76
+ base_url: str = DEFAULT_BASE_URL,
77
+ timeout: float = 180.0,
78
+ **kwargs: Any,
79
+ ) -> None:
80
+ self.client = create_client(
81
+ api_key=api_key,
82
+ token=token,
83
+ base_url=base_url,
84
+ timeout=timeout,
85
+ **kwargs,
86
+ )
87
+
88
+ @property
89
+ def chat(self) -> Any:
90
+ return self.client.chat
91
+
92
+ def ask(
93
+ self,
94
+ prompt: str,
95
+ *,
96
+ model: str,
97
+ system_prompt: Optional[str] = None,
98
+ temperature: Optional[float] = None,
99
+ max_tokens: Optional[int] = None,
100
+ top_p: Optional[float] = None,
101
+ **extra_body: Any,
102
+ ) -> Any:
103
+ if not isinstance(prompt, str) or not prompt.strip():
104
+ raise ValueError("`prompt` 必須是非空字串。")
105
+
106
+ messages = []
107
+ if system_prompt:
108
+ messages.append({"role": "system", "content": system_prompt})
109
+ messages.append({"role": "user", "content": prompt})
110
+
111
+ return self.client.chat.completions.create(
112
+ messages=messages,
113
+ model=model,
114
+ temperature=temperature,
115
+ max_tokens=max_tokens,
116
+ top_p=top_p,
117
+ **extra_body,
118
+ )
119
+
120
+
121
+ class GitHubOAuthDeviceFlow:
122
+ """Implements GitHub OAuth Device Flow using only the standard library."""
123
+
124
+ def __init__(
125
+ self,
126
+ *,
127
+ client_id: str = DEFAULT_GITHUB_OAUTH_CLIENT_ID,
128
+ scope: Optional[str] = None,
129
+ timeout: float = 30.0,
130
+ ) -> None:
131
+ if not client_id:
132
+ raise ValueError("`client_id` 不能為空。")
133
+ self.client_id = client_id
134
+ self.scope = scope
135
+ self.timeout = timeout
136
+
137
+ def start(self) -> DeviceFlowInfo:
138
+ payload = {"client_id": self.client_id}
139
+ if self.scope:
140
+ payload["scope"] = self.scope
141
+
142
+ request = urllib.request.Request(
143
+ DEVICE_CODE_URL,
144
+ data=urllib.parse.urlencode(payload).encode("utf-8"),
145
+ headers={
146
+ "Accept": "application/json",
147
+ "Content-Type": "application/x-www-form-urlencoded",
148
+ },
149
+ method="POST",
150
+ )
151
+ data = _request_json(request, timeout=self.timeout)
152
+ return DeviceFlowInfo(
153
+ device_code=str(data["device_code"]),
154
+ user_code=str(data["user_code"]),
155
+ verification_uri=str(data["verification_uri"]),
156
+ expires_in=int(data["expires_in"]),
157
+ interval=int(data.get("interval", 5)),
158
+ )
159
+
160
+ def poll(self, device_flow: DeviceFlowInfo) -> str:
161
+ deadline = time.time() + device_flow.expires_in
162
+ interval = device_flow.interval
163
+
164
+ while time.time() < deadline:
165
+ payload = {
166
+ "client_id": self.client_id,
167
+ "device_code": device_flow.device_code,
168
+ "grant_type": DEVICE_GRANT_TYPE,
169
+ }
170
+ request = urllib.request.Request(
171
+ ACCESS_TOKEN_URL,
172
+ data=urllib.parse.urlencode(payload).encode("utf-8"),
173
+ headers={
174
+ "Accept": "application/json",
175
+ "Content-Type": "application/x-www-form-urlencoded",
176
+ },
177
+ method="POST",
178
+ )
179
+ response = _request_json(request, timeout=self.timeout)
180
+
181
+ access_token = response.get("access_token")
182
+ if access_token:
183
+ return str(access_token)
184
+
185
+ error = str(response.get("error", ""))
186
+ if error == "authorization_pending":
187
+ time.sleep(interval)
188
+ continue
189
+ if error == "slow_down":
190
+ interval = int(response.get("interval", interval + 5))
191
+ time.sleep(interval)
192
+ continue
193
+ if error in {"expired_token", "token_expired"}:
194
+ raise CopilotAuthenticationError("裝置授權碼已過期,請重新發起 OAuth Device Flow。")
195
+ if error == "access_denied":
196
+ raise CopilotAuthenticationError("使用者已拒絕授權。")
197
+ if error:
198
+ raise CopilotAuthenticationError(f"OAuth Device Flow 失敗:{error}")
199
+
200
+ time.sleep(interval)
201
+
202
+ raise CopilotTimeoutError("等待 OAuth Device Flow 授權逾時。")
203
+
204
+ def authorize(self, *, open_browser: bool = True, print_instructions: bool = True) -> str:
205
+ device_flow = self.start()
206
+ if print_instructions:
207
+ print(
208
+ f"請前往 {device_flow.verification_uri} 並輸入驗證碼 {device_flow.user_code} 完成授權。"
209
+ )
210
+ if open_browser:
211
+ webbrowser.open(device_flow.verification_uri)
212
+ return self.poll(device_flow)
213
+
214
+
215
+ def login_device_flow(
216
+ *,
217
+ client_id: str = DEFAULT_GITHUB_OAUTH_CLIENT_ID,
218
+ scope: Optional[str] = None,
219
+ timeout: float = 30.0,
220
+ open_browser: bool = True,
221
+ print_instructions: bool = True,
222
+ ) -> str:
223
+ flow = GitHubOAuthDeviceFlow(client_id=client_id, scope=scope, timeout=timeout)
224
+ return flow.authorize(open_browser=open_browser, print_instructions=print_instructions)
225
+
226
+
227
+ def ask_copilot(
228
+ prompt: str,
229
+ *,
230
+ model: str,
231
+ api_key: Optional[str] = None,
232
+ token: Optional[str] = None,
233
+ base_url: str = DEFAULT_BASE_URL,
234
+ system_prompt: Optional[str] = None,
235
+ temperature: Optional[float] = None,
236
+ max_tokens: Optional[int] = None,
237
+ top_p: Optional[float] = None,
238
+ timeout: float = 180.0,
239
+ default_headers: Optional[Mapping[str, str]] = None,
240
+ **extra_body: Any,
241
+ ) -> Any:
242
+ client = CopilotClient(
243
+ api_key=api_key or token,
244
+ base_url=base_url,
245
+ timeout=timeout,
246
+ default_headers=default_headers,
247
+ **extra_body.pop("client_options", {}),
248
+ )
249
+ return client.ask(
250
+ prompt,
251
+ model=model,
252
+ system_prompt=system_prompt,
253
+ temperature=temperature,
254
+ max_tokens=max_tokens,
255
+ top_p=top_p,
256
+ **extra_body,
257
+ )
258
+
259
+
260
+ def _get_token_from_environment() -> Optional[str]:
261
+ for name in ("COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"):
262
+ value = os.environ.get(name)
263
+ if value:
264
+ return value
265
+ return None
266
+
267
+
268
+ def _request_json(request: urllib.request.Request, *, timeout: float) -> dict[str, Any]:
269
+ try:
270
+ with urllib.request.urlopen(request, timeout=timeout) as response:
271
+ raw = response.read().decode("utf-8")
272
+ except TimeoutError as exc:
273
+ raise CopilotTimeoutError("網路請求逾時。") from exc
274
+ except urllib.error.HTTPError as exc:
275
+ body = exc.read().decode("utf-8", errors="replace")
276
+ payload = _safe_load_json(body)
277
+ message = _extract_error_message(payload) or body or f"HTTP {exc.code}"
278
+ raise CopilotAuthenticationError(message) from exc
279
+ except urllib.error.URLError as exc:
280
+ raise CopilotError(f"網路連線失敗:{exc.reason}") from exc
281
+
282
+ return _safe_load_json(raw)
283
+
284
+
285
+ def _safe_load_json(raw: str) -> dict[str, Any]:
286
+ if not raw.strip():
287
+ return {}
288
+ try:
289
+ data = json.loads(raw)
290
+ except json.JSONDecodeError as exc:
291
+ raise CopilotError("伺服器回應不是有效的 JSON。") from exc
292
+ if isinstance(data, dict):
293
+ return data
294
+ raise CopilotError("伺服器回應不是有效的 JSON 物件。")
295
+
296
+
297
+ def _extract_error_message(payload: dict[str, Any]) -> str:
298
+ error = payload.get("error")
299
+ if isinstance(error, dict):
300
+ message = error.get("message")
301
+ if isinstance(message, str):
302
+ return message
303
+ if isinstance(error, str):
304
+ return error
305
+ message = payload.get("message")
306
+ if isinstance(message, str):
307
+ return message
308
+ error_description = payload.get("error_description")
309
+ if isinstance(error_description, str):
310
+ return error_description
311
+ return ""