git-copilot-commit 0.4.1__tar.gz → 0.4.3__tar.gz
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.
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/PKG-INFO +13 -51
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/README.md +12 -50
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/github_copilot.py +401 -37
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/.github/workflows/ci.yml +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/.gitignore +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/.justfile +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/.python-version +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/LICENSE +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/pyproject.toml +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/__init__.py +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/cli.py +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/git.py +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/py.typed +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/settings.py +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/version.py +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/uv.lock +0 -0
- {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/vhs/demo.vhs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-copilot-commit
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Automatically generate and commit changes using copilot
|
|
5
5
|
Author-email: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -104,44 +104,6 @@ Options:
|
|
|
104
104
|
--help Show this message and exit.
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
### Authenticate
|
|
108
|
-
|
|
109
|
-
```bash
|
|
110
|
-
$ uvx git-copilot-commit authenticate --help
|
|
111
|
-
Usage: git-copilot-commit authenticate [OPTIONS]
|
|
112
|
-
|
|
113
|
-
Autheticate with GitHub Copilot.
|
|
114
|
-
|
|
115
|
-
Options:
|
|
116
|
-
--help Show this message and exit.
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
### List models
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
$ uvx git-copilot-commit models --help
|
|
123
|
-
Usage: git-copilot-commit models [OPTIONS]
|
|
124
|
-
|
|
125
|
-
List models available for chat in a table.
|
|
126
|
-
|
|
127
|
-
Options:
|
|
128
|
-
--help Show this message and exit.
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
### Configure
|
|
132
|
-
|
|
133
|
-
```bash
|
|
134
|
-
$ uvx git-copilot-commit config --help
|
|
135
|
-
Usage: git-copilot-commit config [OPTIONS]
|
|
136
|
-
|
|
137
|
-
Manage application configuration.
|
|
138
|
-
|
|
139
|
-
Options:
|
|
140
|
-
--set-default-model TEXT Set default model for commit messages
|
|
141
|
-
--show Show current configuration
|
|
142
|
-
--help Show this message and exit.
|
|
143
|
-
```
|
|
144
|
-
|
|
145
107
|
## Examples
|
|
146
108
|
|
|
147
109
|
Commit all changes:
|
|
@@ -162,14 +124,6 @@ Use a specific model:
|
|
|
162
124
|
uvx git-copilot-commit commit --model claude-3.5-sonnet
|
|
163
125
|
```
|
|
164
126
|
|
|
165
|
-
Set and use a default model:
|
|
166
|
-
|
|
167
|
-
```bash
|
|
168
|
-
uvx git-copilot-commit config --set-default-model gpt-4o
|
|
169
|
-
uvx git-copilot-commit commit
|
|
170
|
-
uvx git-copilot-commit commit --model claude-3.5-sonnet
|
|
171
|
-
```
|
|
172
|
-
|
|
173
127
|
## Commit Message Format
|
|
174
128
|
|
|
175
129
|
Follows [Conventional Commits](https://www.conventionalcommits.org/):
|
|
@@ -199,15 +153,23 @@ Add a git alias by adding the following to your `~/.gitconfig`:
|
|
|
199
153
|
ai-commit = "!f() { uvx git-copilot-commit commit $@; }; f"
|
|
200
154
|
```
|
|
201
155
|
|
|
202
|
-
Now you can run:
|
|
156
|
+
Now you can run to review the message before committing:
|
|
203
157
|
|
|
204
158
|
```bash
|
|
205
159
|
git ai-commit
|
|
206
|
-
git ai-commit --all --yes --model claude-3.5-sonnet
|
|
207
160
|
```
|
|
208
161
|
|
|
209
|
-
|
|
162
|
+
Alternatively, you can stage all files and auto accept the commit message and
|
|
163
|
+
specify which model should be used to generate the commit in one CLI invocation.
|
|
210
164
|
|
|
211
165
|
```bash
|
|
212
|
-
git
|
|
166
|
+
git ai-commit --all --yes --model claude-3.5-sonnet
|
|
213
167
|
```
|
|
168
|
+
|
|
169
|
+
> [!TIP]
|
|
170
|
+
>
|
|
171
|
+
> Show more context in diffs by running the following command:
|
|
172
|
+
>
|
|
173
|
+
> ```bash
|
|
174
|
+
> git config --global diff.context 3
|
|
175
|
+
> ```
|
|
@@ -91,44 +91,6 @@ Options:
|
|
|
91
91
|
--help Show this message and exit.
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
### Authenticate
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
$ uvx git-copilot-commit authenticate --help
|
|
98
|
-
Usage: git-copilot-commit authenticate [OPTIONS]
|
|
99
|
-
|
|
100
|
-
Autheticate with GitHub Copilot.
|
|
101
|
-
|
|
102
|
-
Options:
|
|
103
|
-
--help Show this message and exit.
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
### List models
|
|
107
|
-
|
|
108
|
-
```bash
|
|
109
|
-
$ uvx git-copilot-commit models --help
|
|
110
|
-
Usage: git-copilot-commit models [OPTIONS]
|
|
111
|
-
|
|
112
|
-
List models available for chat in a table.
|
|
113
|
-
|
|
114
|
-
Options:
|
|
115
|
-
--help Show this message and exit.
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
### Configure
|
|
119
|
-
|
|
120
|
-
```bash
|
|
121
|
-
$ uvx git-copilot-commit config --help
|
|
122
|
-
Usage: git-copilot-commit config [OPTIONS]
|
|
123
|
-
|
|
124
|
-
Manage application configuration.
|
|
125
|
-
|
|
126
|
-
Options:
|
|
127
|
-
--set-default-model TEXT Set default model for commit messages
|
|
128
|
-
--show Show current configuration
|
|
129
|
-
--help Show this message and exit.
|
|
130
|
-
```
|
|
131
|
-
|
|
132
94
|
## Examples
|
|
133
95
|
|
|
134
96
|
Commit all changes:
|
|
@@ -149,14 +111,6 @@ Use a specific model:
|
|
|
149
111
|
uvx git-copilot-commit commit --model claude-3.5-sonnet
|
|
150
112
|
```
|
|
151
113
|
|
|
152
|
-
Set and use a default model:
|
|
153
|
-
|
|
154
|
-
```bash
|
|
155
|
-
uvx git-copilot-commit config --set-default-model gpt-4o
|
|
156
|
-
uvx git-copilot-commit commit
|
|
157
|
-
uvx git-copilot-commit commit --model claude-3.5-sonnet
|
|
158
|
-
```
|
|
159
|
-
|
|
160
114
|
## Commit Message Format
|
|
161
115
|
|
|
162
116
|
Follows [Conventional Commits](https://www.conventionalcommits.org/):
|
|
@@ -186,15 +140,23 @@ Add a git alias by adding the following to your `~/.gitconfig`:
|
|
|
186
140
|
ai-commit = "!f() { uvx git-copilot-commit commit $@; }; f"
|
|
187
141
|
```
|
|
188
142
|
|
|
189
|
-
Now you can run:
|
|
143
|
+
Now you can run to review the message before committing:
|
|
190
144
|
|
|
191
145
|
```bash
|
|
192
146
|
git ai-commit
|
|
193
|
-
git ai-commit --all --yes --model claude-3.5-sonnet
|
|
194
147
|
```
|
|
195
148
|
|
|
196
|
-
|
|
149
|
+
Alternatively, you can stage all files and auto accept the commit message and
|
|
150
|
+
specify which model should be used to generate the commit in one CLI invocation.
|
|
197
151
|
|
|
198
152
|
```bash
|
|
199
|
-
git
|
|
153
|
+
git ai-commit --all --yes --model claude-3.5-sonnet
|
|
200
154
|
```
|
|
155
|
+
|
|
156
|
+
> [!TIP]
|
|
157
|
+
>
|
|
158
|
+
> Show more context in diffs by running the following command:
|
|
159
|
+
>
|
|
160
|
+
> ```bash
|
|
161
|
+
> git config --global diff.context 3
|
|
162
|
+
> ```
|
{git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/github_copilot.py
RENAMED
|
@@ -8,6 +8,7 @@ import secrets
|
|
|
8
8
|
import time
|
|
9
9
|
import uuid
|
|
10
10
|
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
@@ -17,7 +18,7 @@ from rich.console import Console
|
|
|
17
18
|
from rich.panel import Panel
|
|
18
19
|
from rich.table import Table
|
|
19
20
|
|
|
20
|
-
APP_NAME = "
|
|
21
|
+
APP_NAME = "github-copilot-commit"
|
|
21
22
|
DEFAULT_GITHUB_DOMAIN = "github.com"
|
|
22
23
|
USER_AGENT = "GitHubCopilotChat/0.35.0"
|
|
23
24
|
EDITOR_VERSION = "vscode/1.107.0"
|
|
@@ -28,6 +29,23 @@ CLIENT_ID = base64.b64decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=").decode()
|
|
|
28
29
|
INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2
|
|
29
30
|
SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4
|
|
30
31
|
DEFAULT_MODEL_ID = "gpt-5.3-codex"
|
|
32
|
+
DEFAULT_MODEL_PREFERENCES = (
|
|
33
|
+
"gpt-5.3-codex",
|
|
34
|
+
"gpt-5.4",
|
|
35
|
+
"claude-opus-4.6",
|
|
36
|
+
"claude-opus-4.5",
|
|
37
|
+
"claude-sonnet-4.6",
|
|
38
|
+
"claude-sonnet-4.5",
|
|
39
|
+
"gemini-2.5-pro",
|
|
40
|
+
"gpt-4.1",
|
|
41
|
+
"gpt-4o",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
app = typer.Typer(
|
|
45
|
+
add_completion=False,
|
|
46
|
+
no_args_is_help=True,
|
|
47
|
+
help="General-purpose GitHub Copilot CLI.",
|
|
48
|
+
)
|
|
31
49
|
|
|
32
50
|
console = Console()
|
|
33
51
|
console_err = Console(stderr=True)
|
|
@@ -133,6 +151,42 @@ class CopilotModel:
|
|
|
133
151
|
)
|
|
134
152
|
|
|
135
153
|
|
|
154
|
+
@dataclass(slots=True)
|
|
155
|
+
class GitHubViewer:
|
|
156
|
+
login: str
|
|
157
|
+
name: str | None = None
|
|
158
|
+
html_url: str | None = None
|
|
159
|
+
account_type: str | None = None
|
|
160
|
+
plan_name: str | None = None
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_payload(cls, payload: dict[str, Any]) -> "GitHubViewer":
|
|
164
|
+
login = payload.get("login")
|
|
165
|
+
name = payload.get("name")
|
|
166
|
+
html_url = payload.get("html_url")
|
|
167
|
+
account_type = payload.get("type")
|
|
168
|
+
plan = payload.get("plan")
|
|
169
|
+
|
|
170
|
+
if not isinstance(login, str) or not login:
|
|
171
|
+
raise CopilotError("GitHub user endpoint did not return a login.")
|
|
172
|
+
|
|
173
|
+
plan_name: str | None = None
|
|
174
|
+
if isinstance(plan, dict):
|
|
175
|
+
raw_plan_name = plan.get("name")
|
|
176
|
+
if isinstance(raw_plan_name, str) and raw_plan_name:
|
|
177
|
+
plan_name = raw_plan_name
|
|
178
|
+
|
|
179
|
+
return cls(
|
|
180
|
+
login=login,
|
|
181
|
+
name=name if isinstance(name, str) and name else None,
|
|
182
|
+
html_url=html_url if isinstance(html_url, str) and html_url else None,
|
|
183
|
+
account_type=(
|
|
184
|
+
account_type if isinstance(account_type, str) and account_type else None
|
|
185
|
+
),
|
|
186
|
+
plan_name=plan_name,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
136
190
|
def xdg_data_home() -> Path:
|
|
137
191
|
value = os.environ.get("XDG_DATA_HOME")
|
|
138
192
|
if value:
|
|
@@ -175,6 +229,12 @@ def get_urls(domain: str) -> dict[str, str]:
|
|
|
175
229
|
}
|
|
176
230
|
|
|
177
231
|
|
|
232
|
+
def get_github_api_base_url(domain: str) -> str:
|
|
233
|
+
if domain == DEFAULT_GITHUB_DOMAIN:
|
|
234
|
+
return "https://api.github.com"
|
|
235
|
+
return f"https://api.{domain}"
|
|
236
|
+
|
|
237
|
+
|
|
178
238
|
def get_base_url_from_token(token: str) -> str | None:
|
|
179
239
|
match = re.search(r"proxy-ep=([^;]+)", token)
|
|
180
240
|
if not match:
|
|
@@ -251,10 +311,63 @@ def request_json(
|
|
|
251
311
|
raise CopilotError(f"Invalid JSON response from {url}.") from exc
|
|
252
312
|
|
|
253
313
|
|
|
314
|
+
def iter_sse_events(response: httpx.Response, url: str):
|
|
315
|
+
event_name: str | None = None
|
|
316
|
+
data_lines: list[str] = []
|
|
317
|
+
|
|
318
|
+
def decode_event(raw_data: str, current_event: str | None) -> Any:
|
|
319
|
+
if raw_data == "[DONE]":
|
|
320
|
+
return None
|
|
321
|
+
try:
|
|
322
|
+
payload = json.loads(raw_data)
|
|
323
|
+
except json.JSONDecodeError as exc:
|
|
324
|
+
label = current_event or "message"
|
|
325
|
+
raise CopilotError(
|
|
326
|
+
f"Invalid SSE event payload from {url} ({label})."
|
|
327
|
+
) from exc
|
|
328
|
+
if isinstance(payload, dict) and current_event and "type" not in payload:
|
|
329
|
+
payload = dict(payload)
|
|
330
|
+
payload["type"] = current_event
|
|
331
|
+
return payload
|
|
332
|
+
|
|
333
|
+
for raw_line in response.iter_lines():
|
|
334
|
+
line = raw_line if isinstance(raw_line, str) else raw_line.decode("utf-8")
|
|
335
|
+
if not line:
|
|
336
|
+
if data_lines:
|
|
337
|
+
current_event = event_name
|
|
338
|
+
raw_data = "\n".join(data_lines)
|
|
339
|
+
event_name = None
|
|
340
|
+
data_lines = []
|
|
341
|
+
payload = decode_event(raw_data, current_event)
|
|
342
|
+
if payload is not None:
|
|
343
|
+
yield payload
|
|
344
|
+
else:
|
|
345
|
+
event_name = None
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
if line.startswith(":"):
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
field, _, value = line.partition(":")
|
|
352
|
+
if value.startswith(" "):
|
|
353
|
+
value = value[1:]
|
|
354
|
+
|
|
355
|
+
if field == "event":
|
|
356
|
+
event_name = value
|
|
357
|
+
continue
|
|
358
|
+
if field == "data":
|
|
359
|
+
data_lines.append(value)
|
|
360
|
+
|
|
361
|
+
if data_lines:
|
|
362
|
+
payload = decode_event("\n".join(data_lines), event_name)
|
|
363
|
+
if payload is not None:
|
|
364
|
+
yield payload
|
|
365
|
+
|
|
366
|
+
|
|
254
367
|
def load_credentials() -> CopilotCredentials | None:
|
|
255
368
|
path = credentials_path()
|
|
256
369
|
if not path.exists():
|
|
257
|
-
return
|
|
370
|
+
return
|
|
258
371
|
|
|
259
372
|
try:
|
|
260
373
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -434,10 +547,34 @@ def refresh_copilot_token(
|
|
|
434
547
|
)
|
|
435
548
|
|
|
436
549
|
|
|
550
|
+
def fetch_github_viewer(
|
|
551
|
+
client: httpx.Client,
|
|
552
|
+
github_access_token: str,
|
|
553
|
+
domain: str,
|
|
554
|
+
) -> GitHubViewer:
|
|
555
|
+
payload = request_json(
|
|
556
|
+
client,
|
|
557
|
+
"GET",
|
|
558
|
+
f"{get_github_api_base_url(domain)}/user",
|
|
559
|
+
headers={
|
|
560
|
+
"Accept": "application/vnd.github+json",
|
|
561
|
+
"Authorization": f"Bearer {github_access_token}",
|
|
562
|
+
"User-Agent": USER_AGENT,
|
|
563
|
+
},
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
if not isinstance(payload, dict):
|
|
567
|
+
raise CopilotError("GitHub user endpoint returned an invalid response.")
|
|
568
|
+
|
|
569
|
+
return GitHubViewer.from_payload(payload)
|
|
570
|
+
|
|
571
|
+
|
|
437
572
|
def ensure_fresh_credentials(client: httpx.Client) -> CopilotCredentials:
|
|
438
573
|
credentials = load_credentials()
|
|
439
574
|
if credentials is None:
|
|
440
|
-
raise CopilotError(
|
|
575
|
+
raise CopilotError(
|
|
576
|
+
f"No cached Copilot credentials found. Run `{Path(__file__).name} auth login` first."
|
|
577
|
+
)
|
|
441
578
|
|
|
442
579
|
if not credentials.is_expired():
|
|
443
580
|
return credentials
|
|
@@ -455,10 +592,11 @@ def copilot_request_headers(
|
|
|
455
592
|
access_token: str,
|
|
456
593
|
*,
|
|
457
594
|
intent: str = "conversation-panel",
|
|
595
|
+
accept: str = "application/json",
|
|
458
596
|
) -> dict[str, str]:
|
|
459
597
|
return {
|
|
460
598
|
"Authorization": f"Bearer {access_token}",
|
|
461
|
-
"Accept":
|
|
599
|
+
"Accept": accept,
|
|
462
600
|
"Content-Type": "application/json",
|
|
463
601
|
"User-Agent": USER_AGENT,
|
|
464
602
|
"Editor-Version": EDITOR_VERSION,
|
|
@@ -518,6 +656,10 @@ def pick_model(
|
|
|
518
656
|
)
|
|
519
657
|
return by_id[requested_model]
|
|
520
658
|
|
|
659
|
+
for preferred_model in DEFAULT_MODEL_PREFERENCES:
|
|
660
|
+
if preferred_model in by_id:
|
|
661
|
+
return by_id[preferred_model]
|
|
662
|
+
|
|
521
663
|
return models[0]
|
|
522
664
|
|
|
523
665
|
|
|
@@ -652,7 +794,7 @@ def chat_completion(
|
|
|
652
794
|
}
|
|
653
795
|
],
|
|
654
796
|
"temperature": 0,
|
|
655
|
-
"max_tokens":
|
|
797
|
+
"max_tokens": 1024,
|
|
656
798
|
"stream": False,
|
|
657
799
|
},
|
|
658
800
|
)
|
|
@@ -672,6 +814,7 @@ def extract_response_text(payload: Any) -> str:
|
|
|
672
814
|
raise CopilotError("Responses API returned no output.")
|
|
673
815
|
|
|
674
816
|
parts: list[str] = []
|
|
817
|
+
refusals: list[str] = []
|
|
675
818
|
for item in output:
|
|
676
819
|
if not isinstance(item, dict):
|
|
677
820
|
continue
|
|
@@ -684,12 +827,20 @@ def extract_response_text(payload: Any) -> str:
|
|
|
684
827
|
text = block.get("text")
|
|
685
828
|
if isinstance(text, str) and text.strip():
|
|
686
829
|
parts.append(text.strip())
|
|
830
|
+
continue
|
|
831
|
+
refusal = block.get("refusal")
|
|
832
|
+
if isinstance(refusal, str) and refusal.strip():
|
|
833
|
+
refusals.append(refusal.strip())
|
|
687
834
|
|
|
688
835
|
joined = "\n".join(parts).strip()
|
|
689
836
|
if joined:
|
|
690
837
|
return joined
|
|
691
838
|
|
|
692
|
-
|
|
839
|
+
joined_refusals = "\n".join(refusals).strip()
|
|
840
|
+
if joined_refusals:
|
|
841
|
+
return joined_refusals
|
|
842
|
+
|
|
843
|
+
raise CopilotError("Responses API output did not contain text.")
|
|
693
844
|
|
|
694
845
|
|
|
695
846
|
def responses_completion(
|
|
@@ -699,32 +850,137 @@ def responses_completion(
|
|
|
699
850
|
model: CopilotModel,
|
|
700
851
|
prompt: str,
|
|
701
852
|
) -> str:
|
|
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
|
-
|
|
853
|
+
url = f"{credentials.base_url()}/responses"
|
|
854
|
+
request_body = {
|
|
855
|
+
"model": model.id,
|
|
856
|
+
"input": [
|
|
857
|
+
{
|
|
858
|
+
"role": "user",
|
|
859
|
+
"content": [
|
|
860
|
+
{
|
|
861
|
+
"type": "input_text",
|
|
862
|
+
"text": prompt,
|
|
863
|
+
}
|
|
864
|
+
],
|
|
865
|
+
}
|
|
866
|
+
],
|
|
867
|
+
"stream": True,
|
|
868
|
+
"store": False,
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
text_parts: list[str] = []
|
|
872
|
+
final_response: dict[str, Any] | None = None
|
|
873
|
+
|
|
874
|
+
try:
|
|
875
|
+
with client.stream(
|
|
876
|
+
"POST",
|
|
877
|
+
url,
|
|
878
|
+
headers=copilot_request_headers(
|
|
879
|
+
credentials.copilot_token,
|
|
880
|
+
intent="conversation-edits",
|
|
881
|
+
accept="text/event-stream",
|
|
882
|
+
),
|
|
883
|
+
json=request_body,
|
|
884
|
+
) as response:
|
|
885
|
+
if response.is_error:
|
|
886
|
+
detail = response.read().decode("utf-8", errors="replace").strip()
|
|
887
|
+
if len(detail) > 400:
|
|
888
|
+
detail = f"{detail[:397]}..."
|
|
889
|
+
suffix = f": {detail}" if detail else ""
|
|
890
|
+
raise CopilotError(
|
|
891
|
+
f"{response.status_code} {response.reason_phrase}{suffix}"
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
content_type = response.headers.get("content-type", "")
|
|
895
|
+
if "text/event-stream" not in content_type:
|
|
896
|
+
body = response.read()
|
|
897
|
+
try:
|
|
898
|
+
payload = json.loads(body)
|
|
899
|
+
except ValueError as exc:
|
|
900
|
+
detail = body.decode("utf-8", errors="replace").strip()
|
|
901
|
+
if len(detail) > 400:
|
|
902
|
+
detail = f"{detail[:397]}..."
|
|
903
|
+
raise CopilotError(
|
|
904
|
+
f"Expected an SSE stream from {url}, got {content_type or 'unknown content type'}: {detail}"
|
|
905
|
+
) from exc
|
|
906
|
+
return extract_response_text(payload)
|
|
907
|
+
|
|
908
|
+
for event in iter_sse_events(response, url):
|
|
909
|
+
if not isinstance(event, dict):
|
|
910
|
+
continue
|
|
911
|
+
|
|
912
|
+
event_type = event.get("type")
|
|
913
|
+
if event_type == "response.output_text.delta":
|
|
914
|
+
delta = event.get("delta")
|
|
915
|
+
if isinstance(delta, str) and delta:
|
|
916
|
+
text_parts.append(delta)
|
|
917
|
+
continue
|
|
918
|
+
|
|
919
|
+
if event_type == "response.output_text.done" and not text_parts:
|
|
920
|
+
text = event.get("text")
|
|
921
|
+
if isinstance(text, str) and text.strip():
|
|
922
|
+
text_parts.append(text)
|
|
923
|
+
continue
|
|
924
|
+
|
|
925
|
+
if event_type == "error":
|
|
926
|
+
error = event.get("error")
|
|
927
|
+
if isinstance(error, dict):
|
|
928
|
+
message = error.get("message")
|
|
929
|
+
code = error.get("code")
|
|
930
|
+
if isinstance(message, str) and message.strip():
|
|
931
|
+
prefix = (
|
|
932
|
+
f"{code}: " if isinstance(code, str) and code else ""
|
|
933
|
+
)
|
|
934
|
+
raise CopilotError(
|
|
935
|
+
f"Responses stream error: {prefix}{message.strip()}"
|
|
936
|
+
)
|
|
937
|
+
raise CopilotError("Responses stream returned an error event.")
|
|
938
|
+
|
|
939
|
+
if event_type in {
|
|
940
|
+
"response.completed",
|
|
941
|
+
"response.failed",
|
|
942
|
+
"response.incomplete",
|
|
943
|
+
}:
|
|
944
|
+
response_payload = event.get("response")
|
|
945
|
+
if isinstance(response_payload, dict):
|
|
946
|
+
final_response = response_payload
|
|
947
|
+
except httpx.HTTPError as exc:
|
|
948
|
+
raise CopilotError(f"Request failed for {url}: {exc}") from exc
|
|
949
|
+
|
|
950
|
+
text = "".join(text_parts).strip()
|
|
951
|
+
if final_response is None:
|
|
952
|
+
if text:
|
|
953
|
+
return text
|
|
954
|
+
raise CopilotError("Responses stream ended without a terminal response event.")
|
|
955
|
+
|
|
956
|
+
status = final_response.get("status")
|
|
957
|
+
if status == "failed":
|
|
958
|
+
error = final_response.get("error")
|
|
959
|
+
if isinstance(error, dict):
|
|
960
|
+
message = error.get("message")
|
|
961
|
+
code = error.get("code")
|
|
962
|
+
if isinstance(message, str) and message.strip():
|
|
963
|
+
prefix = f"{code}: " if isinstance(code, str) and code else ""
|
|
964
|
+
raise CopilotError(
|
|
965
|
+
f"Responses API request failed: {prefix}{message.strip()}"
|
|
966
|
+
)
|
|
967
|
+
raise CopilotError("Responses API request failed.")
|
|
968
|
+
|
|
969
|
+
if status == "incomplete":
|
|
970
|
+
details = final_response.get("incomplete_details")
|
|
971
|
+
reason = "unknown"
|
|
972
|
+
if isinstance(details, dict):
|
|
973
|
+
raw_reason = details.get("reason")
|
|
974
|
+
if isinstance(raw_reason, str) and raw_reason.strip():
|
|
975
|
+
reason = raw_reason.strip()
|
|
976
|
+
if text:
|
|
977
|
+
return f"{text}\n\n[Response incomplete: {reason}]"
|
|
978
|
+
raise CopilotError(f"Responses API response was incomplete: {reason}.")
|
|
979
|
+
|
|
980
|
+
if text:
|
|
981
|
+
return text
|
|
982
|
+
|
|
983
|
+
return extract_response_text(final_response)
|
|
728
984
|
|
|
729
985
|
|
|
730
986
|
def complete_text_prompt(
|
|
@@ -763,12 +1019,100 @@ def print_model_table(models: list[CopilotModel]) -> None:
|
|
|
763
1019
|
console.print(table)
|
|
764
1020
|
|
|
765
1021
|
|
|
766
|
-
def
|
|
767
|
-
|
|
768
|
-
|
|
1022
|
+
def format_relative_duration(delta_seconds: int) -> str:
|
|
1023
|
+
remaining = abs(delta_seconds)
|
|
1024
|
+
units = (
|
|
1025
|
+
("d", 86_400),
|
|
1026
|
+
("h", 3_600),
|
|
1027
|
+
("m", 60),
|
|
1028
|
+
("s", 1),
|
|
1029
|
+
)
|
|
1030
|
+
parts: list[str] = []
|
|
1031
|
+
for suffix, width in units:
|
|
1032
|
+
if remaining < width and suffix != "s":
|
|
1033
|
+
continue
|
|
1034
|
+
value, remaining = divmod(remaining, width)
|
|
1035
|
+
if value == 0 and suffix != "s":
|
|
1036
|
+
continue
|
|
1037
|
+
parts.append(f"{value}{suffix}")
|
|
1038
|
+
if len(parts) == 2:
|
|
1039
|
+
break
|
|
1040
|
+
|
|
1041
|
+
if not parts:
|
|
1042
|
+
parts.append("0s")
|
|
1043
|
+
|
|
1044
|
+
text = " ".join(parts)
|
|
1045
|
+
if delta_seconds < 0:
|
|
1046
|
+
return f"{text} ago"
|
|
1047
|
+
return f"in {text}"
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def format_unix_timestamp(timestamp: int) -> str:
|
|
1051
|
+
try:
|
|
1052
|
+
formatted = datetime.fromtimestamp(timestamp).astimezone()
|
|
1053
|
+
except (OSError, OverflowError, ValueError):
|
|
1054
|
+
return str(timestamp)
|
|
1055
|
+
|
|
1056
|
+
delta_seconds = int(timestamp - time.time())
|
|
1057
|
+
return (
|
|
1058
|
+
f"{formatted.strftime('%Y-%m-%d %H:%M:%S %Z')} "
|
|
1059
|
+
f"({format_relative_duration(delta_seconds)})"
|
|
1060
|
+
)
|
|
769
1061
|
|
|
770
1062
|
|
|
771
|
-
def
|
|
1063
|
+
def print_login_summary(
|
|
1064
|
+
domain: str,
|
|
1065
|
+
credentials: CopilotCredentials,
|
|
1066
|
+
github_viewer: GitHubViewer | None = None,
|
|
1067
|
+
models: list[CopilotModel] | None = None,
|
|
1068
|
+
) -> None:
|
|
1069
|
+
table = Table.grid(padding=(0, 1))
|
|
1070
|
+
table.add_column(style="cyan", no_wrap=True)
|
|
1071
|
+
table.add_column(style="white")
|
|
1072
|
+
|
|
1073
|
+
table.add_row("GitHub host", domain)
|
|
1074
|
+
|
|
1075
|
+
if github_viewer is not None:
|
|
1076
|
+
identity = github_viewer.login
|
|
1077
|
+
if github_viewer.name and github_viewer.name != github_viewer.login:
|
|
1078
|
+
identity = f"{identity} ({github_viewer.name})"
|
|
1079
|
+
table.add_row("GitHub user", identity)
|
|
1080
|
+
if github_viewer.account_type:
|
|
1081
|
+
table.add_row("Account type", github_viewer.account_type)
|
|
1082
|
+
if github_viewer.plan_name:
|
|
1083
|
+
table.add_row("GitHub plan", github_viewer.plan_name)
|
|
1084
|
+
if github_viewer.html_url:
|
|
1085
|
+
table.add_row("GitHub profile", github_viewer.html_url)
|
|
1086
|
+
|
|
1087
|
+
table.add_row("Copilot base URL", credentials.base_url())
|
|
1088
|
+
table.add_row(
|
|
1089
|
+
"Copilot token expires",
|
|
1090
|
+
format_unix_timestamp(credentials.copilot_expires_at),
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
if models is not None:
|
|
1094
|
+
default_model = pick_model(models)
|
|
1095
|
+
table.add_row("Available models", str(len(models)))
|
|
1096
|
+
table.add_row(
|
|
1097
|
+
"Default model",
|
|
1098
|
+
f"{default_model.id} ({infer_api_surface(default_model)})",
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
console.print(Panel.fit(table, title="Login Summary"))
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def login(
|
|
1105
|
+
enterprise_domain: str | None = typer.Option(
|
|
1106
|
+
None,
|
|
1107
|
+
"--enterprise-domain",
|
|
1108
|
+
help="GitHub Enterprise hostname. Omit for github.com.",
|
|
1109
|
+
),
|
|
1110
|
+
force: bool = typer.Option(
|
|
1111
|
+
False,
|
|
1112
|
+
"--force",
|
|
1113
|
+
help="Replace any cached Copilot credentials without prompting.",
|
|
1114
|
+
),
|
|
1115
|
+
) -> None:
|
|
772
1116
|
"""Authenticate with GitHub and cache Copilot credentials locally."""
|
|
773
1117
|
normalized_domain = normalize_domain(enterprise_domain)
|
|
774
1118
|
if enterprise_domain and not normalized_domain:
|
|
@@ -804,9 +1148,29 @@ def login(enterprise_domain: str | None = None, force: bool = False) -> None:
|
|
|
804
1148
|
client, github_access_token, normalized_domain
|
|
805
1149
|
)
|
|
806
1150
|
path = save_credentials(credentials)
|
|
1151
|
+
github_viewer: GitHubViewer | None = None
|
|
1152
|
+
available_models: list[CopilotModel] | None = None
|
|
1153
|
+
warnings: list[str] = []
|
|
1154
|
+
|
|
1155
|
+
try:
|
|
1156
|
+
github_viewer = fetch_github_viewer(client, github_access_token, domain)
|
|
1157
|
+
except CopilotError as exc:
|
|
1158
|
+
warnings.append(f"Could not fetch GitHub account details: {exc}")
|
|
1159
|
+
|
|
1160
|
+
try:
|
|
1161
|
+
available_models = list_models(client, credentials)
|
|
1162
|
+
except CopilotError as exc:
|
|
1163
|
+
warnings.append(f"Could not fetch Copilot model summary: {exc}")
|
|
807
1164
|
|
|
808
1165
|
console.print(f"[green]Saved Copilot credentials to[/green] {path}")
|
|
809
|
-
|
|
1166
|
+
print_login_summary(
|
|
1167
|
+
domain,
|
|
1168
|
+
credentials,
|
|
1169
|
+
github_viewer=github_viewer,
|
|
1170
|
+
models=available_models,
|
|
1171
|
+
)
|
|
1172
|
+
for warning in warnings:
|
|
1173
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
810
1174
|
|
|
811
1175
|
|
|
812
1176
|
def ask(prompt: str, model: str | None = None) -> str:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|