Skip to content

Commit 029f39b

Browse files
authored
feat: port maybeRedeemCredits() from get-api-key.tsx to login_with_chatgpt.py (#1221)
This builds on #1212 and ports the `maybeRedeemCredits()` function from `get-api-key.ts` to `login_with_chatgpt.py`: https://github.com/openai/codex/blob/a80240cfdc345a33508333e16560759b2a4abf6d/codex-cli/src/utils/get-api-key.tsx#L84-L89
1 parent a80240c commit 029f39b

File tree

1 file changed

+221
-7
lines changed

1 file changed

+221
-7
lines changed

codex-rs/login/src/login_with_chatgpt.py

Lines changed: 221 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
99
The script should exit with a non-zero code if the user fails to navigate the
1010
auth flow.
11+
12+
To test this script locally without overwriting your existing auth.json file:
13+
14+
```
15+
rm -rf /tmp/codex_home && mkdir /tmp/codex_home
16+
CODEX_HOME=/tmp/codex_home python3 codex-rs/login/src/login_with_chatgpt.py
17+
```
1118
"""
1219

1320
from __future__ import annotations
@@ -23,10 +30,12 @@
2330
import secrets
2431
import sys
2532
import threading
33+
import time
2634
import urllib.parse
2735
import urllib.request
2836
import webbrowser
2937
from dataclasses import dataclass
38+
from typing import Any, Dict # for type hints
3039

3140
# Required port for OAuth client.
3241
REQUIRED_PORT = 1455
@@ -244,12 +253,8 @@ def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
244253
if len(access_token_parts) != 3:
245254
raise ValueError("Invalid access token")
246255

247-
id_token_claims = json.loads(
248-
base64.urlsafe_b64decode(id_token_parts[1] + "==").decode("utf-8")
249-
)
250-
access_token_claims = json.loads(
251-
base64.urlsafe_b64decode(access_token_parts[1] + "==").decode("utf-8")
252-
)
256+
id_token_claims = _decode_jwt_segment(id_token_parts[1])
257+
access_token_claims = _decode_jwt_segment(access_token_parts[1])
253258

254259
token_claims = id_token_claims.get("https://api.openai.com/auth", {})
255260
access_claims = access_token_claims.get("https://api.openai.com/auth", {})
@@ -313,7 +318,20 @@ def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
313318
}
314319
success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_url_query)}"
315320

316-
# TODO(mbolin): Port maybeRedeemCredits() to Python and call it here.
321+
# Attempt to redeem complimentary API credits for eligible ChatGPT
322+
# Plus / Pro subscribers. Any errors are logged but do not interrupt
323+
# the login flow.
324+
325+
try:
326+
maybe_redeem_credits(
327+
issuer=self.server.issuer,
328+
client_id=self.server.client_id,
329+
id_token=token_data.id_token,
330+
refresh_token=token_data.refresh_token,
331+
codex_home=self.server.codex_home,
332+
)
333+
except Exception as exc: # pragma: no cover – best-effort only
334+
eprint(f"Unable to redeem ChatGPT subscriber API credits: {exc}")
317335

318336
# Persist refresh_token/id_token for future use (redeem credits etc.)
319337
last_refresh_str = (
@@ -417,6 +435,163 @@ def auth_url(self) -> str:
417435
return f"{self.issuer}/oauth/authorize?" + urllib.parse.urlencode(params)
418436

419437

438+
def maybe_redeem_credits(
439+
*,
440+
issuer: str,
441+
client_id: str,
442+
id_token: str | None,
443+
refresh_token: str,
444+
codex_home: str,
445+
) -> None:
446+
"""Attempt to redeem complimentary API credits for ChatGPT subscribers.
447+
448+
The operation is best-effort: any error results in a warning being printed
449+
and the function returning early without raising.
450+
"""
451+
id_claims: Dict[str, Any] | None = parse_id_token_claims(id_token or "")
452+
453+
# Refresh expired ID token, if possible
454+
token_expired = True
455+
if id_claims and isinstance(id_claims.get("exp"), int):
456+
token_expired = _current_timestamp_ms() >= int(id_claims["exp"]) * 1000
457+
458+
if token_expired:
459+
eprint("Refreshing credentials...")
460+
new_refresh_token: str | None = None
461+
new_id_token: str | None = None
462+
463+
try:
464+
payload = json.dumps(
465+
{
466+
"client_id": client_id,
467+
"grant_type": "refresh_token",
468+
"refresh_token": refresh_token,
469+
"scope": "openid profile email",
470+
}
471+
).encode()
472+
473+
req = urllib.request.Request(
474+
url="https://auth.openai.com/oauth/token",
475+
data=payload,
476+
method="POST",
477+
headers={"Content-Type": "application/json"},
478+
)
479+
480+
with urllib.request.urlopen(req) as resp:
481+
refresh_data = json.loads(resp.read().decode())
482+
new_id_token = refresh_data.get("id_token")
483+
new_id_claims = parse_id_token_claims(new_id_token or "")
484+
new_refresh_token = refresh_data.get("refresh_token")
485+
except Exception as err:
486+
eprint("Unable to refresh ID token via token-exchange:", err)
487+
return
488+
489+
if not new_id_token or not new_refresh_token:
490+
return
491+
492+
# Update auth.json with new tokens.
493+
try:
494+
auth_dir = codex_home
495+
auth_path = os.path.join(auth_dir, "auth.json")
496+
with open(auth_path, "r", encoding="utf-8") as fp:
497+
existing = json.load(fp)
498+
499+
tokens = existing.setdefault("tokens", {})
500+
tokens["id_token"] = new_id_token
501+
# Note this does not touch the access_token?
502+
tokens["refresh_token"] = new_refresh_token
503+
tokens["last_refresh"] = (
504+
datetime.datetime.now(datetime.timezone.utc)
505+
.isoformat()
506+
.replace("+00:00", "Z")
507+
)
508+
509+
with open(auth_path, "w", encoding="utf-8") as fp:
510+
if hasattr(os, "fchmod"):
511+
os.fchmod(fp.fileno(), 0o600)
512+
json.dump(existing, fp, indent=2)
513+
except Exception as err:
514+
eprint("Unable to update refresh token in auth file:", err)
515+
516+
if not new_id_claims:
517+
# Still couldn't parse claims.
518+
return
519+
520+
id_token = new_id_token
521+
id_claims = new_id_claims
522+
523+
# Done refreshing credentials: now try to redeem credits.
524+
if not id_token:
525+
eprint("No ID token available, cannot redeem credits.")
526+
return
527+
528+
auth_claims = id_claims.get("https://api.openai.com/auth", {})
529+
530+
# Subscription eligibility check (Plus or Pro, >7 days active)
531+
sub_start_str = auth_claims.get("chatgpt_subscription_active_start")
532+
if isinstance(sub_start_str, str):
533+
try:
534+
sub_start_ts = datetime.datetime.fromisoformat(sub_start_str.rstrip("Z"))
535+
if datetime.datetime.now(
536+
datetime.timezone.utc
537+
) - sub_start_ts < datetime.timedelta(days=7):
538+
eprint(
539+
"Sorry, your subscription must be active for more than 7 days to redeem credits."
540+
)
541+
return
542+
except ValueError:
543+
# Malformed; ignore
544+
pass
545+
546+
completed_onboarding = bool(auth_claims.get("completed_platform_onboarding"))
547+
is_org_owner = bool(auth_claims.get("is_org_owner"))
548+
needs_setup = not completed_onboarding and is_org_owner
549+
plan_type = auth_claims.get("chatgpt_plan_type")
550+
551+
if needs_setup or plan_type not in {"plus", "pro"}:
552+
eprint("Only users with Plus or Pro subscriptions can redeem free API credits.")
553+
return
554+
555+
api_host = (
556+
"https://api.openai.com"
557+
if issuer == "https://auth.openai.com"
558+
else "https://api.openai.org"
559+
)
560+
561+
try:
562+
redeem_payload = json.dumps({"id_token": id_token}).encode()
563+
req = urllib.request.Request(
564+
url=f"{api_host}/v1/billing/redeem_credits",
565+
data=redeem_payload,
566+
method="POST",
567+
headers={"Content-Type": "application/json"},
568+
)
569+
570+
with urllib.request.urlopen(req) as resp:
571+
redeem_data = json.loads(resp.read().decode())
572+
573+
granted = redeem_data.get("granted_chatgpt_subscriber_api_credits", 0)
574+
if granted and granted > 0:
575+
eprint(
576+
f"""Thanks for being a ChatGPT {'Plus' if plan_type=='plus' else 'Pro'} subscriber!
577+
If you haven't already redeemed, you should receive {'$5' if plan_type=='plus' else '$50'} in API credits.
578+
579+
Credits: https://platform.openai.com/settings/organization/billing/credit-grants
580+
More info: https://help.openai.com/en/articles/11381614""",
581+
)
582+
else:
583+
eprint(
584+
f"""It looks like no credits were granted:
585+
586+
{json.dumps(redeem_data, indent=2)}
587+
588+
Credits: https://platform.openai.com/settings/organization/billing/credit-grants
589+
More info: https://help.openai.com/en/articles/11381614"""
590+
)
591+
except Exception as err:
592+
eprint("Credit redemption request failed:", err)
593+
594+
420595
def _generate_pkce() -> PkceCodes:
421596
"""Generate PKCE *code_verifier* and *code_challenge* (S256)."""
422597
code_verifier = secrets.token_hex(64)
@@ -429,6 +604,45 @@ def eprint(*args, **kwargs) -> None:
429604
print(*args, file=sys.stderr, **kwargs)
430605

431606

607+
# Parse ID-token claims (if provided)
608+
#
609+
# interface IDTokenClaims {
610+
# "exp": number; // specifically, an int
611+
# "https://api.openai.com/auth": {
612+
# organization_id: string;
613+
# project_id: string;
614+
# completed_platform_onboarding: boolean;
615+
# is_org_owner: boolean;
616+
# chatgpt_subscription_active_start: string;
617+
# chatgpt_subscription_active_until: string;
618+
# chatgpt_plan_type: string;
619+
# };
620+
# }
621+
def parse_id_token_claims(id_token: str) -> Dict[str, Any] | None:
622+
if id_token:
623+
parts = id_token.split(".")
624+
if len(parts) == 3:
625+
return _decode_jwt_segment(parts[1])
626+
return None
627+
628+
629+
def _decode_jwt_segment(segment: str) -> Dict[str, Any]:
630+
"""Return the decoded JSON payload from a JWT segment.
631+
632+
Adds required padding for urlsafe_b64decode.
633+
"""
634+
padded = segment + "=" * (-len(segment) % 4)
635+
try:
636+
data = base64.urlsafe_b64decode(padded.encode())
637+
return json.loads(data.decode())
638+
except Exception:
639+
return {}
640+
641+
642+
def _current_timestamp_ms() -> int:
643+
return int(time.time() * 1000)
644+
645+
432646
LOGIN_SUCCESS_HTML = """<!DOCTYPE html>
433647
<html lang="en">
434648
<head>

0 commit comments

Comments
 (0)