The model and the reasoning effort of the summaries. The defaults are
``gpt-6-luna`` and ``high``.
+*******
+ Login
+*******
+
+The ``chatgpt`` endpoint needs a login with a ChatGPT account:
+
+.. code:: console
+
+ onechat login
+
+The command shows a link and a code. Open the link on any device, enter
+the code, and approve the login. Device code login must be allowed in
+the ChatGPT security settings. OneChat writes the tokens to
+``auth.json`` in the data directory, with mode 0600, and refreshes them
+before they expire. The file belongs to OneChat only. Do not share it
+with the Codex CLI, because each program rotates the refresh token and
+logs the other out.
+
*************
Development
*************
--- /dev/null
+package main
+
+import (
+ "bytes"
+ "cmp"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ // authIssuer is the OpenAI login service.
+ authIssuer = "https://auth.openai.com"
+
+ // codexClientID is the OAuth client ID of the Codex CLI. The ChatGPT
+ // backend takes the tokens of this client only.
+ codexClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
+
+ // authClaim is the JWT claim that holds the ChatGPT account ID.
+ authClaim = "https://api.openai.com/auth"
+
+ // refreshMargin is how long before its expiry an access token is
+ // refreshed.
+ refreshMargin = 60 * time.Second
+
+ // loginTimeout is how long a device code login waits for the user.
+ loginTimeout = 15 * time.Minute
+
+ // tokenFileName is the name of the token file in the data directory.
+ tokenFileName = "auth.json"
+
+ // defaultPollInterval is the poll interval of a device code login when
+ // the login service gives none.
+ defaultPollInterval = 5 * time.Second
+)
+
+// errLoginNeeded tells that no valid token is available, so the user must run
+// "onechat login".
+var errLoginNeeded = errors.New(`login needed: run "onechat login"`)
+
+// chatgptAuth authorizes model calls to the ChatGPT backend with the tokens in
+// the token file. The tokens belong to OneChat only. The Codex CLI rotates the
+// refresh token on each refresh, so two programs with one token file log each
+// other out.
+//
+// Before each call it reads the expiry from the access token. Within
+// refreshMargin of the expiry it gets new tokens with the refresh token and
+// replaces the file atomically. A mutex lets one refresh run at a time, and a
+// call that waits for it uses the new tokens.
+type chatgptAuth struct {
+ mutex sync.Mutex
+ path string
+ issuer string
+ http *http.Client
+ clock func() time.Time
+}
+
+// tokenFile is the content of the token file.
+type tokenFile struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token"`
+ AccountID string `json:"account_id"`
+}
+
+// tokenResponse is the answer of the token endpoint.
+type tokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token"`
+}
+
+// deviceCode is the answer of the user code endpoint. The interval is a number
+// or a string of seconds.
+type deviceCode struct {
+ DeviceAuthID string `json:"device_auth_id"`
+ UserCode string `json:"user_code"`
+ UserCodeAlt string `json:"usercode"`
+ Interval json.RawMessage `json:"interval"`
+}
+
+// deviceGrant is the answer of the device token endpoint after the user
+// approves the code.
+type deviceGrant struct {
+ AuthorizationCode string `json:"authorization_code"`
+ CodeVerifier string `json:"code_verifier"`
+}
+
+// login runs "onechat login": the device code login into the token file of the
+// data directory.
+func login(ctx context.Context, out io.Writer) error {
+ dir, err := dataDir()
+ if err != nil {
+ return err
+ }
+
+ return newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer).Login(ctx, out)
+}
+
+// newChatGPTAuth returns the authorizer for the token file at path, with the
+// login service at issuer.
+func newChatGPTAuth(path, issuer string) *chatgptAuth {
+ return &chatgptAuth{
+ path: path,
+ issuer: issuer,
+ http: &http.Client{},
+ clock: time.Now,
+ }
+}
+
+// Login runs the device code login. It writes the link and the code for the
+// user to out, waits until the user approves the code, and writes the tokens
+// to the token file.
+func (a *chatgptAuth) Login(ctx context.Context, out io.Writer) error {
+ a.mutex.Lock()
+ defer a.mutex.Unlock()
+
+ var code deviceCode
+ err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
+ "client_id": codexClientID,
+ }, &code)
+ if err != nil {
+ return fmt.Errorf("request device code (device code login must be allowed in the ChatGPT security settings): %w", err)
+ }
+
+ userCode := cmp.Or(code.UserCode, code.UserCodeAlt)
+ if code.DeviceAuthID == "" || userCode == "" {
+ return errors.New("device code response has no device ID or user code")
+ }
+
+ _, err = fmt.Fprintf(out, "Open %s/codex/device on any device and enter this code:\n\n %s\n\nWaiting for the approval...\n", a.issuer, userCode)
+ if err != nil {
+ return err
+ }
+
+ grant, err := a.poll(ctx, code.DeviceAuthID, userCode, pollInterval(code.Interval))
+ if err != nil {
+ return err
+ }
+
+ tokens, err := a.exchange(ctx, grant)
+ if err != nil {
+ return err
+ }
+
+ err = a.write(tokens)
+ if err != nil {
+ return err
+ }
+
+ _, err = fmt.Fprintln(out, "Logged in.")
+
+ return err
+}
+
+// postJSON posts body as JSON to the path of the login service and decodes the
+// answer into result.
+func (a *chatgptAuth) postJSON(ctx context.Context, path string, body, result any) error {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("encode request: %w", err)
+ }
+
+ _, err = a.post(ctx, path, "application/json", data, result)
+
+ return err
+}
+
+// post posts body to the path of the login service and decodes a 200 answer
+// into result. It returns the HTTP status, or 0 when no answer came. The error
+// holds the status, but not the body, which can hold a token.
+func (a *chatgptAuth) post(ctx context.Context, path, contentType string, body []byte, result any) (status int, err error) {
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.issuer+path, bytes.NewReader(body))
+ if err != nil {
+ return 0, fmt.Errorf("create request: %w", err)
+ }
+
+ request.Header.Set("Content-Type", contentType)
+ response, err := a.http.Do(request)
+ if err != nil {
+ return 0, fmt.Errorf("send request: %w", err)
+ }
+
+ defer func() {
+ err = errors.Join(err, response.Body.Close())
+ }()
+
+ if response.StatusCode != http.StatusOK {
+ return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode)
+ }
+
+ err = json.NewDecoder(response.Body).Decode(result)
+ if err != nil {
+ return response.StatusCode, fmt.Errorf("decode answer: %w", err)
+ }
+
+ return response.StatusCode, nil
+}
+
+// poll asks for the grant of the device code until the user approves it or the
+// login times out.
+func (a *chatgptAuth) poll(ctx context.Context, deviceID, userCode string, interval time.Duration) (deviceGrant, error) {
+ ctx, cancel := context.WithTimeout(ctx, loginTimeout)
+ defer cancel()
+
+ body, err := json.Marshal(map[string]string{
+ "device_auth_id": deviceID,
+ "user_code": userCode,
+ })
+ if err != nil {
+ return deviceGrant{}, fmt.Errorf("encode poll request: %w", err)
+ }
+
+ for {
+ var grant deviceGrant
+ status, err := a.post(ctx, "/api/accounts/deviceauth/token", "application/json", body, &grant)
+ if err == nil && (grant.AuthorizationCode == "" || grant.CodeVerifier == "") {
+ err = errors.New("device grant has no authorization code or code verifier")
+ }
+
+ if err == nil {
+ return grant, nil
+ }
+
+ if status != http.StatusForbidden && status != http.StatusNotFound {
+ return deviceGrant{}, fmt.Errorf("poll device code: %w", err)
+ }
+
+ timer := time.NewTimer(interval)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return deviceGrant{}, fmt.Errorf("wait for approval: %w", ctx.Err())
+ case <-timer.C:
+ }
+ }
+}
+
+// exchange trades the grant of the device code for tokens.
+func (a *chatgptAuth) exchange(ctx context.Context, grant deviceGrant) (tokenFile, error) {
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {grant.AuthorizationCode},
+ "redirect_uri": {a.issuer + "/deviceauth/callback"},
+ "client_id": {codexClientID},
+ "code_verifier": {grant.CodeVerifier},
+ }
+
+ var response tokenResponse
+ _, err := a.post(ctx, "/oauth/token", "application/x-www-form-urlencoded", []byte(form.Encode()), &response)
+ if err != nil {
+ return tokenFile{}, fmt.Errorf("exchange code for tokens: %w", err)
+ }
+
+ if response.AccessToken == "" || response.RefreshToken == "" || response.IDToken == "" {
+ return tokenFile{}, errors.New("token response has no access, refresh, or ID token")
+ }
+
+ tokens := tokenFile{
+ AccessToken: response.AccessToken,
+ RefreshToken: response.RefreshToken,
+ IDToken: response.IDToken,
+ }
+
+ tokens.AccountID = cmp.Or(accountID(tokens.AccessToken), accountID(tokens.IDToken))
+ if tokens.AccountID == "" {
+ return tokenFile{}, errors.New("tokens have no ChatGPT account ID")
+ }
+
+ return tokens, nil
+}
+
+// write replaces the token file atomically. The file has mode 0600.
+func (a *chatgptAuth) write(tokens tokenFile) error {
+ data, err := json.Marshal(tokens)
+ if err != nil {
+ return fmt.Errorf("encode tokens: %w", err)
+ }
+
+ err = writeFileAtomic(a.path, append(data, '\n'))
+ if err != nil {
+ return fmt.Errorf("write token file: %w", err)
+ }
+
+ return nil
+}
+
+// Authorize sets the access token and the account ID, after a refresh when the
+// token expires within refreshMargin.
+func (a *chatgptAuth) Authorize(ctx context.Context, header http.Header) error {
+ a.mutex.Lock()
+ defer a.mutex.Unlock()
+
+ tokens, err := a.read()
+ if err != nil {
+ return err
+ }
+
+ expiry, found := tokenExpiry(tokens.AccessToken)
+ if !found || !a.clock().Before(expiry.Add(-refreshMargin)) {
+ tokens, err = a.refresh(ctx, tokens)
+ if err != nil {
+ return err
+ }
+ }
+
+ header.Set("Authorization", "Bearer "+tokens.AccessToken)
+ header.Set("ChatGPT-Account-Id", tokens.AccountID)
+
+ return nil
+}
+
+// read reads the token file.
+func (a *chatgptAuth) read() (tokenFile, error) {
+ data, err := os.ReadFile(a.path)
+ if errors.Is(err, os.ErrNotExist) {
+ return tokenFile{}, errLoginNeeded
+ }
+
+ if err != nil {
+ return tokenFile{}, fmt.Errorf("read token file: %w", err)
+ }
+
+ var tokens tokenFile
+ err = json.Unmarshal(data, &tokens)
+ if err == nil && (tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.AccountID == "") {
+ err = errors.New("a token or the account ID is missing")
+ }
+
+ if err != nil {
+ return tokenFile{}, fmt.Errorf("token file: %w: %w", err, errLoginNeeded)
+ }
+
+ return tokens, nil
+}
+
+// refresh gets new tokens with the refresh token and replaces the token file.
+// It keeps the old refresh token and ID token when the answer has none.
+func (a *chatgptAuth) refresh(ctx context.Context, tokens tokenFile) (tokenFile, error) {
+ var response tokenResponse
+ err := a.postJSON(ctx, "/oauth/token", map[string]string{
+ "client_id": codexClientID,
+ "grant_type": "refresh_token",
+ "refresh_token": tokens.RefreshToken,
+ "scope": "openid profile email",
+ }, &response)
+ if err == nil && response.AccessToken == "" {
+ err = errors.New("refresh response has no access token")
+ }
+
+ if err != nil {
+ return tokenFile{}, fmt.Errorf("refresh tokens: %w: %w", err, errLoginNeeded)
+ }
+
+ tokens.AccessToken = response.AccessToken
+ tokens.RefreshToken = cmp.Or(response.RefreshToken, tokens.RefreshToken)
+ tokens.IDToken = cmp.Or(response.IDToken, tokens.IDToken)
+ tokens.AccountID = cmp.Or(accountID(tokens.AccessToken), accountID(tokens.IDToken), tokens.AccountID)
+
+ err = a.write(tokens)
+ if err != nil {
+ return tokenFile{}, err
+ }
+
+ return tokens, nil
+}
+
+// pollInterval returns the poll interval of a device code, given in seconds as
+// a number or a string.
+func pollInterval(raw json.RawMessage) time.Duration {
+ seconds, err := strconv.ParseFloat(strings.Trim(string(raw), `"`), 64)
+ if err != nil || seconds < 0 {
+ return defaultPollInterval
+ }
+
+ return time.Duration(seconds * float64(time.Second))
+}
+
+// accountID returns the ChatGPT account ID of a JWT, or "" when it has none.
+func accountID(token string) string {
+ var claims map[string]json.RawMessage
+ if !decodeClaims(token, &claims) {
+ return ""
+ }
+
+ var auth struct {
+ AccountID string `json:"chatgpt_account_id"`
+ }
+
+ err := json.Unmarshal(claims[authClaim], &auth)
+ if err != nil {
+ return ""
+ }
+
+ return auth.AccountID
+}
+
+// decodeClaims decodes the payload of a JWT into claims, without a check of
+// the signature. The token comes from the login service over HTTPS, and the
+// client reads only the expiry and the account ID from it.
+func decodeClaims(token string, claims any) bool {
+ parts := strings.Split(token, ".")
+ if len(parts) != 3 {
+ return false
+ }
+
+ payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
+ if err != nil {
+ return false
+ }
+
+ return json.Unmarshal(payload, claims) == nil
+}
+
+// tokenExpiry returns the expiry of a JWT from its exp claim.
+func tokenExpiry(token string) (time.Time, bool) {
+ var claims struct {
+ Exp *float64 `json:"exp"`
+ }
+
+ if !decodeClaims(token, &claims) || claims.Exp == nil {
+ return time.Time{}, false
+ }
+
+ return time.Unix(int64(*claims.Exp), 0), true
+}
--- /dev/null
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// testAccount is the fake ChatGPT account ID in the fake tokens.
+const testAccount = "account-test"
+
+func TestLoginWritesTheTokenFile(t *testing.T) {
+ server := newFakeAuth(t)
+ server.pending = 2
+ auth := newTestAuth(t, server)
+
+ var out strings.Builder
+ err := auth.Login(t.Context(), &out)
+ require.NoError(t, err)
+
+ require.Contains(t, out.String(), server.URL+"/codex/device")
+ require.Contains(t, out.String(), "CODE-1234")
+ require.Equal(t, 3, server.polls)
+
+ form := server.exchange
+ require.Equal(t, "authorization_code", form.Get("grant_type"))
+ require.Equal(t, "auth-code", form.Get("code"))
+ require.Equal(t, "verifier", form.Get("code_verifier"))
+ require.Equal(t, codexClientID, form.Get("client_id"))
+ require.Equal(t, server.URL+"/deviceauth/callback", form.Get("redirect_uri"))
+
+ tokens := readTokenFile(t, auth.path)
+ require.Equal(t, "refresh-1", tokens.RefreshToken)
+ require.Equal(t, testAccount, tokens.AccountID)
+ require.Equal(t, server.access[0], tokens.AccessToken)
+ require.NotContains(t, out.String(), tokens.AccessToken)
+ require.NotContains(t, out.String(), tokens.RefreshToken)
+
+ info, err := os.Stat(auth.path)
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+}
+
+func TestLoginFailsWhenTheCodeIsDenied(t *testing.T) {
+ server := newFakeAuth(t)
+ server.deny = true
+ auth := newTestAuth(t, server)
+
+ err := auth.Login(t.Context(), io.Discard)
+ require.ErrorContains(t, err, "HTTP 400")
+ require.NoFileExists(t, auth.path)
+}
+
+func TestAuthorizeUsesAFreshTokenWithoutRefresh(t *testing.T) {
+ server := newFakeAuth(t)
+ auth := newTestAuth(t, server)
+ writeTestTokens(t, auth, server.token(time.Hour), "refresh-0")
+
+ header := http.Header{}
+ err := auth.Authorize(t.Context(), header)
+ require.NoError(t, err)
+ require.Equal(t, "Bearer "+server.access[0], header.Get("Authorization"))
+ require.Equal(t, testAccount, header.Get("ChatGPT-Account-Id"))
+ require.Equal(t, 0, server.refreshes)
+}
+
+func TestAuthorizeRefreshesNearTheExpiry(t *testing.T) {
+ testCases := []struct {
+ name string
+ newRefresh string
+ wantRefresh string
+ }{
+ {
+ name: "new refresh token kept",
+ newRefresh: "refresh-new",
+ wantRefresh: "refresh-new",
+ },
+ {
+ name: "old refresh token kept without a new one",
+ newRefresh: "",
+ wantRefresh: "refresh-0",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ server := newFakeAuth(t)
+ server.newRefresh = tc.newRefresh
+ auth := newTestAuth(t, server)
+ writeTestTokens(t, auth, server.token(59*time.Second), "refresh-0")
+
+ header := http.Header{}
+ err := auth.Authorize(t.Context(), header)
+ require.NoError(t, err)
+ require.Equal(t, 1, server.refreshes)
+ require.Equal(t, "refresh-0", server.refreshed)
+
+ tokens := readTokenFile(t, auth.path)
+ require.Equal(t, server.access[1], tokens.AccessToken)
+ require.Equal(t, tc.wantRefresh, tokens.RefreshToken)
+ require.Equal(t, testAccount, tokens.AccountID)
+ require.Equal(t, "Bearer "+server.access[1], header.Get("Authorization"))
+
+ err = auth.Authorize(t.Context(), header)
+ require.NoError(t, err)
+ require.Equal(t, 1, server.refreshes)
+ })
+ }
+}
+
+func TestAuthorizeRunsOneRefreshAtATime(t *testing.T) {
+ server := newFakeAuth(t)
+ server.newRefresh = "refresh-new"
+ auth := newTestAuth(t, server)
+ writeTestTokens(t, auth, server.token(time.Second), "refresh-0")
+
+ var group sync.WaitGroup
+ errs := make(chan error, 8)
+ for range 8 {
+ group.Go(func() {
+ errs <- auth.Authorize(t.Context(), http.Header{})
+ })
+ }
+
+ group.Wait()
+ close(errs)
+ for err := range errs {
+ require.NoError(t, err)
+ }
+
+ require.Equal(t, 1, server.refreshes)
+}
+
+func TestAuthorizeNeedsALogin(t *testing.T) {
+ server := newFakeAuth(t)
+ auth := newTestAuth(t, server)
+
+ err := auth.Authorize(t.Context(), http.Header{})
+ require.ErrorIs(t, err, errLoginNeeded)
+
+ server.failRefresh = true
+ writeTestTokens(t, auth, server.token(-time.Minute), "refresh-0")
+ before, err := os.ReadFile(auth.path)
+ require.NoError(t, err)
+
+ err = auth.Authorize(t.Context(), http.Header{})
+ require.ErrorIs(t, err, errLoginNeeded)
+ require.NotContains(t, err.Error(), "refresh-0")
+
+ after, err := os.ReadFile(auth.path)
+ require.NoError(t, err)
+ require.Equal(t, before, after)
+}
+
+func TestForcedRefreshLetsTheNextCallWork(t *testing.T) {
+ server := newFakeAuth(t)
+ auth := newTestAuth(t, server)
+ writeTestTokens(t, auth, server.token(-time.Hour), "refresh-0")
+
+ responses := newFakeResponses(t)
+ client := newClient(responses.URL, auth, "agent-key", filepath.Join(t.TempDir(), "usage.jsonl"))
+ _, err := client.Create(t.Context(), testRequest())
+ require.NoError(t, err)
+
+ require.Equal(t, 1, server.refreshes)
+ call := responses.Call(0)
+ require.Equal(t, "Bearer "+server.access[1], call.header.Get("Authorization"))
+ require.Equal(t, testAccount, call.header.Get("ChatGPT-Account-Id"))
+ require.Equal(t, server.access[1], readTokenFile(t, auth.path).AccessToken)
+}
+
+func TestModelEndpointFollowsTheConfig(t *testing.T) {
+ dir := t.TempDir()
+ config := defaultConfig()
+
+ t.Setenv("OPENAI_API_KEY", testKey)
+ address, auth, err := modelEndpoint(config, dir)
+ require.NoError(t, err)
+ require.Equal(t, chatgptURL, address)
+ require.IsType(t, &chatgptAuth{}, auth)
+ require.Equal(t, filepath.Join(dir, tokenFileName), auth.(*chatgptAuth).path)
+
+ config.Endpoint = endpointAPI
+ address, auth, err = modelEndpoint(config, dir)
+ require.NoError(t, err)
+ require.Equal(t, apiURL, address)
+ require.Equal(t, apiKey(testKey), auth)
+
+ t.Setenv("OPENAI_API_KEY", "")
+ _, _, err = modelEndpoint(config, dir)
+ require.ErrorContains(t, err, "OPENAI_API_KEY")
+}
+
+// fakeAuth is an httptest login service. The device token endpoint answers
+// 403 pending times before it grants the code. Each token answer has a new
+// access token, kept in access.
+type fakeAuth struct {
+ *httptest.Server
+ mutex sync.Mutex
+ pending int
+ deny bool
+ polls int
+ exchange url.Values
+ refreshes int
+ refreshed string
+ newRefresh string
+ failRefresh bool
+ access []string
+}
+
+func newFakeAuth(t *testing.T) *fakeAuth {
+ t.Helper()
+
+ f := &fakeAuth{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
+ mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
+ mux.HandleFunc("POST /oauth/token", f.oauthToken)
+ f.Server = httptest.NewServer(mux)
+ t.Cleanup(f.Close)
+
+ return f
+}
+
+func (f *fakeAuth) userCode(w http.ResponseWriter, r *http.Request) {
+ var body map[string]string
+ err := json.NewDecoder(r.Body).Decode(&body)
+ if err != nil || body["client_id"] != codexClientID {
+ http.Error(w, "bad client", http.StatusBadRequest)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "device_auth_id": "device-1",
+ "user_code": "CODE-1234",
+ "interval": "0",
+ })
+}
+
+func (f *fakeAuth) deviceToken(w http.ResponseWriter, r *http.Request) {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ var body map[string]string
+ err := json.NewDecoder(r.Body).Decode(&body)
+ if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" {
+ http.Error(w, "bad device", http.StatusBadRequest)
+ return
+ }
+
+ f.polls++
+ if f.deny {
+ http.Error(w, "denied", http.StatusBadRequest)
+ return
+ }
+
+ if f.polls <= f.pending {
+ http.Error(w, "pending", http.StatusForbidden)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "authorization_code": "auth-code",
+ "code_challenge": "challenge",
+ "code_verifier": "verifier",
+ })
+}
+
+func (f *fakeAuth) oauthToken(w http.ResponseWriter, r *http.Request) {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ f.exchange = r.PostForm
+ writeJSON(w, map[string]any{
+ "access_token": f.newToken(time.Hour),
+ "refresh_token": "refresh-1",
+ "id_token": f.newToken(time.Hour),
+ })
+
+ return
+ }
+
+ var body map[string]string
+ err := json.NewDecoder(r.Body).Decode(&body)
+ if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != codexClientID {
+ http.Error(w, "bad refresh", http.StatusBadRequest)
+ return
+ }
+
+ f.refreshes++
+ f.refreshed = body["refresh_token"]
+ if f.failRefresh {
+ http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized)
+ return
+ }
+
+ answer := map[string]any{
+ "access_token": f.newToken(time.Hour),
+ "id_token": f.newToken(time.Hour),
+ }
+
+ if f.newRefresh != "" {
+ answer["refresh_token"] = f.newRefresh
+ }
+
+ writeJSON(w, answer)
+}
+
+// token returns a new access token that expires after ttl, for a token file
+// that a test writes.
+func (f *fakeAuth) token(ttl time.Duration) string {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ return f.newToken(ttl)
+}
+
+// newToken returns a new fake JWT with the expiry and the account claim, and
+// keeps it in access. The caller holds the mutex.
+func (f *fakeAuth) newToken(ttl time.Duration) string {
+ claims, err := json.Marshal(map[string]any{
+ "exp": time.Now().Add(ttl).Unix(),
+ authClaim: map[string]any{
+ "chatgpt_account_id": testAccount,
+ },
+ "serial": len(f.access),
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ encode := base64.RawURLEncoding.EncodeToString
+ token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake"))
+ f.access = append(f.access, token)
+
+ return token
+}
+
+func newTestAuth(t *testing.T, server *fakeAuth) *chatgptAuth {
+ t.Helper()
+
+ return newChatGPTAuth(filepath.Join(t.TempDir(), tokenFileName), server.URL)
+}
+
+func writeTestTokens(t *testing.T, auth *chatgptAuth, access, refresh string) {
+ t.Helper()
+
+ err := auth.write(tokenFile{
+ AccessToken: access,
+ RefreshToken: refresh,
+ IDToken: access,
+ AccountID: testAccount,
+ })
+ require.NoError(t, err)
+}
+
+func readTokenFile(t *testing.T, path string) tokenFile {
+ t.Helper()
+
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ var tokens tokenFile
+ require.NoError(t, json.Unmarshal(data, &tokens))
+
+ return tokens
+}
+
+func writeJSON(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ err := json.NewEncoder(w).Encode(value)
+ if err != nil {
+ panic(fmt.Sprint("encode answer: ", err))
+ }
+}
"fmt"
"os"
"os/signal"
+ "strings"
"syscall"
)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
- _, err := fmt.Println("onechat")
- if err != nil {
- return err
+ args := os.Args[1:]
+ switch {
+ case len(args) == 0:
+ _, err := fmt.Println("onechat")
+ if err != nil {
+ return err
+ }
+ case len(args) == 1 && args[0] == "login":
+ err := login(ctx, os.Stdout)
+ if err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("unknown command %q", strings.Join(args, " "))
}
return ctx.Err()
"io"
"net/http"
"os"
+ "path/filepath"
"strings"
"sync"
"time"
)
const (
+ // chatgptURL is the Responses endpoint of the ChatGPT backend.
+ chatgptURL = "https://chatgpt.com/backend-api/codex/responses"
+
+ // apiURL is the Responses endpoint of the OpenAI API.
+ apiURL = "https://api.openai.com/v1/responses"
+
// quotaPrefix starts the names of the quota headers of the ChatGPT
// backend, in canonical form.
quotaPrefix = "X-Codex-"
} `json:"content"`
}
+// modelEndpoint returns the URL and the authorizer of the endpoint in config.
+// The ChatGPT backend uses the token file in dir. The OpenAI API uses the key in
+// OPENAI_API_KEY, and there is no fallback from one to the other. All clients
+// share the authorizer, so one refresh runs at a time.
+func modelEndpoint(config Config, dir string) (string, authorizer, error) {
+ if config.Endpoint == endpointAPI {
+ key := os.Getenv("OPENAI_API_KEY")
+ if key == "" {
+ return "", nil, errors.New("endpoint api needs OPENAI_API_KEY")
+ }
+
+ return apiURL, apiKey(key), nil
+ }
+
+ return chatgptURL, newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer), nil
+}
+
// newClient returns a client for the Responses endpoint at url. It records
// each call in the usage file at usagePath.
func newClient(url string, auth authorizer, cacheKey, usagePath string) *Client {