- Chat reasoning_effort "none" (and Responses reasoning.effort "none") was read as low by effortOf before qoderEffort saw it, so a model that can stop thinking (qfmodel) was sent enable_thinking true at low, and an Anthropic request with thinking disabled got the model's default (max on an always-thinking one). The request now carries ThinkOff beside Effort — set for effort "none" and for Anthropic thinking {"type":"disabled"} — and Qoder reads it as none: off where the model can stop, its lowest effort where it can't. effortOf and Effort are unchanged, so every other vendor gets what it did.
- The device token serves only Qoder's account pages (usage); chat runs on the job token. A refused device-token refresh marked the whole account "sign-in has expired" though chat still worked. Now only a refused job-token refresh lapses the account; a refused device one leaves usage unavailable with that reason ("Qoder refused the account-page sign-in (chat still works) — sign in again to see usage").
- askQoder answered 401 for any credential error, so a refresh that timed out made Claude Code ask to log in again. Errors that mean the sign-in is gone (not signed in, no such account, unreadable, no refresh token, a refused job refresh) now wrap provider.ErrQoderSignIn and are 401; anything else (a timeout, the network) is 502.
- Images a tool returned were dropped. Qoder's tool turns hold text, but its user turns take image_url blocks, so the images now follow the tool turns in a user turn — "[From the result of <tool> (tool call <id>):]" then the images — joined to the start of the user's own next turn, and the tool turn notes that they follow, the way buildChat does for Chat upstreams.
Tested: TestQoderThinkingOff (Chat, Responses, Anthropic off on qfmodel and on an always-thinking model; other vendors' Effort still low; thinking unmentioned keeps the default), TestQoderCredentialStatus (timeout and network 502, a gone sign-in 401), TestQoderToolResultImages, TestQoderRefusedRefreshLapses (device refused: not lapsed, usage reason; job refused: lapsed, ErrQoderSignIn) and TestQoderRefreshNetworkErrorNotSignIn — each of the first four fails on main. go build (darwin, GOOS=windows) and go test -tags nogui ./... pass
469 lines
13 KiB
Go
469 lines
13 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/catalog"
|
|
"github.com/yetone/magpie/internal/qoder"
|
|
)
|
|
|
|
// qoderMu serializes checking, rotating and saving tokens, including sign-in
|
|
// and removal. Credentials are decoded into private values on every read.
|
|
var qoderMu sync.Mutex
|
|
var qoderClient = &http.Client{Timeout: 20 * time.Second}
|
|
var qoderAPI = qoder.APIHost
|
|
|
|
// A rotated pair whose disk write failed must never spend its predecessor
|
|
// again. Retry persisting this private blob on the next access.
|
|
var qoderPending = map[string]struct{ before, after json.RawMessage }{}
|
|
|
|
func qoderPendingKey(user string) string { return loginsPath() + ":" + strings.ToLower(user) }
|
|
|
|
func qoderCurrent(l savedLogin) (qoder.Credential, bool, bool) {
|
|
key := qoderPendingKey(l.User)
|
|
if p, ok := qoderPending[key]; ok {
|
|
if string(p.before) == string(l.Auth) {
|
|
c, valid := qoderSaved(savedLogin{Auth: p.after})
|
|
return c, valid, true
|
|
}
|
|
delete(qoderPending, key)
|
|
}
|
|
c, ok := qoderSaved(l)
|
|
return c, ok, false
|
|
}
|
|
|
|
// qoderRefreshTimeout bounds a token refresh, which runs apart from the
|
|
// request that needed it: once Qoder has rotated the pair, the reply must be
|
|
// kept even if that request is gone.
|
|
const qoderRefreshTimeout = 20 * time.Second
|
|
|
|
// qoderLookup reads one saved account. loginsMu is held only for the read, so
|
|
// a Qoder refresh never stalls the other subscriptions; qoderMu, held by the
|
|
// caller, keeps every Qoder auth write out while it is refreshed.
|
|
func qoderLookup(user string) (savedLogin, bool) {
|
|
loginsMu.Lock()
|
|
defer loginsMu.Unlock()
|
|
for _, l := range readLogins() {
|
|
if l.Agent == "qoder" && strings.EqualFold(l.User, user) {
|
|
return l, true
|
|
}
|
|
}
|
|
return savedLogin{}, false
|
|
}
|
|
|
|
// qoderPersist writes c back into the account l as logins.json is now. The
|
|
// caller holds qoderMu, so l.Auth is still what is saved; the rest of the
|
|
// file is re-read under loginsMu so changes made meanwhile are kept.
|
|
func qoderPersist(l savedLogin, c qoder.Credential, renewed bool) error {
|
|
auth, err := json.Marshal(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := qoderPendingKey(l.User)
|
|
loginsMu.Lock()
|
|
defer loginsMu.Unlock()
|
|
ls := readLogins()
|
|
err = fmt.Errorf("Qoder: couldn't read the saved sign-in of %s back", l.User)
|
|
for i := range ls {
|
|
if ls[i].Agent != "qoder" || !strings.EqualFold(ls[i].User, l.User) {
|
|
continue
|
|
}
|
|
ls[i].Auth = auth
|
|
if renewed {
|
|
ls[i].Renewed, ls[i].Lapsed = time.Now().UTC(), ""
|
|
}
|
|
err = writeLogins(ls)
|
|
break
|
|
}
|
|
if err != nil {
|
|
qoderPending[key] = struct{ before, after json.RawMessage }{l.Auth, auth}
|
|
return err
|
|
}
|
|
delete(qoderPending, key)
|
|
return nil
|
|
}
|
|
|
|
// ErrQoderSignIn is under every error that says the Qoder sign-in itself is
|
|
// gone — not signed in, unreadable, or its refresh refused — as against a
|
|
// refresh that timed out or never reached Qoder, which may pass.
|
|
var ErrQoderSignIn = errors.New("Qoder sign-in")
|
|
|
|
// qoderRefreshFailed marks the account lapsed when Qoder refused its job
|
|
// refresh token: that sign-in is gone and has to be made again. A refresh
|
|
// that never got an answer marks nothing.
|
|
func qoderRefreshFailed(user string, err error) error {
|
|
var job *qoder.JobTokenRefreshHTTPError
|
|
if !errors.As(err, &job) || job.StatusCode != http.StatusUnauthorized && job.StatusCode != http.StatusForbidden {
|
|
return err
|
|
}
|
|
msg := user + "'s Qoder sign-in has expired — sign in again"
|
|
loginsMu.Lock()
|
|
defer loginsMu.Unlock()
|
|
ls := readLogins()
|
|
for i := range ls {
|
|
if ls[i].Agent == "qoder" && strings.EqualFold(ls[i].User, user) {
|
|
ls[i].Lapsed = msg
|
|
}
|
|
}
|
|
_ = writeLogins(ls)
|
|
return fmt.Errorf("%s (%w)", msg, errors.Join(ErrQoderSignIn, err))
|
|
}
|
|
|
|
func qoderWho(c qoder.Credential) string { return firstNonEmpty(c.Email, c.UID) }
|
|
|
|
func qoderSaved(l savedLogin) (qoder.Credential, bool) {
|
|
var c qoder.Credential
|
|
err := json.Unmarshal(l.Auth, &c)
|
|
return c, err == nil && c.UID != "" && c.Token != ""
|
|
}
|
|
|
|
// migrateQoder moves the old single-account file exactly once. A newer
|
|
// logins.json entry takes precedence; failed writes leave the old file intact.
|
|
// With nothing to move it takes no lock, so listing accounts doesn't wait
|
|
// behind a refresh.
|
|
func migrateQoder() error {
|
|
path := filepath.Join(filepath.Dir(Path()), "qoder.json")
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
qoderMu.Lock()
|
|
defer qoderMu.Unlock()
|
|
b, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var c qoder.Credential
|
|
if err := json.Unmarshal(b, &c); err != nil {
|
|
return err
|
|
}
|
|
if c.UID == "" || c.Token == "" {
|
|
return fmt.Errorf("Qoder: unreadable legacy sign-in")
|
|
}
|
|
if c.MachineID == "" {
|
|
c.MachineID = qoder.NewMachineID()
|
|
}
|
|
loginsMu.Lock()
|
|
defer loginsMu.Unlock()
|
|
ls := readLogins()
|
|
found := false
|
|
for _, l := range ls {
|
|
if l.Agent == "qoder" {
|
|
old, ok := qoderSaved(l)
|
|
if ok && old.UID == c.UID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
auth, err := json.Marshal(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ls = append(ls, savedLogin{Agent: "qoder", User: qoderWho(c), Auth: auth, On: true, Seen: time.Now().UTC()})
|
|
if err := writeLogins(ls); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return os.Remove(path)
|
|
}
|
|
|
|
func qoderLogins() []sideLogin {
|
|
if migrateQoder() != nil {
|
|
return nil
|
|
}
|
|
ls := sideLogins("qoder", "", func(l savedLogin) bool { _, ok := qoderSaved(l); return ok })
|
|
for i := range ls {
|
|
ls[i].Lapsed = ls[i].saved.Lapsed // a refused refresh shows on the account
|
|
}
|
|
return ls
|
|
}
|
|
|
|
func QoderSignedIn() bool { return len(qoderLogins()) > 0 }
|
|
|
|
// QoderCredential returns an independent snapshot for the selected account.
|
|
func QoderCredential(ctx context.Context, user string) (*qoder.Credential, error) {
|
|
if err := migrateQoder(); err != nil {
|
|
return nil, err
|
|
}
|
|
if user == "" {
|
|
ls := qoderLogins()
|
|
if len(ls) == 0 {
|
|
return nil, fmt.Errorf("Qoder: not signed in (%w)", ErrQoderSignIn)
|
|
}
|
|
user = ls[0].User
|
|
}
|
|
qoderMu.Lock()
|
|
defer qoderMu.Unlock()
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
l, found := qoderLookup(user)
|
|
if !found {
|
|
return nil, fmt.Errorf("no Qoder account %q (%w)", user, ErrQoderSignIn)
|
|
}
|
|
c, ok, changed := qoderCurrent(l)
|
|
if !ok {
|
|
return nil, fmt.Errorf("Qoder: unreadable sign-in (%w)", ErrQoderSignIn)
|
|
}
|
|
if c.MachineID == "" {
|
|
c.MachineID = qoder.NewMachineID()
|
|
changed = true
|
|
}
|
|
renewed := false
|
|
if !c.Valid() {
|
|
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), qoderRefreshTimeout)
|
|
fresh, err := c.Refresh(rctx, qoderClient)
|
|
cancel()
|
|
if err != nil {
|
|
if c.RefreshToken == "" { // nothing to refresh with: sign in again
|
|
err = errors.Join(ErrQoderSignIn, err)
|
|
}
|
|
return nil, qoderRefreshFailed(l.User, err)
|
|
}
|
|
c, changed, renewed = fresh, true, true
|
|
}
|
|
if changed {
|
|
if err := qoderPersist(l, c, renewed); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func qoderUser(c *qoder.Credential) *qoder.User {
|
|
return &qoder.User{UID: c.UID, Token: c.Token, Name: c.Name, Email: c.Email, MachineID: c.MachineID}
|
|
}
|
|
|
|
func qoderSave(c qoder.Credential) error {
|
|
if c.UID == "" || c.Token == "" {
|
|
return fmt.Errorf("Qoder: incomplete sign-in")
|
|
}
|
|
if err := migrateQoder(); err != nil {
|
|
return err
|
|
}
|
|
qoderMu.Lock()
|
|
defer qoderMu.Unlock()
|
|
if c.MachineID == "" {
|
|
c.MachineID = qoder.NewMachineID()
|
|
}
|
|
auth, err := json.Marshal(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
loginsMu.Lock()
|
|
defer loginsMu.Unlock()
|
|
ls := readLogins()
|
|
for i := range ls {
|
|
if ls[i].Agent != "qoder" {
|
|
continue
|
|
}
|
|
old, ok := qoderSaved(ls[i])
|
|
if ok && old.UID == c.UID {
|
|
oldKey := qoderPendingKey(ls[i].User)
|
|
ls[i].Auth, ls[i].User, ls[i].Seen, ls[i].Lapsed = auth, qoderWho(c), time.Now().UTC(), ""
|
|
if err := writeLogins(ls); err != nil {
|
|
return err
|
|
}
|
|
delete(qoderPending, oldKey)
|
|
return nil
|
|
}
|
|
}
|
|
return writeLogins(append(ls, savedLogin{Agent: "qoder", User: qoderWho(c), Auth: auth, On: true, Seen: time.Now().UTC()}))
|
|
}
|
|
|
|
func qoderFetchModels(ctx context.Context, user string) ([]catalog.Model, error) {
|
|
c, err := QoderCredential(ctx, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
raw, err := qoder.FetchModels(ctx, qoderClient, qoderAPI, qoderUser(c))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ms, err := qoder.ParseModels(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
qoderMu.Lock()
|
|
err = editSideLogin("qoder", qoderWho(*c), func(ls []savedLogin, i int) ([]savedLogin, error) {
|
|
latest, ok := qoderSaved(ls[i])
|
|
if !ok || latest.UID != c.UID {
|
|
return nil, fmt.Errorf("Qoder: account changed while fetching models")
|
|
}
|
|
latest.Models = raw
|
|
ls[i].Auth, _ = json.Marshal(latest)
|
|
return ls, nil
|
|
})
|
|
qoderMu.Unlock()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ms, catalog.SaveLive("qoder", "", ms)
|
|
}
|
|
|
|
// QoderModel accepts only an enabled model in this account's own model list.
|
|
func QoderModel(ctx context.Context, user, key string) (qoder.ModelInfo, error) {
|
|
c, err := QoderCredential(ctx, user)
|
|
if err != nil {
|
|
return qoder.ModelInfo{}, err
|
|
}
|
|
if len(c.Models) == 0 {
|
|
if _, err := qoderFetchModels(ctx, qoderWho(*c)); err != nil {
|
|
return qoder.ModelInfo{}, err
|
|
}
|
|
c, err = QoderCredential(ctx, qoderWho(*c))
|
|
if err != nil {
|
|
return qoder.ModelInfo{}, err
|
|
}
|
|
}
|
|
ms, err := qoder.ModelConfigs(c.Models)
|
|
if err != nil {
|
|
return qoder.ModelInfo{}, err
|
|
}
|
|
for _, m := range ms {
|
|
if m.Key == key {
|
|
return m, nil
|
|
}
|
|
}
|
|
return qoder.ModelInfo{}, fmt.Errorf("Qoder: unknown or disabled model %q", key)
|
|
}
|
|
|
|
func qoderProvider(l sideLogin) Provider {
|
|
user := l.User
|
|
a := &Account{Agent: "qoder", User: user, Plan: l.Plan, Stream: true}
|
|
a.models = func() []catalog.Model {
|
|
loginsMu.Lock()
|
|
defer loginsMu.Unlock()
|
|
for _, l := range readLogins() {
|
|
if l.Agent == "qoder" && strings.EqualFold(l.User, user) {
|
|
c, _ := qoderSaved(l)
|
|
ms, _ := qoder.ParseModels(c.Models)
|
|
return ms
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
a.fetch = func(ctx context.Context) ([]catalog.Model, error) { return qoderFetchModels(ctx, user) }
|
|
return Provider{ID: "qoder", Name: "Qoder", Icon: "qoder", Website: "https://qoder.com", Account: a}
|
|
}
|
|
|
|
func qoderAccount() (Provider, bool) {
|
|
ls := qoderLogins()
|
|
if len(ls) == 0 {
|
|
return Provider{}, false
|
|
}
|
|
return qoderProvider(ls[0]), true
|
|
}
|
|
|
|
func qoderAlsoOn() []Provider {
|
|
var out []Provider
|
|
for _, l := range qoderLogins() {
|
|
if !l.Active && l.On {
|
|
out = append(out, qoderProvider(l))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func forgetQoderLogin(user string) error {
|
|
if err := migrateQoder(); err != nil {
|
|
return err
|
|
}
|
|
qoderMu.Lock()
|
|
defer qoderMu.Unlock()
|
|
err := editSideLogin("qoder", user, func(ls []savedLogin, i int) ([]savedLogin, error) {
|
|
return append(ls[:i], ls[i+1:]...), nil
|
|
})
|
|
if err == nil {
|
|
delete(qoderPending, qoderPendingKey(user))
|
|
forgetAccountCaches()
|
|
}
|
|
return err
|
|
}
|
|
|
|
type qoderFlow struct {
|
|
verifier, nonce string
|
|
client *qoder.DeviceFlow
|
|
deadline time.Time
|
|
}
|
|
|
|
const qoderSignInTimeout = 15 * time.Minute
|
|
|
|
func QoderAuthURL() (url string, flow *qoderFlow, err error) {
|
|
f := qoder.NewDeviceFlow(qoderClient)
|
|
url, verifier, nonce, err := f.Authorization()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return url, &qoderFlow{verifier: verifier, nonce: nonce, client: f, deadline: time.Now().Add(qoderSignInTimeout)}, nil
|
|
}
|
|
|
|
func QoderCompleteSignIn(ctx context.Context, fl *qoderFlow) (user string, err error) {
|
|
ctx, cancel := context.WithDeadline(ctx, fl.deadline)
|
|
defer cancel()
|
|
dt, err := fl.client.PollDeviceToken(ctx, fl.nonce, fl.verifier, 2*time.Second)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
jt, err := fl.client.JobToken(ctx, dt.Token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
life := jt.Expiry()
|
|
if life <= 0 {
|
|
life = 24 * time.Hour
|
|
}
|
|
cred := qoder.Credential{UID: dt.UserID, Token: jt.Token, RefreshToken: jt.RefreshToken,
|
|
DeviceToken: dt.Token, DeviceRefresh: dt.RefreshToken, MachineID: fl.client.MachineID(),
|
|
ExpiresAt: time.Now().Add(life).UnixMilli()}
|
|
if ui, err := qoder.FetchUserInfo(ctx, qoderClient, dt.Token); err == nil && ui != nil {
|
|
cred.Email, cred.Name = ui.Email, ui.Name
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := qoderSave(cred); err != nil {
|
|
return "", err
|
|
}
|
|
_, _ = qoderFetchModels(ctx, qoderWho(cred))
|
|
forgetAccountCaches()
|
|
return qoderWho(cred), nil
|
|
}
|
|
|
|
func startQoderSignIn(s *signInFlow) error {
|
|
authURL, fl, err := QoderAuthURL()
|
|
if err != nil {
|
|
return fmt.Errorf("Qoder sign-in: %w", err)
|
|
}
|
|
ctx, cancel := context.WithDeadline(context.Background(), fl.deadline)
|
|
s.mu.Lock()
|
|
s.st.URL, s.stop = authURL, cancel
|
|
s.mu.Unlock()
|
|
go func() {
|
|
defer cancel()
|
|
user, err := QoderCompleteSignIn(ctx, fl)
|
|
if err != nil {
|
|
s.finish(SignInState{State: "failed", Error: "Qoder: " + err.Error()})
|
|
return
|
|
}
|
|
ls := qoderLogins()
|
|
s.finish(SignInState{State: "done", User: user, Using: strings.EqualFold(activeOf(ls), user)})
|
|
}()
|
|
return nil
|
|
}
|