copilot-spend 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.
- copilot_spend/__init__.py +1 -0
- copilot_spend/__main__.py +4 -0
- copilot_spend/api.py +114 -0
- copilot_spend/auth.py +203 -0
- copilot_spend/cli.py +104 -0
- copilot_spend/login.py +266 -0
- copilot_spend/output.py +57 -0
- copilot_spend/paths.py +81 -0
- copilot_spend/quota.py +147 -0
- copilot_spend-0.1.0.dist-info/METADATA +263 -0
- copilot_spend-0.1.0.dist-info/RECORD +14 -0
- copilot_spend-0.1.0.dist-info/WHEEL +4 -0
- copilot_spend-0.1.0.dist-info/entry_points.txt +2 -0
- copilot_spend-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""copilot-spend: a one-shot CLI for current-period GitHub Copilot spend."""
|
copilot_spend/api.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from copilot_spend.auth import Auth
|
|
11
|
+
from copilot_spend.paths import scrub
|
|
12
|
+
from copilot_spend.quota import NoSubscriptionError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class APIError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _package_version() -> str:
|
|
20
|
+
try:
|
|
21
|
+
return version("copilot-spend")
|
|
22
|
+
except PackageNotFoundError:
|
|
23
|
+
return "dev"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_url(host: str) -> str:
|
|
27
|
+
if host == "github.com":
|
|
28
|
+
return "https://api.github.com/copilot_internal/user"
|
|
29
|
+
return f"https://{host}/api/v3/copilot_internal/user"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _body_excerpt(raw: bytes, limit: int = 500) -> str:
|
|
33
|
+
try:
|
|
34
|
+
text = raw.decode("utf-8", errors="replace")
|
|
35
|
+
except Exception:
|
|
36
|
+
text = "<unreadable response body>"
|
|
37
|
+
text = text.strip()
|
|
38
|
+
if len(text) > limit:
|
|
39
|
+
text = text[:limit] + "…"
|
|
40
|
+
return text
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _reauth_message(source: str) -> str:
|
|
44
|
+
if source == "native":
|
|
45
|
+
return (
|
|
46
|
+
"Token rejected by GitHub Copilot. "
|
|
47
|
+
"Run `copilot-spend login` to re-authenticate."
|
|
48
|
+
)
|
|
49
|
+
return (
|
|
50
|
+
"Token rejected by GitHub Copilot — opencode token may be expired. "
|
|
51
|
+
"Run `opencode login` to refresh."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fetch_quota(auth: Auth, *, timeout: float = 10.0) -> dict:
|
|
56
|
+
# `/copilot_internal/user` accepts the OAuth/GitHub-App user token
|
|
57
|
+
# directly as Bearer for both `ghu_` (native) and `gho_` (opencode).
|
|
58
|
+
# No session-token exchange — that token (`/copilot_internal/v2/token`)
|
|
59
|
+
# is for the Copilot Chat proxy at api.githubcopilot.com, not this endpoint.
|
|
60
|
+
url = _build_url(auth.host)
|
|
61
|
+
request = urllib.request.Request(
|
|
62
|
+
url,
|
|
63
|
+
headers={
|
|
64
|
+
"Authorization": f"Bearer {auth.token}",
|
|
65
|
+
"User-Agent": f"copilot-spend/{_package_version()}",
|
|
66
|
+
"Accept": "application/json",
|
|
67
|
+
},
|
|
68
|
+
method="GET",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
context = ssl.create_default_context()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
|
|
75
|
+
raw = response.read()
|
|
76
|
+
except urllib.error.HTTPError as exc:
|
|
77
|
+
status = exc.code
|
|
78
|
+
if status in (401, 403):
|
|
79
|
+
raise APIError(_reauth_message(auth.source)) from None
|
|
80
|
+
if status == 404:
|
|
81
|
+
raise NoSubscriptionError(
|
|
82
|
+
"No Copilot quota on this account."
|
|
83
|
+
) from None
|
|
84
|
+
try:
|
|
85
|
+
body = scrub(_body_excerpt(exc.read()), auth.token)
|
|
86
|
+
except Exception:
|
|
87
|
+
body = "<no response body>"
|
|
88
|
+
if 500 <= status < 600:
|
|
89
|
+
raise APIError(
|
|
90
|
+
f"GitHub Copilot API returned {status} at {url} — try again shortly."
|
|
91
|
+
) from None
|
|
92
|
+
raise APIError(
|
|
93
|
+
f"GitHub Copilot API returned {status} at {url}: {body}"
|
|
94
|
+
) from None
|
|
95
|
+
except TimeoutError:
|
|
96
|
+
raise APIError(
|
|
97
|
+
f"Request to {auth.host} timed out after {timeout}s."
|
|
98
|
+
) from None
|
|
99
|
+
except socket.timeout:
|
|
100
|
+
raise APIError(
|
|
101
|
+
f"Request to {auth.host} timed out after {timeout}s."
|
|
102
|
+
) from None
|
|
103
|
+
except urllib.error.URLError as exc:
|
|
104
|
+
underlying = getattr(exc, "reason", exc)
|
|
105
|
+
raise APIError(
|
|
106
|
+
f"Could not reach {auth.host}: {underlying}"
|
|
107
|
+
) from None
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
return json.loads(raw.decode("utf-8"))
|
|
111
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
112
|
+
raise APIError(
|
|
113
|
+
f"GitHub Copilot API at {url} returned a non-JSON response: {exc}"
|
|
114
|
+
) from None
|
copilot_spend/auth.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
AUTH_PATH = Path.home() / ".local/share/opencode/auth.json"
|
|
14
|
+
|
|
15
|
+
# RFC 1123 hostname: dot-separated labels, each 1-63 chars of [A-Za-z0-9-],
|
|
16
|
+
# not starting or ending with a hyphen. Total length ≤ 253.
|
|
17
|
+
_HOSTNAME_LABEL = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
|
|
18
|
+
_HOSTNAME_RE = re.compile(rf"^{_HOSTNAME_LABEL}(?:\.{_HOSTNAME_LABEL})*$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Auth:
|
|
27
|
+
token: str
|
|
28
|
+
host: str
|
|
29
|
+
source: Literal["native", "opencode"] = "opencode"
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
return f"Auth(token=<redacted>, host={self.host!r}, source={self.source!r})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_private_ip_literal(host: str) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
addr = ipaddress.ip_address(host)
|
|
38
|
+
except ValueError:
|
|
39
|
+
return False
|
|
40
|
+
return (
|
|
41
|
+
addr.is_private
|
|
42
|
+
or addr.is_loopback
|
|
43
|
+
or addr.is_link_local
|
|
44
|
+
or addr.is_reserved
|
|
45
|
+
or addr.is_multicast
|
|
46
|
+
or addr.is_unspecified
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_valid_host(host: str) -> bool:
|
|
51
|
+
if not host or len(host) > 253:
|
|
52
|
+
return False
|
|
53
|
+
if _is_private_ip_literal(host):
|
|
54
|
+
return False
|
|
55
|
+
return bool(_HOSTNAME_RE.match(host))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_host(raw: str) -> str:
|
|
59
|
+
host = raw.strip()
|
|
60
|
+
for prefix in ("https://", "http://"):
|
|
61
|
+
if host.startswith(prefix):
|
|
62
|
+
host = host[len(prefix):]
|
|
63
|
+
break
|
|
64
|
+
return host.rstrip("/").strip()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Backwards-compat aliases retained for module-internal use.
|
|
68
|
+
_is_valid_host = is_valid_host
|
|
69
|
+
_normalize_host = normalize_host
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _warn_if_permissive(path: Path) -> None:
|
|
73
|
+
if os.name != "posix":
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
mode = path.stat().st_mode
|
|
77
|
+
except OSError:
|
|
78
|
+
return
|
|
79
|
+
permissive_bits = mode & (stat.S_IRWXG | stat.S_IRWXO)
|
|
80
|
+
if permissive_bits:
|
|
81
|
+
owner_octal = stat.S_IMODE(mode)
|
|
82
|
+
print(
|
|
83
|
+
f"warning: {path} permissions are {oct(owner_octal)} — "
|
|
84
|
+
"the file holds a long-lived Copilot token; consider `chmod 600`.",
|
|
85
|
+
file=sys.stderr,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _read_opencode(path: Path) -> Auth | None:
|
|
90
|
+
if not path.exists():
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
_warn_if_permissive(path)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
97
|
+
except json.JSONDecodeError as exc:
|
|
98
|
+
raise AuthError(f"opencode auth file is malformed JSON at {path}: {exc.msg}") from None
|
|
99
|
+
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
raise AuthError(f"opencode auth file at {path} is not a JSON object.")
|
|
102
|
+
|
|
103
|
+
entry = data.get("github-copilot") or {}
|
|
104
|
+
if not isinstance(entry, dict):
|
|
105
|
+
raise AuthError(
|
|
106
|
+
f"opencode auth file at {path} has a malformed github-copilot entry."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
token = entry.get("access")
|
|
110
|
+
if not token or not isinstance(token, str):
|
|
111
|
+
raise AuthError(
|
|
112
|
+
f"No GitHub Copilot token in {path}. Run `opencode login` first."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
enterprise_raw = entry.get("enterpriseUrl")
|
|
116
|
+
if enterprise_raw is None:
|
|
117
|
+
enterprise_raw = ""
|
|
118
|
+
if not isinstance(enterprise_raw, str):
|
|
119
|
+
raise AuthError(
|
|
120
|
+
f"opencode auth file at {path} has a non-string enterpriseUrl field."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
enterprise = normalize_host(enterprise_raw)
|
|
124
|
+
if not enterprise:
|
|
125
|
+
host = "github.com"
|
|
126
|
+
else:
|
|
127
|
+
if not is_valid_host(enterprise):
|
|
128
|
+
raise AuthError(
|
|
129
|
+
f"enterpriseUrl in {path} is not a valid hostname: {enterprise_raw!r}. "
|
|
130
|
+
"Expected a bare hostname like `ghe.example.com`."
|
|
131
|
+
)
|
|
132
|
+
host = enterprise
|
|
133
|
+
|
|
134
|
+
return Auth(token=token, host=host, source="opencode")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _read_native(path: Path) -> Auth | None:
|
|
138
|
+
if not path.exists():
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
_warn_if_permissive(path)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
145
|
+
except json.JSONDecodeError as exc:
|
|
146
|
+
raise AuthError(
|
|
147
|
+
f"copilot-spend auth file is malformed JSON at {path}: {exc.msg}. "
|
|
148
|
+
"Run `copilot-spend login` to recreate it."
|
|
149
|
+
) from None
|
|
150
|
+
|
|
151
|
+
if not isinstance(data, dict):
|
|
152
|
+
raise AuthError(f"copilot-spend auth file at {path} is not a JSON object.")
|
|
153
|
+
|
|
154
|
+
entry = data.get("github-copilot") or {}
|
|
155
|
+
if not isinstance(entry, dict):
|
|
156
|
+
raise AuthError(
|
|
157
|
+
f"copilot-spend auth file at {path} has a malformed github-copilot entry."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
token = entry.get("token")
|
|
161
|
+
if not token or not isinstance(token, str):
|
|
162
|
+
raise AuthError(
|
|
163
|
+
f"No GitHub Copilot token in {path}. Run `copilot-spend login`."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
host_raw = entry.get("host", "")
|
|
167
|
+
if not isinstance(host_raw, str):
|
|
168
|
+
raise AuthError(
|
|
169
|
+
f"copilot-spend auth file at {path} has a non-string host field."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
host = normalize_host(host_raw) or "github.com"
|
|
173
|
+
if host != "github.com" and not is_valid_host(host):
|
|
174
|
+
raise AuthError(
|
|
175
|
+
f"host in {path} is not a valid hostname: {host_raw!r}."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return Auth(token=token, host=host, source="native")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def resolve_auth(
|
|
182
|
+
*,
|
|
183
|
+
native_path: Path | None = None,
|
|
184
|
+
opencode_path: Path = AUTH_PATH,
|
|
185
|
+
) -> Auth:
|
|
186
|
+
if native_path is None:
|
|
187
|
+
# Defer the import to call time so test monkeypatching of
|
|
188
|
+
# COPILOT_SPEND_CONFIG_DIR is honored.
|
|
189
|
+
from . import paths as _paths
|
|
190
|
+
native_path = _paths.auth_path()
|
|
191
|
+
|
|
192
|
+
native = _read_native(native_path)
|
|
193
|
+
if native is not None:
|
|
194
|
+
return native
|
|
195
|
+
|
|
196
|
+
opencode = _read_opencode(opencode_path)
|
|
197
|
+
if opencode is not None:
|
|
198
|
+
return opencode
|
|
199
|
+
|
|
200
|
+
raise AuthError(
|
|
201
|
+
"No credentials found. Run `copilot-spend login` to authenticate, "
|
|
202
|
+
"or install opencode and run `opencode login`."
|
|
203
|
+
)
|
copilot_spend/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
|
|
8
|
+
from copilot_spend.api import APIError, fetch_quota
|
|
9
|
+
from copilot_spend.auth import AuthError, resolve_auth
|
|
10
|
+
from copilot_spend.output import render
|
|
11
|
+
from copilot_spend.paths import scrub
|
|
12
|
+
from copilot_spend.quota import NoSubscriptionError, parse_quota
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _package_version() -> str:
|
|
16
|
+
try:
|
|
17
|
+
return version("copilot-spend")
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
return "dev"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="copilot-spend",
|
|
25
|
+
description="Print your current-period GitHub Copilot spend and reset date.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--version",
|
|
29
|
+
action="version",
|
|
30
|
+
version=f"copilot-spend {_package_version()}",
|
|
31
|
+
)
|
|
32
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
33
|
+
subparsers.add_parser(
|
|
34
|
+
"login",
|
|
35
|
+
help="Authenticate via GitHub OAuth device flow (github.com or GHE).",
|
|
36
|
+
)
|
|
37
|
+
subparsers.add_parser(
|
|
38
|
+
"logout",
|
|
39
|
+
help="Remove copilot-spend's stored credentials.",
|
|
40
|
+
)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run_show_quota() -> int:
|
|
45
|
+
auth = None
|
|
46
|
+
try:
|
|
47
|
+
auth = resolve_auth()
|
|
48
|
+
except AuthError as exc:
|
|
49
|
+
print(str(exc), file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
payload = fetch_quota(auth)
|
|
54
|
+
except NoSubscriptionError as exc:
|
|
55
|
+
print(f"no Copilot quota on this account: {exc}", file=sys.stderr)
|
|
56
|
+
return 4
|
|
57
|
+
except APIError as exc:
|
|
58
|
+
print(scrub(str(exc), auth.token if auth else None), file=sys.stderr)
|
|
59
|
+
return 3
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
spend = parse_quota(payload)
|
|
63
|
+
except NoSubscriptionError as exc:
|
|
64
|
+
print(f"no Copilot quota on this account: {exc}", file=sys.stderr)
|
|
65
|
+
return 4
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
print(render(spend, now=datetime.now(timezone.utc)))
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
print(
|
|
71
|
+
scrub(f"unexpected error rendering output: {exc}", auth.token if auth else None),
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
parser = _build_parser()
|
|
81
|
+
args = parser.parse_args(argv)
|
|
82
|
+
|
|
83
|
+
if args.command == "login":
|
|
84
|
+
from copilot_spend.login import run_login
|
|
85
|
+
return run_login()
|
|
86
|
+
if args.command == "logout":
|
|
87
|
+
from copilot_spend.login import run_logout
|
|
88
|
+
return run_logout()
|
|
89
|
+
|
|
90
|
+
return _run_show_quota()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _entrypoint() -> None:
|
|
94
|
+
try:
|
|
95
|
+
sys.exit(main())
|
|
96
|
+
except SystemExit:
|
|
97
|
+
raise
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
print(f"unexpected error: {exc}", file=sys.stderr)
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
_entrypoint()
|
copilot_spend/login.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
from . import api, paths
|
|
12
|
+
from .auth import Auth, AuthError, is_valid_host, normalize_host
|
|
13
|
+
from .quota import NoSubscriptionError
|
|
14
|
+
|
|
15
|
+
CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
|
16
|
+
OAUTH_SCOPE = "read:user"
|
|
17
|
+
POLL_MAX_WAIT_S = 900
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _device_url(host: str) -> str:
|
|
21
|
+
if host == "github.com":
|
|
22
|
+
return "https://github.com/login/device/code"
|
|
23
|
+
return f"https://{host}/login/device/code"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _access_token_url(host: str) -> str:
|
|
27
|
+
if host == "github.com":
|
|
28
|
+
return "https://github.com/login/oauth/access_token"
|
|
29
|
+
return f"https://{host}/login/oauth/access_token"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _post_json(url: str, body: dict, *, timeout: float = 10.0) -> dict:
|
|
33
|
+
request = urllib.request.Request(
|
|
34
|
+
url,
|
|
35
|
+
data=json.dumps(body).encode("utf-8"),
|
|
36
|
+
headers={
|
|
37
|
+
"Accept": "application/json",
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
},
|
|
40
|
+
method="POST",
|
|
41
|
+
)
|
|
42
|
+
context = ssl.create_default_context()
|
|
43
|
+
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
|
|
44
|
+
raw = response.read()
|
|
45
|
+
return json.loads(raw.decode("utf-8"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _prompt_host(stdin=None, stderr=None) -> str:
|
|
49
|
+
stdin = stdin or sys.stdin
|
|
50
|
+
stderr = stderr or sys.stderr
|
|
51
|
+
print("Where do you authenticate?", file=stderr)
|
|
52
|
+
print(" 1) github.com", file=stderr)
|
|
53
|
+
print(" 2) GitHub Enterprise", file=stderr)
|
|
54
|
+
print("Choose 1 or 2 [1]: ", end="", file=stderr, flush=True)
|
|
55
|
+
choice = stdin.readline().strip() or "1"
|
|
56
|
+
|
|
57
|
+
if choice == "1":
|
|
58
|
+
return "github.com"
|
|
59
|
+
if choice != "2":
|
|
60
|
+
raise AuthError(f"Unrecognized choice: {choice!r}. Expected 1 or 2.")
|
|
61
|
+
|
|
62
|
+
print("GHE host (e.g. ghe.example.com): ", end="", file=stderr, flush=True)
|
|
63
|
+
raw = stdin.readline().strip()
|
|
64
|
+
host = normalize_host(raw)
|
|
65
|
+
if not is_valid_host(host):
|
|
66
|
+
raise AuthError(
|
|
67
|
+
f"Not a valid hostname: {raw!r}. Expected a bare hostname like `ghe.example.com`."
|
|
68
|
+
)
|
|
69
|
+
return host
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _request_device_code(host: str) -> dict:
|
|
73
|
+
url = _device_url(host)
|
|
74
|
+
try:
|
|
75
|
+
payload = _post_json(url, {"client_id": CLIENT_ID, "scope": OAUTH_SCOPE})
|
|
76
|
+
except urllib.error.HTTPError as exc:
|
|
77
|
+
raise AuthError(
|
|
78
|
+
f"GitHub returned {exc.code} from {url}. Check the host and try again."
|
|
79
|
+
) from None
|
|
80
|
+
except (TimeoutError, socket.timeout):
|
|
81
|
+
raise AuthError(f"Timed out reaching {host} for device-code request.") from None
|
|
82
|
+
except urllib.error.URLError as exc:
|
|
83
|
+
underlying = getattr(exc, "reason", exc)
|
|
84
|
+
raise AuthError(f"Could not reach '{host}': {underlying}.") from None
|
|
85
|
+
|
|
86
|
+
if not isinstance(payload, dict):
|
|
87
|
+
raise AuthError(f"Unexpected response from {url}: not a JSON object.")
|
|
88
|
+
error = payload.get("error")
|
|
89
|
+
if error:
|
|
90
|
+
description = payload.get("error_description", error)
|
|
91
|
+
raise AuthError(
|
|
92
|
+
f"GitHub rejected the device-code request ({error}): {description}."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
required = ("device_code", "user_code", "verification_uri", "interval")
|
|
96
|
+
if not all(payload.get(k) for k in required):
|
|
97
|
+
raise AuthError(
|
|
98
|
+
f"Device-code response from {url} is missing required fields."
|
|
99
|
+
)
|
|
100
|
+
return payload
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _poll_for_token(
|
|
104
|
+
host: str,
|
|
105
|
+
device_code: str,
|
|
106
|
+
interval: int,
|
|
107
|
+
*,
|
|
108
|
+
now=time.monotonic,
|
|
109
|
+
sleep=time.sleep,
|
|
110
|
+
max_wait: int = POLL_MAX_WAIT_S,
|
|
111
|
+
) -> str:
|
|
112
|
+
url = _access_token_url(host)
|
|
113
|
+
body = {
|
|
114
|
+
"client_id": CLIENT_ID,
|
|
115
|
+
"device_code": device_code,
|
|
116
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
117
|
+
}
|
|
118
|
+
started = now()
|
|
119
|
+
current_interval = max(int(interval), 1)
|
|
120
|
+
|
|
121
|
+
while True:
|
|
122
|
+
if now() - started > max_wait:
|
|
123
|
+
raise AuthError("Login timed out. Run `copilot-spend login` again.")
|
|
124
|
+
|
|
125
|
+
sleep(current_interval)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
payload = _post_json(url, body)
|
|
129
|
+
except urllib.error.HTTPError as exc:
|
|
130
|
+
raise AuthError(
|
|
131
|
+
f"GitHub returned {exc.code} during token polling. Run `copilot-spend login` again."
|
|
132
|
+
) from None
|
|
133
|
+
except (TimeoutError, socket.timeout):
|
|
134
|
+
# Transient — keep polling within the time budget.
|
|
135
|
+
continue
|
|
136
|
+
except urllib.error.URLError as exc:
|
|
137
|
+
underlying = getattr(exc, "reason", exc)
|
|
138
|
+
raise AuthError(f"Could not reach '{host}': {underlying}.") from None
|
|
139
|
+
|
|
140
|
+
if not isinstance(payload, dict):
|
|
141
|
+
raise AuthError("Unexpected non-object response from token endpoint.")
|
|
142
|
+
|
|
143
|
+
token = payload.get("access_token")
|
|
144
|
+
if token:
|
|
145
|
+
if not isinstance(token, str):
|
|
146
|
+
raise AuthError("GitHub returned a non-string access_token.")
|
|
147
|
+
return token
|
|
148
|
+
|
|
149
|
+
err = payload.get("error")
|
|
150
|
+
if err == "authorization_pending":
|
|
151
|
+
continue
|
|
152
|
+
if err == "slow_down":
|
|
153
|
+
current_interval += 5
|
|
154
|
+
continue
|
|
155
|
+
if err == "expired_token":
|
|
156
|
+
raise AuthError("Login timed out. Run `copilot-spend login` again.")
|
|
157
|
+
if err == "access_denied":
|
|
158
|
+
raise AuthError("Authorization denied. Run `copilot-spend login` again to retry.")
|
|
159
|
+
if err == "unauthorized_client":
|
|
160
|
+
raise AuthError(
|
|
161
|
+
"GitHub rejected the OAuth app (unauthorized_client). "
|
|
162
|
+
"The bundled CLIENT_ID may not be allowed on this host. "
|
|
163
|
+
"See README 'Switch to your own GitHub App'."
|
|
164
|
+
)
|
|
165
|
+
if err:
|
|
166
|
+
description = payload.get("error_description", err)
|
|
167
|
+
raise AuthError(f"GitHub returned an error during polling ({err}): {description}.")
|
|
168
|
+
|
|
169
|
+
raise AuthError("Token response missing both access_token and error.")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def run_login(
|
|
173
|
+
*,
|
|
174
|
+
stdin=None,
|
|
175
|
+
stdout=None,
|
|
176
|
+
stderr=None,
|
|
177
|
+
sleep=time.sleep,
|
|
178
|
+
now=time.monotonic,
|
|
179
|
+
) -> int:
|
|
180
|
+
stdin = stdin or sys.stdin
|
|
181
|
+
stdout = stdout or sys.stdout
|
|
182
|
+
stderr = stderr or sys.stderr
|
|
183
|
+
try:
|
|
184
|
+
host = _prompt_host(stdin=stdin, stderr=stderr)
|
|
185
|
+
device = _request_device_code(host)
|
|
186
|
+
except AuthError as exc:
|
|
187
|
+
print(f"error: {exc}", file=stderr)
|
|
188
|
+
return 2
|
|
189
|
+
|
|
190
|
+
print(
|
|
191
|
+
f"Visit {device['verification_uri']} and enter the code: {device['user_code']}",
|
|
192
|
+
file=stdout,
|
|
193
|
+
)
|
|
194
|
+
print("Waiting for authorization...", file=stderr)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
token = _poll_for_token(
|
|
198
|
+
host,
|
|
199
|
+
device["device_code"],
|
|
200
|
+
int(device.get("interval", 5)),
|
|
201
|
+
now=now,
|
|
202
|
+
sleep=sleep,
|
|
203
|
+
)
|
|
204
|
+
except KeyboardInterrupt:
|
|
205
|
+
print("Login cancelled.", file=stderr)
|
|
206
|
+
return 2
|
|
207
|
+
except AuthError as exc:
|
|
208
|
+
print(f"error: {exc}", file=stderr)
|
|
209
|
+
return 2
|
|
210
|
+
|
|
211
|
+
if not token.startswith("ghu_"):
|
|
212
|
+
print(
|
|
213
|
+
"error: GitHub returned a non-GitHub-App token (expected `ghu_…` prefix). "
|
|
214
|
+
"The bundled CLIENT_ID is misconfigured — see README 'Switch to your own GitHub App'.",
|
|
215
|
+
file=stderr,
|
|
216
|
+
)
|
|
217
|
+
return 2
|
|
218
|
+
|
|
219
|
+
auth = Auth(token=token, host=host, source="native")
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
api.fetch_quota(auth)
|
|
223
|
+
except NoSubscriptionError:
|
|
224
|
+
print(
|
|
225
|
+
"error: post-login verification failed: this account has no Copilot quota.",
|
|
226
|
+
file=stderr,
|
|
227
|
+
)
|
|
228
|
+
return 2
|
|
229
|
+
except api.APIError as exc:
|
|
230
|
+
scrubbed = paths.scrub(str(exc), token)
|
|
231
|
+
print(f"error: post-login verification failed: {scrubbed}", file=stderr)
|
|
232
|
+
return 2
|
|
233
|
+
|
|
234
|
+
target = paths.auth_path()
|
|
235
|
+
if target.exists():
|
|
236
|
+
print(
|
|
237
|
+
"Re-authenticating — previous credentials will be replaced.",
|
|
238
|
+
file=stderr,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
paths.write_secret_file(
|
|
243
|
+
target,
|
|
244
|
+
{"github-copilot": {"token": token, "host": host}},
|
|
245
|
+
)
|
|
246
|
+
except AuthError as exc:
|
|
247
|
+
print(f"error: could not write credentials: {exc}", file=stderr)
|
|
248
|
+
return 2
|
|
249
|
+
except OSError as exc:
|
|
250
|
+
print(f"error: could not write credentials: {exc}", file=stderr)
|
|
251
|
+
return 2
|
|
252
|
+
|
|
253
|
+
# Clean up any stale session.json from versions that cached a session token.
|
|
254
|
+
paths.delete_secret_file(paths.config_dir() / "session.json")
|
|
255
|
+
|
|
256
|
+
print(f"Logged in to GitHub Copilot via {host}.", file=stdout)
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_logout(*, stdout=None) -> int:
|
|
261
|
+
stdout = stdout or sys.stdout
|
|
262
|
+
paths.delete_secret_file(paths.auth_path())
|
|
263
|
+
# Legacy cleanup: pre-session-removal versions cached a session token here.
|
|
264
|
+
paths.delete_secret_file(paths.config_dir() / "session.json")
|
|
265
|
+
print("Logged out of copilot-spend.", file=stdout)
|
|
266
|
+
return 0
|
copilot_spend/output.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from copilot_spend.quota import Spend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _format_dollars(amount: float) -> str:
|
|
9
|
+
if amount < 0:
|
|
10
|
+
return f"-${abs(amount):.2f}"
|
|
11
|
+
return f"${amount:.2f}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _format_reset(reset: datetime | None, now: datetime) -> str:
|
|
15
|
+
if reset is None:
|
|
16
|
+
return "next reset: unknown"
|
|
17
|
+
|
|
18
|
+
absolute = reset.strftime("%b %d, %Y")
|
|
19
|
+
delta_days = (reset.date() - now.date()).days
|
|
20
|
+
|
|
21
|
+
if delta_days == 0:
|
|
22
|
+
relative = "today"
|
|
23
|
+
elif delta_days == 1:
|
|
24
|
+
relative = "tomorrow"
|
|
25
|
+
elif delta_days > 1:
|
|
26
|
+
relative = f"in {delta_days} days"
|
|
27
|
+
elif delta_days == -1:
|
|
28
|
+
relative = "yesterday (overdue)"
|
|
29
|
+
else:
|
|
30
|
+
relative = f"overdue by {abs(delta_days)} days"
|
|
31
|
+
|
|
32
|
+
return f"{absolute} ({relative})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render(spend: Spend, *, now: datetime) -> str:
|
|
36
|
+
plan_label = f" ({spend.plan})" if spend.plan else ""
|
|
37
|
+
login_label = spend.login or "<unknown account>"
|
|
38
|
+
|
|
39
|
+
lines = [
|
|
40
|
+
f"GitHub Copilot - {login_label}{plan_label}",
|
|
41
|
+
f" Used: {spend.consumed} PRUs",
|
|
42
|
+
f" Allowance: {_format_dollars(spend.dollars_entitlement)} ({spend.entitlement} PRUs included)",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
if spend.billable_prus > 0:
|
|
46
|
+
lines.append(
|
|
47
|
+
f" Billable: {_format_dollars(spend.dollars_owed)}"
|
|
48
|
+
f" ({spend.billable_prus} PRUs over allowance at $0.04/PRU)"
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
lines.append(
|
|
52
|
+
f" Remaining: {_format_dollars(spend.dollars_free_remaining)}"
|
|
53
|
+
f" ({spend.free_remaining_prus} PRUs of free allowance left)"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
lines.append(f" Resets: {_format_reset(spend.reset, now)}")
|
|
57
|
+
return "\n".join(lines)
|
copilot_spend/paths.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .auth import AuthError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def config_dir() -> Path:
|
|
14
|
+
override = os.environ.get("COPILOT_SPEND_CONFIG_DIR")
|
|
15
|
+
if override:
|
|
16
|
+
return Path(override)
|
|
17
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
18
|
+
if xdg:
|
|
19
|
+
return Path(xdg) / "copilot-spend"
|
|
20
|
+
return Path.home() / ".config" / "copilot-spend"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def auth_path() -> Path:
|
|
24
|
+
return config_dir() / "auth.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def assert_safe_parent(parent: Path) -> None:
|
|
28
|
+
if os.name != "posix":
|
|
29
|
+
return
|
|
30
|
+
if not parent.exists():
|
|
31
|
+
return
|
|
32
|
+
try:
|
|
33
|
+
info = parent.stat()
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
raise AuthError(f"Cannot stat config directory {parent}: {exc}") from None
|
|
36
|
+
if info.st_uid != os.getuid():
|
|
37
|
+
raise AuthError(
|
|
38
|
+
f"Refusing to use config directory {parent}: owned by uid "
|
|
39
|
+
f"{info.st_uid}, expected {os.getuid()}. "
|
|
40
|
+
"Set COPILOT_SPEND_CONFIG_DIR or XDG_CONFIG_HOME to a directory "
|
|
41
|
+
"you own."
|
|
42
|
+
)
|
|
43
|
+
if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
44
|
+
raise AuthError(
|
|
45
|
+
f"Refusing to use config directory {parent}: mode "
|
|
46
|
+
f"{oct(stat.S_IMODE(info.st_mode))} grants group or world write. "
|
|
47
|
+
f"Run `chmod 700 {parent}`."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_secret_file(path: Path, payload: dict[str, Any]) -> None:
|
|
52
|
+
parent = path.parent
|
|
53
|
+
assert_safe_parent(parent)
|
|
54
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
if os.name == "posix":
|
|
56
|
+
os.chmod(parent, 0o700)
|
|
57
|
+
|
|
58
|
+
fd, tmp_str = tempfile.mkstemp(dir=parent, prefix=".tmp-", suffix=".json")
|
|
59
|
+
tmp_path = Path(tmp_str)
|
|
60
|
+
try:
|
|
61
|
+
if os.name == "posix":
|
|
62
|
+
os.chmod(fd, 0o600)
|
|
63
|
+
with os.fdopen(fd, "wb") as handle:
|
|
64
|
+
handle.write(json.dumps(payload, indent=2).encode("utf-8"))
|
|
65
|
+
handle.flush()
|
|
66
|
+
os.fsync(handle.fileno())
|
|
67
|
+
os.replace(tmp_path, path)
|
|
68
|
+
except BaseException:
|
|
69
|
+
tmp_path.unlink(missing_ok=True)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def delete_secret_file(path: Path) -> None:
|
|
74
|
+
path.unlink(missing_ok=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def scrub(text: str, *tokens: str | None) -> str:
|
|
78
|
+
for token in tokens:
|
|
79
|
+
if token and token in text:
|
|
80
|
+
text = text.replace(token, "<redacted-token>")
|
|
81
|
+
return text
|
copilot_spend/quota.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# Published GitHub Copilot premium request unit price as of 2026-05.
|
|
8
|
+
# Update this constant if GitHub changes the rate.
|
|
9
|
+
PRU_PRICE_USD = 0.04
|
|
10
|
+
|
|
11
|
+
# Plausible field names for the next-reset timestamp. Searched at the payload
|
|
12
|
+
# top level first, then inside `quota_snapshots.premium_interactions`. The
|
|
13
|
+
# endpoint is undocumented; live observation (2026-05) shows `quota_reset_date`
|
|
14
|
+
# at the top level with a date-only string. The other names are defensive
|
|
15
|
+
# fallbacks in case GitHub renames or relocates the field.
|
|
16
|
+
RESET_FIELD_CANDIDATES: tuple[str, ...] = (
|
|
17
|
+
"quota_reset_date",
|
|
18
|
+
"next_reset",
|
|
19
|
+
"reset_date",
|
|
20
|
+
"resets_at",
|
|
21
|
+
"reset",
|
|
22
|
+
"next_reset_date",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Nested paths inside `premium_interactions` to search when no flat candidate
|
|
26
|
+
# matches. Each path is a tuple of keys to walk.
|
|
27
|
+
RESET_NESTED_PATHS: tuple[tuple[str, ...], ...] = (
|
|
28
|
+
("reset", "date"),
|
|
29
|
+
("reset", "at"),
|
|
30
|
+
("next", "reset"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NoSubscriptionError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Spend:
|
|
40
|
+
login: str
|
|
41
|
+
plan: str
|
|
42
|
+
entitlement: int # included free PRUs per period
|
|
43
|
+
consumed: int # total PRUs used this period (>= 0)
|
|
44
|
+
billable_prus: int # max(0, consumed - entitlement)
|
|
45
|
+
free_remaining_prus: int # max(0, entitlement - consumed)
|
|
46
|
+
dollars_owed: float # billable_prus * PRU_PRICE_USD
|
|
47
|
+
dollars_entitlement: float # entitlement * PRU_PRICE_USD (reference)
|
|
48
|
+
dollars_free_remaining: float # free_remaining_prus * PRU_PRICE_USD
|
|
49
|
+
reset: datetime | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_iso(value: Any) -> datetime | None:
|
|
53
|
+
if not isinstance(value, str) or not value:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
57
|
+
except ValueError:
|
|
58
|
+
return None
|
|
59
|
+
# Date-only strings (e.g. "2026-06-01") parse to naive datetimes; attach
|
|
60
|
+
# UTC so downstream comparisons and formatting are consistent.
|
|
61
|
+
if parsed.tzinfo is None:
|
|
62
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
63
|
+
return parsed
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_reset(payload: dict, pi: dict) -> datetime | None:
|
|
67
|
+
for source in (payload, pi):
|
|
68
|
+
for name in RESET_FIELD_CANDIDATES:
|
|
69
|
+
if name in source:
|
|
70
|
+
parsed = _parse_iso(source[name])
|
|
71
|
+
if parsed is not None:
|
|
72
|
+
return parsed
|
|
73
|
+
for path in RESET_NESTED_PATHS:
|
|
74
|
+
cursor: Any = pi
|
|
75
|
+
for key in path:
|
|
76
|
+
if isinstance(cursor, dict) and key in cursor:
|
|
77
|
+
cursor = cursor[key]
|
|
78
|
+
else:
|
|
79
|
+
cursor = None
|
|
80
|
+
break
|
|
81
|
+
parsed = _parse_iso(cursor)
|
|
82
|
+
if parsed is not None:
|
|
83
|
+
return parsed
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_quota(payload: dict) -> Spend:
|
|
88
|
+
plan = payload.get("copilot_plan") or ""
|
|
89
|
+
snapshots = payload.get("quota_snapshots") or {}
|
|
90
|
+
pi = snapshots.get("premium_interactions") if isinstance(snapshots, dict) else None
|
|
91
|
+
|
|
92
|
+
# Origin R7: missing copilot_plan OR missing premium_interactions → no subscription.
|
|
93
|
+
if not plan or not isinstance(pi, dict):
|
|
94
|
+
raise NoSubscriptionError("No Copilot quota on this account.")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
entitlement = int(pi["entitlement"])
|
|
98
|
+
remaining = int(pi["remaining"])
|
|
99
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
100
|
+
raise NoSubscriptionError(
|
|
101
|
+
f"premium_interactions missing entitlement/remaining: {exc}"
|
|
102
|
+
) from None
|
|
103
|
+
|
|
104
|
+
# API semantics observed against a business-plan account:
|
|
105
|
+
# `remaining` counts down from 0 as you consume PRUs (so the value is
|
|
106
|
+
# ≤ 0 in steady state on this plan class). `entitlement` is the free
|
|
107
|
+
# credit per period — the first N PRUs are not billable.
|
|
108
|
+
# Defensive: if `remaining` is positive (unobserved case, possibly other
|
|
109
|
+
# plan classes), treat it as zero consumption rather than guessing.
|
|
110
|
+
consumed = max(0, -remaining)
|
|
111
|
+
billable_prus = max(0, consumed - entitlement)
|
|
112
|
+
free_remaining_prus = max(0, entitlement - consumed)
|
|
113
|
+
|
|
114
|
+
dollars_owed = round(billable_prus * PRU_PRICE_USD, 2)
|
|
115
|
+
dollars_entitlement = round(entitlement * PRU_PRICE_USD, 2)
|
|
116
|
+
dollars_free_remaining = round(free_remaining_prus * PRU_PRICE_USD, 2)
|
|
117
|
+
|
|
118
|
+
reset = _extract_reset(payload, pi)
|
|
119
|
+
|
|
120
|
+
login = payload.get("login") or ""
|
|
121
|
+
if not isinstance(login, str):
|
|
122
|
+
login = ""
|
|
123
|
+
if not isinstance(plan, str):
|
|
124
|
+
plan = ""
|
|
125
|
+
|
|
126
|
+
return Spend(
|
|
127
|
+
login=login,
|
|
128
|
+
plan=plan,
|
|
129
|
+
entitlement=entitlement,
|
|
130
|
+
consumed=consumed,
|
|
131
|
+
billable_prus=billable_prus,
|
|
132
|
+
free_remaining_prus=free_remaining_prus,
|
|
133
|
+
dollars_owed=dollars_owed,
|
|
134
|
+
dollars_entitlement=dollars_entitlement,
|
|
135
|
+
dollars_free_remaining=dollars_free_remaining,
|
|
136
|
+
reset=reset,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = [
|
|
141
|
+
"PRU_PRICE_USD",
|
|
142
|
+
"RESET_FIELD_CANDIDATES",
|
|
143
|
+
"RESET_NESTED_PATHS",
|
|
144
|
+
"NoSubscriptionError",
|
|
145
|
+
"Spend",
|
|
146
|
+
"parse_quota",
|
|
147
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: copilot-spend
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Print your current-period GitHub Copilot spend and reset date.
|
|
5
|
+
Project-URL: Homepage, https://github.com/nkootstra/copilot-spend
|
|
6
|
+
Project-URL: Source, https://github.com/nkootstra/copilot-spend
|
|
7
|
+
Project-URL: Issues, https://github.com/nkootstra/copilot-spend/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/nkootstra/copilot-spend/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Documentation, https://github.com/nkootstra/copilot-spend#readme
|
|
10
|
+
Author-email: Niels Kootstra <niels.kootstra@gmail.com>
|
|
11
|
+
License: MIT License
|
|
12
|
+
|
|
13
|
+
Copyright (c) 2026 Niels
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
in the Software without restriction, including without limitation the rights
|
|
18
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
furnished to do so, subject to the following conditions:
|
|
21
|
+
|
|
22
|
+
The above copyright notice and this permission notice shall be included in all
|
|
23
|
+
copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Keywords: cli,copilot,github,quota,spend
|
|
34
|
+
Classifier: Development Status :: 3 - Alpha
|
|
35
|
+
Classifier: Environment :: Console
|
|
36
|
+
Classifier: Intended Audience :: Developers
|
|
37
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
38
|
+
Classifier: Operating System :: MacOS
|
|
39
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
40
|
+
Classifier: Programming Language :: Python :: 3
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
45
|
+
Classifier: Topic :: Software Development
|
|
46
|
+
Classifier: Topic :: Utilities
|
|
47
|
+
Requires-Python: >=3.10
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
|
|
52
|
+
# copilot-spend
|
|
53
|
+
|
|
54
|
+
Find out what your Copilot habit actually costs.
|
|
55
|
+
|
|
56
|
+
A small Python CLI that reads your GitHub Copilot quota and prints
|
|
57
|
+
your current-period spend in dollars and PRUs, plus when the period resets.
|
|
58
|
+
Works against both `github.com` and GitHub Enterprise hosts.
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
```mermaid
|
|
63
|
+
flowchart TD
|
|
64
|
+
Start([copilot-spend ...]) --> Cmd{subcommand?}
|
|
65
|
+
|
|
66
|
+
Cmd -- login --> Host[prompt: github.com or GHE host]
|
|
67
|
+
Host --> DeviceCode[POST /login/device/code]
|
|
68
|
+
DeviceCode --> ShowCode[show user code + verification URL]
|
|
69
|
+
ShowCode --> Poll[poll /login/oauth/access_token]
|
|
70
|
+
Poll -- access_denied / expired --> LoginFail[/exit 2: login failed/]
|
|
71
|
+
Poll -- ghu_ token --> Verify[verify token works<br/>GET /copilot_internal/user]
|
|
72
|
+
Verify -- fail --> LoginFail
|
|
73
|
+
Verify -- ok --> WriteAuth[write auth.json 0o600]
|
|
74
|
+
WriteAuth --> LoginDone[/exit 0/]
|
|
75
|
+
|
|
76
|
+
Cmd -- logout --> DeleteFiles[delete auth.json]
|
|
77
|
+
DeleteFiles --> LogoutDone[/exit 0/]
|
|
78
|
+
|
|
79
|
+
Cmd -- bare --> Native{native<br/>~/.config/copilot-spend/auth.json?}
|
|
80
|
+
Native -- yes --> NativeBearer[bearer = ghu_ token]
|
|
81
|
+
Native -- no --> Opencode{opencode<br/>~/.local/share/opencode/auth.json?}
|
|
82
|
+
Opencode -- yes --> OpencodeBearer[bearer = gho_ token]
|
|
83
|
+
Opencode -- no --> NoCreds[/exit 2: run copilot-spend login/]
|
|
84
|
+
NativeBearer --> Quota[GET /copilot_internal/user with Bearer]
|
|
85
|
+
OpencodeBearer --> Quota
|
|
86
|
+
Quota -- 200 --> Print[/print spend + reset date<br/>exit 0/]
|
|
87
|
+
Quota -- 401 / 403 --> AuthErr[/exit 2: re-authenticate/]
|
|
88
|
+
Quota -- 404 --> NoSub[/exit 4: no Copilot quota/]
|
|
89
|
+
Quota -- 5xx / timeout --> ApiErr[/exit 3: API error/]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The bare `copilot-spend` invocation, expanded as numbered steps:
|
|
93
|
+
|
|
94
|
+
1. Resolves a GitHub Copilot token from the first source that exists, in
|
|
95
|
+
this order:
|
|
96
|
+
1. Native: `~/.config/copilot-spend/auth.json`, created by running
|
|
97
|
+
`copilot-spend login`.
|
|
98
|
+
2. Opencode fallback: `~/.local/share/opencode/auth.json` (keys
|
|
99
|
+
`github-copilot.access` and `github-copilot.enterpriseUrl`), if
|
|
100
|
+
opencode is installed.
|
|
101
|
+
2. Uses the resolved token directly as the `Bearer` for the next step.
|
|
102
|
+
Both source kinds work the same way: native gives a `ghu_…` GitHub App
|
|
103
|
+
user-to-server token, opencode gives a `gho_…` OAuth App token, and
|
|
104
|
+
`/copilot_internal/user` accepts either one directly.
|
|
105
|
+
3. Calls `GET /api/v3/copilot_internal/user` on your GHE host, or
|
|
106
|
+
`GET https://api.github.com/copilot_internal/user` if no enterprise
|
|
107
|
+
host is configured.
|
|
108
|
+
4. Computes the billable overage:
|
|
109
|
+
`billable_PRUs = max(0, consumed - entitlement)`, then
|
|
110
|
+
`dollars_owed = billable_PRUs × $0.04`.
|
|
111
|
+
The first `entitlement` PRUs each period are included with your plan
|
|
112
|
+
and cost nothing.
|
|
113
|
+
5. Prints a plain-text summary on stdout.
|
|
114
|
+
|
|
115
|
+
No background daemon. No history. No session-token cache. One HTTP
|
|
116
|
+
request per run.
|
|
117
|
+
|
|
118
|
+
## Requirements
|
|
119
|
+
|
|
120
|
+
- Python 3.10 or newer
|
|
121
|
+
- macOS or Linux
|
|
122
|
+
- A GitHub Copilot token, obtained by either:
|
|
123
|
+
- running `copilot-spend login`, or
|
|
124
|
+
- having opencode installed and authenticated already
|
|
125
|
+
|
|
126
|
+
## Install
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
# Option A: pipx (persistent, isolated)
|
|
130
|
+
pipx install copilot-spend
|
|
131
|
+
|
|
132
|
+
# Option B: pip (into your active environment or --user)
|
|
133
|
+
pip install copilot-spend
|
|
134
|
+
|
|
135
|
+
# Option C: uv tool (persistent, isolated)
|
|
136
|
+
uv tool install copilot-spend
|
|
137
|
+
|
|
138
|
+
# Option D: one-off run, no install
|
|
139
|
+
uvx copilot-spend
|
|
140
|
+
pipx run copilot-spend
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Install from source
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
git clone https://github.com/nkootstra/copilot-spend.git
|
|
147
|
+
cd copilot-spend
|
|
148
|
+
|
|
149
|
+
pipx install .
|
|
150
|
+
# or:
|
|
151
|
+
uv tool install --from . copilot-spend
|
|
152
|
+
# or one-off:
|
|
153
|
+
uvx --from . copilot-spend
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Usage
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
copilot-spend # print current-period quota
|
|
160
|
+
copilot-spend login # authenticate via GitHub OAuth device flow
|
|
161
|
+
copilot-spend logout # remove copilot-spend's stored credentials
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`copilot-spend login` prompts for github.com or a GHE host, shows a
|
|
165
|
+
device code with a URL to visit, then polls until you complete the
|
|
166
|
+
flow in your browser. The new token is verified against
|
|
167
|
+
`/copilot_internal/user` before anything gets persisted. Credentials
|
|
168
|
+
land in `~/.config/copilot-spend/auth.json` (mode `0o600`). If you
|
|
169
|
+
already have opencode authenticated, the bare `copilot-spend` command
|
|
170
|
+
continues to work without login.
|
|
171
|
+
|
|
172
|
+
Example output (under your allowance):
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
GitHub Copilot - your-login (business)
|
|
176
|
+
Used: 221 PRUs
|
|
177
|
+
Allowance: $12.00 (300 PRUs included)
|
|
178
|
+
Remaining: $3.16 (79 PRUs of free allowance left)
|
|
179
|
+
Resets: May 31, 2026 (in 15 days)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Example output (over your allowance — billable overage):
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
GitHub Copilot - your-login (business)
|
|
186
|
+
Used: 4073 PRUs
|
|
187
|
+
Allowance: $12.00 (300 PRUs included)
|
|
188
|
+
Billable: $150.92 (3773 PRUs over allowance at $0.04/PRU)
|
|
189
|
+
Resets: Jun 01, 2026 (in 16 days)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Flags: `--help`, `--version`. Subcommands: `login`, `logout`.
|
|
193
|
+
|
|
194
|
+
## Exit codes
|
|
195
|
+
|
|
196
|
+
| Code | Meaning |
|
|
197
|
+
|------|---------|
|
|
198
|
+
| 0 | Success |
|
|
199
|
+
| 1 | Unexpected error |
|
|
200
|
+
| 2 | Auth error (missing/invalid `auth.json` or token) |
|
|
201
|
+
| 3 | API error (network, timeout, 4xx/5xx) |
|
|
202
|
+
| 4 | No Copilot quota on the account |
|
|
203
|
+
|
|
204
|
+
## Switch to your own GitHub App
|
|
205
|
+
|
|
206
|
+
`copilot-spend login` runs the GitHub OAuth device flow against
|
|
207
|
+
Microsoft's well-known VS Code Copilot GitHub App
|
|
208
|
+
(`Iv1.b507a08c87ecfe98`). This is the same client ID used by every
|
|
209
|
+
working third-party Copilot tool — copilot.vim, avante.nvim, LiteLLM,
|
|
210
|
+
and others — because GitHub's session-token exchange endpoint
|
|
211
|
+
(`/copilot_internal/v2/token`) only accepts tokens issued by a GitHub
|
|
212
|
+
App, not by an OAuth App.
|
|
213
|
+
|
|
214
|
+
The trade-off: the GitHub consent screen during login says "GitHub for
|
|
215
|
+
VS Code" rather than "copilot-spend", and you depend on Microsoft not
|
|
216
|
+
rotating that app. To remove both, register your own GitHub App and
|
|
217
|
+
swap the constant:
|
|
218
|
+
|
|
219
|
+
1. Visit https://github.com/settings/apps and click **New GitHub App**.
|
|
220
|
+
This must be a *GitHub App*, not an *OAuth App* — OAuth Apps issue
|
|
221
|
+
`gho_…` tokens that the Copilot exchange endpoint rejects with 404.
|
|
222
|
+
2. Set Homepage URL and Callback URL to anything (the device flow does
|
|
223
|
+
not use them).
|
|
224
|
+
3. Enable **Device flow** under "Identifying and authorizing users".
|
|
225
|
+
4. Account permissions: none required beyond user identification.
|
|
226
|
+
The `read:user` OAuth scope is enough.
|
|
227
|
+
5. Note the resulting **Client ID** (starts with `Iv23` or `Iv1.`).
|
|
228
|
+
6. Replace the `CLIENT_ID` constant in
|
|
229
|
+
`src/copilot_spend/login.py` with your new client ID.
|
|
230
|
+
7. Rebuild/reinstall (`pipx install --force .` or
|
|
231
|
+
`uv tool install --force --from . copilot-spend`).
|
|
232
|
+
|
|
233
|
+
After the swap, the consent screen shows your app's name and your
|
|
234
|
+
copilot-spend install no longer breaks if Microsoft rotates
|
|
235
|
+
`Iv1.b507a08c87ecfe98`.
|
|
236
|
+
|
|
237
|
+
## Caveats
|
|
238
|
+
|
|
239
|
+
- The PRU price is hardcoded at $0.04 (correct as of 2026-05). Update the
|
|
240
|
+
constant in `src/copilot_spend/quota.py` if GitHub changes it.
|
|
241
|
+
- v1 ships with VS Code's GitHub App ID `Iv1.b507a08c87ecfe98` for the
|
|
242
|
+
device flow. See "Switch to your own GitHub App" above to remove the
|
|
243
|
+
dependency.
|
|
244
|
+
- The billing model assumed: the first `entitlement` PRUs each period are
|
|
245
|
+
included with your plan, and anything beyond that is billable at $0.04
|
|
246
|
+
per PRU. This matches observed behavior on a business plan. Org-level
|
|
247
|
+
caps or contracts may change what you actually pay — treat the
|
|
248
|
+
`Billable` figure as a personal estimate, not an invoice.
|
|
249
|
+
- The reset-date field name in the Copilot API response is best-effort:
|
|
250
|
+
`copilot-spend` tries the field names observed on a real response, plus
|
|
251
|
+
a few defensive fallbacks, and prints `next reset: unknown` if none
|
|
252
|
+
match. Adjust `RESET_FIELD_CANDIDATES` in `quota.py` if your response
|
|
253
|
+
uses a different name.
|
|
254
|
+
- The `/copilot_internal/user` endpoint is not a public, documented API.
|
|
255
|
+
GitHub may change its shape at any time.
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
```sh
|
|
260
|
+
python -m venv .venv
|
|
261
|
+
.venv/bin/pip install -e ".[dev]"
|
|
262
|
+
.venv/bin/pytest
|
|
263
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
copilot_spend/__init__.py,sha256=mOUnkKLmpJwiljM2PF7UB0ifT-94wKv8Bo_NGBBIgt8,77
|
|
2
|
+
copilot_spend/__main__.py,sha256=aI19Q7QEFhIHNZatXr3Xkrwj1-p5nQ--LVEb48dIvzI,88
|
|
3
|
+
copilot_spend/api.py,sha256=TpaEISTOw5ZX4lZVJZgaf9l2-CuXTWQL8tVy73-7ilk,3587
|
|
4
|
+
copilot_spend/auth.py,sha256=cTz98IDi_1HnZxQt3e4EL0vjCx4FZjYeaK330zhju1s,5819
|
|
5
|
+
copilot_spend/cli.py,sha256=0tAkQJS43b1m2x0-3OHR68Qw0UEzLcaDtVxdDa4x9-M,2758
|
|
6
|
+
copilot_spend/login.py,sha256=cp7dxdP4dw0b4yP05rvPy0P2UUdz936gSa62E2jFMOg,8678
|
|
7
|
+
copilot_spend/output.py,sha256=jzj1TWxfjwcQkCPd-O5ULjVlZPFp9jdPjl4XFIs-SfY,1693
|
|
8
|
+
copilot_spend/paths.py,sha256=3SrN3QV-5ybCGh4oKvbR2mu5oS0MwN1yxdk-mgAAhhc,2340
|
|
9
|
+
copilot_spend/quota.py,sha256=tVnUdoPQRVa7D0-xmTB0qZI3k5kFNkfmHCeHv96UdDc,5023
|
|
10
|
+
copilot_spend-0.1.0.dist-info/METADATA,sha256=lgtmVNaC5xo5PFu-0GL8i0VGF2uvZzS8t6hThcqffPU,10371
|
|
11
|
+
copilot_spend-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
copilot_spend-0.1.0.dist-info/entry_points.txt,sha256=HGni_718xl1N0OzIN8Nq7KOJtghb1GDUXhgP8ENwwGM,64
|
|
13
|
+
copilot_spend-0.1.0.dist-info/licenses/LICENSE,sha256=OIDefE7LMks4ClJdo8FZ4rJNaaoNksNHKYDfpLjump8,1062
|
|
14
|
+
copilot_spend-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Niels
|
|
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.
|