]> git.giacomolli.uk - onechat/blobdiff - chatgpt.go
Add local context to memory operation failures
[onechat] / chatgpt.go
index 7a5afc20865b4b547af24fa73449d0ed506e816b..77481905822674f27c9bc4bd888933d046514c51 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 {
@@ -123,54 +133,6 @@ func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error {
        return nil
 }
 
-// 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 ChatGPTDeviceCode
-       err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
-               "client_id": chatgptClientID,
-       }, &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 fmt.Errorf("write device code: %w", err)
-       }
-
-       grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(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.")
-       if err != nil {
-               return fmt.Errorf("write login result: %w", err)
-       }
-
-       return nil
-}
-
 // read reads the token file.
 func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) {
        data, err := os.ReadFile(a.path)
@@ -183,6 +145,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 +162,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",
@@ -239,6 +203,102 @@ func (a *ChatGPTAuth) postJSON(ctx context.Context, path string, body, result an
        return err
 }
 
+// write replaces the token file atomically. The file has mode 0600.
+func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) 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
+}
+
+// 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
+}
+
+// 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 ChatGPTDeviceCode
+
+       err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
+               "client_id": chatgptClientID,
+       }, &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 fmt.Errorf("write device code: %w", err)
+       }
+
+       grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(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.")
+       if err != nil {
+               return fmt.Errorf("write login result: %w", err)
+       }
+
+       return 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) (ChatGPTDeviceGrant, error) {
@@ -255,6 +315,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 +333,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 +351,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)
@@ -312,83 +375,6 @@ func (a *ChatGPTAuth) exchange(ctx context.Context, grant ChatGPTDeviceGrant) (C
        return tokens, nil
 }
 
-// write replaces the token file atomically. The file has mode 0600.
-func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) 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
-}
-
-// 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
-}
-
-// 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"`
-}
-
 // chatgptTokenExpiry returns the expiry of a JWT from its exp claim.
 func chatgptTokenExpiry(token string) (time.Time, bool) {
        var claims struct {
@@ -402,21 +388,11 @@ func chatgptTokenExpiry(token string) (time.Time, bool) {
        return time.Unix(int64(*claims.Exp), 0), true
 }
 
-// chatgptPollInterval returns the poll interval of a device code, given in
-// seconds as a number or a string.
-func chatgptPollInterval(raw json.RawMessage) time.Duration {
-       seconds, err := strconv.ParseFloat(strings.Trim(string(raw), `"`), 64)
-       if err != nil || seconds < 0 {
-               return chatgptDefaultPollInterval
-       }
-
-       return time.Duration(seconds * float64(time.Second))
-}
-
 // chatgptAccountID returns the ChatGPT account ID of a JWT, or "" when it has
 // none.
 func chatgptAccountID(token string) string {
        var claims map[string]json.RawMessage
+
        if !chatgptDecodeClaims(token, &claims) {
                return ""
        }
@@ -433,6 +409,17 @@ func chatgptAccountID(token string) string {
        return auth.AccountID
 }
 
+// chatgptPollInterval returns the poll interval of a device code, given in
+// seconds as a number or a string.
+func chatgptPollInterval(raw json.RawMessage) time.Duration {
+       seconds, err := strconv.ParseFloat(strings.Trim(string(raw), `"`), 64)
+       if err != nil || seconds < 0 {
+               return chatgptDefaultPollInterval
+       }
+
+       return time.Duration(seconds * float64(time.Second))
+}
+
 // chatgptDecodeClaims 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.
@@ -449,3 +436,24 @@ func chatgptDecodeClaims(token string, claims any) bool {
 
        return json.Unmarshal(payload, claims) == nil
 }
+
+// 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)
+}