93 lines
3.5 KiB
Go
93 lines
3.5 KiB
Go
package qoder
|
|
|
|
// PLUGIN-SERVED (see AGENTS.md): Qoder ("qoder") and Qoder CN ("qoder-cn")
|
|
// are deprecated built-in subscriptions served by their plugin,
|
|
// @magpie-community/opencode-qoder-auth, each once moved onto it
|
|
// (provider.Moved; the default for a new sign-in). A moved one's
|
|
// sign-ins, models, requests and usage are all the plugin's, never this
|
|
// code's (only the move, in migrate*.go, still reads its accounts). A fix here alone doesn't reach those users; fix the plugin
|
|
// (github.com/magpie-community/plugins, packages/qoder) and raise the
|
|
// movers' min in internal/provider/migrate_qoder.go.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// Credential is kept in the account's auth blob in magpie's logins.json.
|
|
type Credential struct {
|
|
UID string `json:"uid"`
|
|
Email string `json:"email,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Token string `json:"token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
DeviceToken string `json:"device_token"`
|
|
DeviceRefresh string `json:"device_refresh,omitempty"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
MachineID string `json:"machine_id"`
|
|
Models json.RawMessage `json:"models,omitempty"`
|
|
// Site is the Qoder site the account is on: CNProviderKey for Qoder CN,
|
|
// empty for the global site (every credential saved before there were two).
|
|
Site string `json:"site,omitempty"`
|
|
// DeviceChat says Token is the device token itself, as Qoder CN's CLI
|
|
// uses it, because the site refused to trade it for a job token; it is
|
|
// then refreshed as a device token.
|
|
DeviceChat bool `json:"device_chat,omitempty"`
|
|
}
|
|
|
|
// OnSite is the Qoder site the credential belongs to.
|
|
func (c *Credential) OnSite() *Site { return SiteOf(c.Site) }
|
|
|
|
const refreshLead = 5 * time.Minute
|
|
|
|
func (c *Credential) Valid() bool {
|
|
return c != nil && c.Token != "" && c.ExpiresAt-time.Now().UnixMilli() > int64(refreshLead/time.Millisecond)
|
|
}
|
|
|
|
// Refresh returns a new credential value. The caller serializes the entire
|
|
// check, refresh and save operation, since refresh tokens rotate on use.
|
|
func (c Credential) Refresh(ctx context.Context, client *http.Client) (Credential, error) {
|
|
if c.RefreshToken == "" {
|
|
return Credential{}, fmt.Errorf("qoder: the sign-in lapsed; sign in again")
|
|
}
|
|
if c.DeviceChat {
|
|
dt, err := RefreshDeviceToken(ctx, client, c.OnSite().OpenAPI+DeviceTokenRefreshPath, c.RefreshToken)
|
|
if err != nil {
|
|
// a refused refresh is as final as a refused job refresh
|
|
var refused *DeviceTokenRefreshHTTPError
|
|
if errors.As(err, &refused) {
|
|
return Credential{}, &JobTokenRefreshHTTPError{StatusCode: refused.StatusCode}
|
|
}
|
|
return Credential{}, err
|
|
}
|
|
c.Token, c.RefreshToken = dt.Token, dt.RefreshToken
|
|
c.DeviceToken, c.DeviceRefresh = dt.Token, dt.RefreshToken
|
|
c.ExpiresAt = DeviceExpiry(*dt).UnixMilli()
|
|
return c, nil
|
|
}
|
|
jt, err := RefreshJobToken(ctx, client, c.OnSite(), c.RefreshToken)
|
|
if err != nil {
|
|
return Credential{}, err
|
|
}
|
|
c.Token, c.RefreshToken = jt.Token, jt.RefreshToken
|
|
life := jt.Expiry()
|
|
if life <= 0 {
|
|
life = 24 * time.Hour
|
|
}
|
|
c.ExpiresAt = time.Now().Add(life).UnixMilli()
|
|
return c, nil
|
|
}
|
|
|
|
// DeviceExpiry is when a device token used for chat is taken to lapse: what
|
|
// Qoder said, else a day, as a job token without a lifetime is.
|
|
func DeviceExpiry(t DeviceToken) time.Time {
|
|
if at := t.Expiry(); !at.IsZero() {
|
|
return at
|
|
}
|
|
return time.Now().Add(24 * time.Hour)
|
|
}
|