Files
magpie/internal/provider/qoder_usage.go
T
yetone 3efba14ed5 qoder: thinking turned off reaches Qoder as off, a refused usage sign-in no longer lapses the account, only a gone sign-in is a 401, and a tool's images are sent (#162 review follow-ups)
- 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
2026-09-29 17:03:45 +08:00

151 lines
4.3 KiB
Go

package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/yetone/magpie/internal/qoder"
)
// Account endpoints use the device token, independently of the chat job token.
func qoderLoginQuota(ctx context.Context, l Login) SubscriptionQuota {
q := SubscriptionQuota{Provider: "qoder", User: l.User, Name: "Qoder", Icon: "qoder", Plan: l.Plan, Windows: []QuotaWindow{}}
c, err := QoderCredential(ctx, l.User)
if err != nil {
q.Error = err.Error()
return q
}
raw, err := qoder.FetchUsage(ctx, qoderClient, "", c.DeviceToken)
var status *qoder.UsageHTTPError
if errors.As(err, &status) && (status.StatusCode == 401 || status.StatusCode == 403) {
var token string
token, err = qoderRefreshDevice(ctx, l.User, c.DeviceToken)
if err == nil {
raw, err = qoder.FetchUsage(ctx, qoderClient, "", token)
}
}
if err != nil {
q.Error = err.Error()
return q
}
q, err = parseQoderQuota(raw, q)
if err != nil {
q.Error = err.Error()
}
return q
}
func qoderRefreshDevice(ctx context.Context, user, attempted string) (string, error) {
qoderMu.Lock()
defer qoderMu.Unlock()
l, found := qoderLookup(user)
if !found {
return "", fmt.Errorf("no Qoder account %q", user)
}
c, ok, pending := qoderCurrent(l)
if !ok {
return "", fmt.Errorf("Qoder: unreadable sign-in")
}
if c.DeviceToken != attempted {
if pending {
if err := qoderPersist(l, c, false); err != nil {
return "", err
}
}
return c.DeviceToken, nil
}
// the device refresh token rotates too: keep its reply past the caller
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), qoderRefreshTimeout)
dt, err := qoder.RefreshDeviceToken(rctx, qoderClient, "", c.DeviceRefresh)
cancel()
if err != nil {
// The device token serves only the account pages (usage); chat
// runs on the job token, so a refused one doesn't lapse the account.
var refused *qoder.DeviceTokenRefreshHTTPError
if errors.As(err, &refused) && (refused.StatusCode == http.StatusUnauthorized || refused.StatusCode == http.StatusForbidden) {
return "", fmt.Errorf("Qoder usage is unavailable: Qoder refused the account-page sign-in (chat still works) — sign in again to see usage (%w)", err)
}
return "", err
}
c.DeviceToken, c.DeviceRefresh = dt.Token, dt.RefreshToken
if err := qoderPersist(l, c, false); err != nil {
return "", err
}
return c.DeviceToken, nil
}
func parseQoderQuota(raw []byte, q SubscriptionQuota) (SubscriptionQuota, error) {
var env struct {
DisplayMode string `json:"displayMode"`
Usage map[string]json.RawMessage `json:"qoderUsage"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return q, err
}
if env.DisplayMode == "enterprise" {
q.Plan = "Enterprise"
return q, nil
}
if env.DisplayMode != "qoder" || env.Usage == nil {
return q, fmt.Errorf("Qoder: missing quota data")
}
field := func(a, b string) json.RawMessage {
if v := env.Usage[a]; len(v) > 0 {
return v
}
return env.Usage[b]
}
_ = json.Unmarshal(field("userType", "user_type"), &q.Plan)
var expiry any
if json.Unmarshal(field("expiresAt", "expires_at"), &expiry) == nil {
q.Until = cmdTime(expiry)
}
add := func(raw json.RawMessage, name string) {
var b struct {
Total *float64 `json:"total"`
Cap *float64 `json:"cap"`
Used *float64 `json:"used"`
Remaining *float64 `json:"remaining"`
Name string `json:"name"`
Unit string `json:"unit"`
}
if json.Unmarshal(raw, &b) != nil {
return
}
if b.Total == nil {
b.Total = b.Cap
}
if b.Total == nil || *b.Total <= 0 || b.Used == nil && b.Remaining == nil {
return
}
used := *b.Total
if b.Used != nil {
used = *b.Used
} else {
used -= *b.Remaining
}
if used < 0 {
return
}
if b.Name != "" {
name = b.Name
}
if b.Unit == "" {
b.Unit = "credits"
}
q.Windows = append(q.Windows, QuotaWindow{Name: name, Used: min(100, 100*used / *b.Total), Display: fmt.Sprintf("%g / %g %s", used, *b.Total, b.Unit)})
}
add(field("userQuota", "user_quota"), "Credits")
add(field("addOnQuota", "add_on_quota"), "Add-on credits")
add(field("orgResourcePackage", "org_resource_package"), "Shared credits")
var dedicated []json.RawMessage
_ = json.Unmarshal(field("dedicatedResourcePackages", "dedicated_resource_packages"), &dedicated)
for _, b := range dedicated {
add(b, "Dedicated credits")
}
return q, nil
}