Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-token-account-card-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Qoder token (PAT) accounts now refresh their status and credits like browser-login accounts. The in-process account had no online probe, so "Refresh credits", opening the Accounts page, and the background sweep never re-read the upstream quota and the card showed a number that never moved. It now probes the live session and pulls `/api/v2/quota/usage` synchronously on a manual refresh (surfacing the error instead of a stale number), and the cached snapshot keeps its real fetch time instead of restamping "now". Quota buckets now follow the same rules as the browser path (an unusable organization package no longer inflates the balance). Token accounts also record their credential type (`pat`) and gateway uid, so the UID and export entry appear as they do for a browser login.

### 中文

- Qoder token(PAT)账号现在会像浏览器登录账号一样刷新状态和积分。进程内账号原本没有任何在线探测,导致「刷新积分」、进入账号页以及后台巡检都不会重新拉取上游额度,卡片上的数字一直不变。现在会探测实时会话,并在手动刷新时同步拉取 `/api/v2/quota/usage`(失败时返回错误而不是保留旧数字),缓存快照也会保留真实的抓取时间而不再每次都写成「当前时间」。额度分桶的合并规则也与浏览器登录路径保持一致(不可用的组织资源包不再虚增余额)。token 账号还会记录凭据类型(`pat`)和网关 uid,因此 UID 与导出入口会和浏览器登录一致。
3 changes: 3 additions & 0 deletions internal/accounts/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ type AccountStore interface {
SaveCredential(ctx context.Context, accountID, authType string, credential NativeCredential) error
LoadCredential(ctx context.Context, accountID string) (NativeCredential, error)
SaveCredentialPayload(ctx context.Context, accountID, format string, payload []byte) error
// SetCredentialAuthType records the credential kind ("pat"/"oauth") on the
// account without rewriting the blob, so a PAT login shows the right type.
SetCredentialAuthType(ctx context.Context, accountID, authType string) error
LoadCredentialPayload(ctx context.Context, accountID string) (string, []byte, error)
Observe(ctx context.Context, id, remoteUID, status, lastError, lastKind string) error
SaveQuota(ctx context.Context, id string, quota *QuotaSnapshot) error
Expand Down
11 changes: 10 additions & 1 deletion internal/control/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,11 @@ func (a *Accounts) Export(ctx context.Context, id string) (AccountExport, error)
if err != nil {
return AccountExport{}, err
}
if account.Provider != "" && account.Provider != "qoder" {
// PAT-backed Qoder accounts (qoder-pat-v1) store a portable credential
// payload, not the legacy CLI blob; export them by payload so the bundle can
// be re-imported. Other providers and legacy Qoder (qoder-native-v1) keep
// their existing branches.
if account.Provider != "qoder" {
format, payload, err := a.LoadCredentialPayload(ctx, id)
if err != nil {
return AccountExport{}, err
Expand All @@ -227,6 +231,11 @@ func (a *Accounts) Export(ctx context.Context, id string) (AccountExport, error)
Format: format, Name: account.Name, Provider: account.Provider, Region: account.ProviderRegion, Credential: payload,
}, nil
}
if format, payload, err := a.LoadCredentialPayload(ctx, id); err == nil && format == "qoder-pat-v1" && len(payload) > 0 {
return AccountExport{
Format: format, Name: account.Name, Provider: account.Provider, Region: account.ProviderRegion, Credential: payload,
}, nil
}
credential, err := a.LoadNativeCredential(ctx, id)
if err != nil {
return AccountExport{}, err
Expand Down
48 changes: 48 additions & 0 deletions internal/control/duty_services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,54 @@ func TestAccountsAdminLoginPATDoesNotSwallowRealErrors(t *testing.T) {
}
}

// A successful PAT login must record the credential type so the card shows
// "pat" (which also enables export and the type filter), not the create-time
// default "none".
func TestAccountsLoginPATRecordsAuthType(t *testing.T) {
svc, runtime, store, _ := newTestServices()
store.accounts["acc-1"] = accounts.Account{ID: "acc-1", Provider: "qoder"}
svc.Accounts.Providers = providers.NewRegistry()
svc.Accounts.Providers.Register(providers.Adapter{ID: "qoder", Credential: stubImporter{
format: "qoder-pat-v1", payload: []byte("a-valid-pat-value-123456"), ready: true,
}})
result, err := svc.Accounts.Admin(context.Background(), AccountAdminAction{
AccountID: "acc-1", Action: "login/pat", Method: "POST",
Body: []byte(`{"pat":"a-valid-pat-value-123456"}`),
})
if err != nil {
t.Fatalf("login/pat err = %v", err)
}
if result.Kind != "login_complete" {
t.Fatalf("kind = %q", result.Kind)
}
if got := store.accounts["acc-1"].AuthType; got != "pat" {
t.Fatalf("auth_type = %q, want pat", got)
}
if store.payloadFormats["acc-1"] != "qoder-pat-v1" {
t.Fatalf("payload format = %q", store.payloadFormats["acc-1"])
}
if len(runtime.started) != 1 || runtime.started[0] != "acc-1" {
t.Fatalf("account not started after PAT login: %+v", runtime.started)
}
}

// Exporting a PAT-backed Qoder account must return the portable credential
// payload, not fail trying to read the (absent) legacy CLI blob.
func TestAccountsExportQoderPATUsesPayload(t *testing.T) {
svc, _, store, _ := newTestServices()
store.accounts["acc-1"] = accounts.Account{ID: "acc-1", Provider: "qoder", AuthType: "pat"}
store.payloads["acc-1"] = []byte("a-valid-pat-value-123456")
store.payloadFormats["acc-1"] = "qoder-pat-v1"

exported, err := svc.Accounts.Export(context.Background(), "acc-1")
if err != nil {
t.Fatalf("export err = %v", err)
}
if exported.Format != "qoder-pat-v1" || string(exported.Credential) != "a-valid-pat-value-123456" {
t.Fatalf("export = %+v", exported)
}
}

// When the provider has no PAT importer, login/pat must fall back to the legacy
// worker path (old qoder-native blob accounts).
func TestAccountsAdminLoginPATFallsBackOnlyOnProviderUnsupported(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions internal/control/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ func (a *Accounts) LoginPAT(ctx context.Context, id, token string) error {
if err := a.store().SaveCredentialPayload(ctx, id, importer.Format(), prepared.Payload); err != nil {
return operationError("credential_save_failed", err.Error())
}
// Record the credential kind so the card shows the right type (and enables
// export/filtering) instead of the create-time default "none".
if err := a.store().SetCredentialAuthType(ctx, id, "pat"); err != nil {
return operationError("credential_save_failed", err.Error())
}
enabled := true
if err := a.store().Update(ctx, id, accounts.UpdateAccount{Enabled: &enabled}); err != nil {
return err
Expand Down
8 changes: 8 additions & 0 deletions internal/control/s06_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ func (s *fakeStore) SaveCredentialPayload(_ context.Context, accountID, format s
s.payloadFormats[accountID] = format
return nil
}
func (s *fakeStore) SetCredentialAuthType(_ context.Context, accountID, authType string) error {
s.log.add("store.SetCredentialAuthType")
if account, ok := s.accounts[accountID]; ok {
account.AuthType = authType
s.accounts[accountID] = account
}
return nil
}
func (s *fakeStore) LoadCredentialPayload(_ context.Context, accountID string) (string, []byte, error) {
s.log.add("store.LoadCredentialPayload")
return s.payloadFormats[accountID], s.payloads[accountID], nil
Expand Down
15 changes: 15 additions & 0 deletions internal/providers/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,21 @@ type QuotaInfo struct {
ExpiresAt int64
ExpiringRemain float64
Packages []QuotaPackage
// AddOn / ResourcePackage carry the Qoder credit-bucket breakdown so the
// persisted snapshot keeps the same detail the browser/worker path shows.
// They are only populated by providers that report these buckets (Qoder).
HasAddOn bool
AddOnUsed float64
AddOnTotal float64
AddOnRemaining float64
AddOnUnit string
AddOnAvailable *bool
HasResourcePackage bool
ResourcePackageUsed float64
ResourcePackageTotal float64
ResourcePackageRemaining float64
ResourcePackageUnit string
ResourcePackageAvailable *bool
}

// AccountProber refreshes provider-native readiness and optional display quota.
Expand Down
97 changes: 73 additions & 24 deletions internal/providers/qoder/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import (

"github.com/caigee-cmd/cli2api/internal/accounts"
"github.com/caigee-cmd/cli2api/internal/providers"
"github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth"
"github.com/caigee-cmd/cli2api/internal/translate"
)

// Client wraps S08 worker transport behind providers.Adapter slots.
// It still talks to a child-process worker URL; it is not an in-process
// cloud client. Callers must keep using the child-process path for
// probe/quota/login/chat until those slices are accepted.
// Client wraps S08 worker transport behind providers.Adapter slots. PAT-backed
// accounts run fully in-process through the qauth Bridge (chat, login, catalog,
// quota, check-in); the child-process worker remains only as a legacy fallback
// for accounts that still carry the old qoder-native-v1 CLI blob.
type Client struct {
mu sync.RWMutex

Expand Down Expand Up @@ -153,16 +154,21 @@ func (c *Client) SetLoginWait(timeout, interval time.Duration) {
}

func (c *Client) Adapter() providers.Adapter {
// Probe/Quota stay off the registered bundle in S09: empty-URL Qoder
// items must not enter refreshInProcess just because an Adapter exists.
// Callers that need those methods use the Client directly.
return providers.Adapter{
ID: "qoder",
Credential: patCodec{},
Login: c,
Chat: c,
Models: c,
Checkin: c,
// Prober covers PAT-backed in-process accounts (no worker URL): Probe
// bootstraps/renews the Bridge session and Quota pulls the OpenAPI
// usage, so the console's status and "refresh credits" match the
// browser/worker path instead of showing a value that never moves.
// For legacy worker accounts Probe/Quota transparently fall back to the
// per-account worker URL, and refreshOne keeps using the worker /health
// branch because those items carry a non-empty URL.
Prober: c,
}
}

Expand Down Expand Up @@ -472,9 +478,23 @@ func FreeCatalogIDsFromInfos(models []providers.ModelInfo) []string {
}

func (c *Client) Probe(ctx context.Context, accountID string) (providers.AccountHealth, error) {
// PAT-backed accounts run fully in-process and have no worker URL: probe the
// Bridge session directly so the console reflects real readiness/identity.
// Legacy accounts (no PAT payload) fall through to the worker /health probe.
if b, err := c.bridge(ctx, accountID); err == nil {
if err := b.EnsureFreshSession(ctx); err != nil {
return providers.AccountHealth{}, err
}
return providers.AccountHealth{Ready: true, Hot: true, UID: b.UID()}, nil
} else if !errors.Is(err, ErrPATRequired) {
return providers.AccountHealth{}, err
}
workerURL, err := c.lookup(accountID)
if err != nil {
return providers.AccountHealth{}, err
// Legacy account with no PAT and no live worker (e.g. mid-start): report
// not-ready without an error so it shows like a starting worker instead
// of a hard failure.
return providers.AccountHealth{}, nil
}
c.mu.RLock()
httpClient := c.healthHTTP
Expand All @@ -493,19 +513,44 @@ func (c *Client) Probe(ctx context.Context, accountID string) (providers.Account
}, nil
}

// IsAuthError reports whether an error is a Qoder gateway authentication
// rejection (HTTP 401/403). The runtime uses it to show "login required"
// instead of a generic error when a PAT session can no longer authenticate.
func IsAuthError(err error) bool {
var authErr *qauth.AuthError
return errors.As(err, &authErr)
}

func (c *Client) Quota(ctx context.Context, accountID string) (*providers.QuotaInfo, error) {
snapshot, err := c.QuotaSnapshot(ctx, accountID, false)
// The AccountProber surface refreshes on every call so background and
// manual probes both hit the gateway; a cached snapshot would leave the
// card showing a number that never moves.
snapshot, err := c.QuotaSnapshot(ctx, accountID, true)
if err != nil || snapshot == nil {
return nil, err
}
return &providers.QuotaInfo{
Used: snapshot.Used,
Total: snapshot.Total,
Remaining: snapshot.Remaining,
Percentage: snapshot.Percentage,
Unit: snapshot.Unit,
Exceeded: snapshot.Exceeded,
FetchedAt: snapshot.FetchedAt,
Used: snapshot.Used,
Total: snapshot.Total,
Remaining: snapshot.Remaining,
Percentage: snapshot.Percentage,
Unit: snapshot.Unit,
Exceeded: snapshot.Exceeded,
FetchedAt: snapshot.FetchedAt,
ProviderID: "qoder",
Plan: snapshot.Plan,
HasAddOn: snapshot.HasAddOn,
AddOnUsed: snapshot.AddOnUsed,
AddOnTotal: snapshot.AddOnTotal,
AddOnRemaining: snapshot.AddOnRemaining,
AddOnUnit: snapshot.AddOnUnit,
AddOnAvailable: snapshot.AddOnAvailable,
HasResourcePackage: snapshot.HasResourcePackage,
ResourcePackageUsed: snapshot.ResourcePackageUsed,
ResourcePackageTotal: snapshot.ResourcePackageTotal,
ResourcePackageRemaining: snapshot.ResourcePackageRemaining,
ResourcePackageUnit: snapshot.ResourcePackageUnit,
ResourcePackageAvailable: snapshot.ResourcePackageAvailable,
}, nil
}

Expand All @@ -514,16 +559,20 @@ func (c *Client) QuotaSnapshot(ctx context.Context, accountID string, force bool
// /api/v2/quota/usage endpoint (CN uses the COSY securityOauthToken
// directly; global uses the separately-issued OpenAPI bearer).
if b, err := c.bridge(ctx, accountID); err == nil {
// force bypasses the cached /user/status TTL so the operator's
// "refresh quota" action always hits the gateway.
// force re-pulls /user/status + quota/usage so the operator's "refresh
// credits" action always hits the gateway. A failure surfaces as an
// error (the console then shows it) instead of silently returning a
// stale cached number stamped "now".
if force {
_ = b.EnsureAccountStatus(ctx)
}
if snap := b.QuotaSnapshot(); snap != nil {
return snap, nil
if _, rerr := b.RefreshAccountStatus(ctx); rerr != nil {
return nil, rerr
}
} else if b.AccountStatus() == nil {
// Cold bridge with no cached snapshot: the first read must fetch.
if _, rerr := b.RefreshAccountStatus(ctx); rerr != nil {
return nil, rerr
}
}
// No cached snapshot yet (cold bridge): force a fresh fetch.
b.EnsureAccountStatus(ctx)
if snap := b.QuotaSnapshot(); snap != nil {
return snap, nil
}
Expand Down
58 changes: 55 additions & 3 deletions internal/providers/qoder/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ import (
"time"

"github.com/caigee-cmd/cli2api/internal/providers"
"github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth"
"github.com/caigee-cmd/cli2api/internal/translate"
)

func TestAdapterDoesNotRegisterProber(t *testing.T) {
func TestAdapterRegistersProberForInProcessQuota(t *testing.T) {
adapter := NewClient().Adapter()
if adapter.ID != "qoder" {
t.Fatalf("id = %q", adapter.ID)
}
if adapter.Prober != nil {
t.Fatal("S09 Adapter must omit Prober so empty-URL Qoder stays off refreshInProcess")
// PAT-backed accounts have no worker URL; the registered Prober is what lets
// the runtime refresh their readiness and quota (status/credits that move).
if adapter.Prober == nil {
t.Fatal("Qoder Adapter must register a Prober so in-process PAT accounts refresh status/quota")
}
if adapter.Models == nil || adapter.Login == nil || adapter.Chat == nil {
t.Fatal("expected Models, Login, and Chat wrappers")
Expand Down Expand Up @@ -123,6 +126,55 @@ func TestAdapterQuotaSnapshotPreservesAddOn(t *testing.T) {
}
}

// The PAT adapter Prober must report the bridge session as ready with the
// gateway uid, so the runtime can mark the in-process account "ready" and record
// remote_uid — matching what the worker /health probe yields for browser login.
func TestAdapterProbeReportsInProcessSession(t *testing.T) {
client := NewClient()
client.SetPATStore(patFixtureStore{format: PATCredentialFormat, payload: []byte("a-valid-pat-value-123456")}, func(context.Context, string) (string, error) {
return "cn", nil
})
// Inject an already-bootstrapped bridge so no network call is needed.
b := NewBridge("a-valid-pat-value-123456", qauth.CN, "mid", "mtok", "mac")
identity := qauth.AuthIdentity{Aid: "qoder-aid-9", UID: "qoder-aid-9", SecurityOauthToken: "sot"}
b.identity = &identity
b.sess = qauth.NewSession(identity, "mid", "mtok", "mac")
b.securityOath = "sot"
b.bootstrapped.Store(true)
b.expireTimeMs = time.Now().Add(24 * time.Hour).UnixMilli()
b.refreshedMs = time.Now().UnixMilli()
client.bridgesMu.Lock()
client.bridges = map[string]*cachedBridge{"acc-1": {bridge: b, patSig: patSignature("a-valid-pat-value-123456", "cn")}}
client.bridgesMu.Unlock()

health, err := client.Probe(context.Background(), "acc-1")
if err != nil {
t.Fatalf("Probe err = %v", err)
}
if !health.Ready || !health.Hot || health.UID != "qoder-aid-9" {
t.Fatalf("health = %+v", health)
}
}

// A forced quota refresh whose OpenAPI call fails must return an error (the
// console reports it) rather than a stale cached snapshot stamped fresh.
func TestAdapterQuotaForceSurfacesRefreshError(t *testing.T) {
client := NewClient()
client.SetPATStore(patFixtureStore{format: PATCredentialFormat, payload: []byte("a-valid-pat-value-123456")}, func(context.Context, string) (string, error) {
return "cn", nil
})
b := NewBridge("a-valid-pat-value-123456", qauth.CN, "mid", "mtok", "mac")
// No session bootstrapped and no reachable gateway: EnsureFreshSession fails.
b.bootstrapped.Store(false)
client.bridgesMu.Lock()
client.bridges = map[string]*cachedBridge{"acc-1": {bridge: b, patSig: patSignature("a-valid-pat-value-123456", "cn")}}
client.bridgesMu.Unlock()

if _, err := client.QuotaSnapshot(context.Background(), "acc-1", true); err == nil {
t.Fatal("forced quota refresh with an unreachable gateway must return an error")
}
}

func TestAdapterStartLoginWaitsForAuthManager(t *testing.T) {
var healthHits atomic.Int32
var deviceHits atomic.Int32
Expand Down
Loading
Loading