]> git.giacomolli.uk - onechat/blobdiff - chatgpt.go
Order daemon and storage declarations
[onechat] / chatgpt.go
index 7a5afc20865b4b547af24fa73449d0ed506e816b..ad40abd8d365627ff7d4720ebc394b4112f3857e 100644 (file)
@@ -51,37 +51,6 @@ const (
 // must run "onechat login".
 var errChatGPTLoginNeeded = errors.New(`login needed: run "onechat login"`)
 
-// chatgptLogin runs "onechat login": the device code login into the token file
-// of the data directory. It holds the lock of the token file, so it does not
-// run while the daemon runs.
-func chatgptLogin(ctx context.Context, out io.Writer) (err error) {
-       dir, err := dataDir()
-       if err != nil {
-               return err
-       }
-
-       tokens, err := lockTokens(dir)
-       if err != nil {
-               return err
-       }
-
-       defer func() {
-               err = errors.Join(err, tokens.Close())
-       }()
-
-       return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).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{},
-       }
-}
-
 // 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
@@ -98,6 +67,47 @@ type ChatGPTAuth struct {
        http   *http.Client
 }
 
+// ChatGPTDeviceCode is the answer of the user code endpoint. The interval is a
+// number or a string of seconds.
+type ChatGPTDeviceCode struct {
+       DeviceAuthID string          `json:"device_auth_id"`
+       UserCode     string          `json:"user_code"`
+       UserCodeAlt  string          `json:"usercode"`
+       Interval     json.RawMessage `json:"interval"`
+}
+
+// ChatGPTTokenFile is the content of the token file.
+type ChatGPTTokenFile struct {
+       AccessToken  string `json:"access_token"`
+       RefreshToken string `json:"refresh_token"`
+       IDToken      string `json:"id_token"`
+       AccountID    string `json:"account_id"`
+}
+
+// ChatGPTTokenResponse is the answer of the token endpoint.
+type ChatGPTTokenResponse struct {
+       AccessToken  string `json:"access_token"`
+       RefreshToken string `json:"refresh_token"`
+       IDToken      string `json:"id_token"`
+}
+
+// ChatGPTDeviceGrant is the answer of the device token endpoint after the user
+// approves the code.
+type ChatGPTDeviceGrant struct {
+       AuthorizationCode string `json:"authorization_code"`
+       CodeVerifier      string `json:"code_verifier"`
+}
+
+// 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{},
+       }
+}
+
 // Authorize sets the access token and the account ID, after a refresh when the
 // token expires within chatgptRefreshMargin.
 func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error {
@@ -131,6 +141,7 @@ func (a *ChatGPTAuth) Login(ctx context.Context, out io.Writer) error {
        defer a.mutex.Unlock()
 
        var code ChatGPTDeviceCode
+
        err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
                "client_id": chatgptClientID,
        }, &code)
@@ -183,6 +194,7 @@ func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) {
        }
 
        var tokens ChatGPTTokenFile
+
        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")
@@ -199,6 +211,7 @@ func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) {
 // It keeps the old refresh token and ID token when the answer has none.
 func (a *ChatGPTAuth) refresh(ctx context.Context, tokens ChatGPTTokenFile) (ChatGPTTokenFile, error) {
        var response ChatGPTTokenResponse
+
        err := a.postJSON(ctx, "/oauth/token", map[string]string{
                "client_id":     chatgptClientID,
                "grant_type":    "refresh_token",
@@ -255,6 +268,7 @@ func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, inter
 
        for {
                var grant ChatGPTDeviceGrant
+
                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")
@@ -272,6 +286,7 @@ func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, inter
                select {
                case <-ctx.Done():
                        timer.Stop()
+
                        return ChatGPTDeviceGrant{}, fmt.Errorf("wait for approval: %w", ctx.Err())
                case <-timer.C:
                }
@@ -289,6 +304,7 @@ func (a *ChatGPTAuth) exchange(ctx context.Context, grant ChatGPTDeviceGrant) (C
        }
 
        var response ChatGPTTokenResponse
+
        _, err := a.post(ctx, "/oauth/token", "application/x-www-form-urlencoded", []byte(form.Encode()), &response)
        if err != nil {
                return ChatGPTTokenFile{}, fmt.Errorf("exchange code for tokens: %w", err)
@@ -337,6 +353,7 @@ func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body [
        }
 
        request.Header.Set("Content-Type", contentType)
+
        response, err := a.http.Do(request)
        if err != nil {
                return 0, fmt.Errorf("send request: %w", err)
@@ -358,35 +375,25 @@ func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body [
        return response.StatusCode, nil
 }
 
-// ChatGPTDeviceCode is the answer of the user code endpoint. The interval is a
-// number or a string of seconds.
-type ChatGPTDeviceCode struct {
-       DeviceAuthID string          `json:"device_auth_id"`
-       UserCode     string          `json:"user_code"`
-       UserCodeAlt  string          `json:"usercode"`
-       Interval     json.RawMessage `json:"interval"`
-}
+// chatgptLogin runs "onechat login": the device code login into the token file
+// of the data directory. It holds the lock of the token file, so it does not
+// run while the daemon runs.
+func chatgptLogin(ctx context.Context, out io.Writer) (err error) {
+       dir, err := dataDir()
+       if err != nil {
+               return err
+       }
 
-// ChatGPTTokenFile is the content of the token file.
-type ChatGPTTokenFile struct {
-       AccessToken  string `json:"access_token"`
-       RefreshToken string `json:"refresh_token"`
-       IDToken      string `json:"id_token"`
-       AccountID    string `json:"account_id"`
-}
+       tokens, err := lockTokens(dir)
+       if err != nil {
+               return err
+       }
 
-// ChatGPTTokenResponse is the answer of the token endpoint.
-type ChatGPTTokenResponse struct {
-       AccessToken  string `json:"access_token"`
-       RefreshToken string `json:"refresh_token"`
-       IDToken      string `json:"id_token"`
-}
+       defer func() {
+               err = errors.Join(err, tokens.Close())
+       }()
 
-// ChatGPTDeviceGrant is the answer of the device token endpoint after the user
-// approves the code.
-type ChatGPTDeviceGrant struct {
-       AuthorizationCode string `json:"authorization_code"`
-       CodeVerifier      string `json:"code_verifier"`
+       return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out)
 }
 
 // chatgptTokenExpiry returns the expiry of a JWT from its exp claim.
@@ -417,6 +424,7 @@ func chatgptPollInterval(raw json.RawMessage) time.Duration {
 // none.
 func chatgptAccountID(token string) string {
        var claims map[string]json.RawMessage
+
        if !chatgptDecodeClaims(token, &claims) {
                return ""
        }