diff --git a/changelog/unreleased/qoder-token-account-card-refresh.md b/changelog/unreleased/qoder-token-account-card-refresh.md new file mode 100644 index 00000000..a6b8f9d2 --- /dev/null +++ b/changelog/unreleased/qoder-token-account-card-refresh.md @@ -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 与导出入口会和浏览器登录一致。 diff --git a/internal/accounts/store.go b/internal/accounts/store.go index ab97c2d8..d954b86c 100644 --- a/internal/accounts/store.go +++ b/internal/accounts/store.go @@ -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 diff --git a/internal/control/accounts.go b/internal/control/accounts.go index c6a59086..1358f403 100644 --- a/internal/control/accounts.go +++ b/internal/control/accounts.go @@ -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 @@ -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 diff --git a/internal/control/duty_services_test.go b/internal/control/duty_services_test.go index 3362a903..9b1818a6 100644 --- a/internal/control/duty_services_test.go +++ b/internal/control/duty_services_test.go @@ -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) { diff --git a/internal/control/login.go b/internal/control/login.go index b3983b14..26a596c5 100644 --- a/internal/control/login.go +++ b/internal/control/login.go @@ -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 diff --git a/internal/control/s06_control_test.go b/internal/control/s06_control_test.go index d87b3c45..1a8c2088 100644 --- a/internal/control/s06_control_test.go +++ b/internal/control/s06_control_test.go @@ -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 diff --git a/internal/providers/interfaces.go b/internal/providers/interfaces.go index 269f81a3..c9a03046 100644 --- a/internal/providers/interfaces.go +++ b/internal/providers/interfaces.go @@ -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. diff --git a/internal/providers/qoder/adapter.go b/internal/providers/qoder/adapter.go index 49c7248b..47e16f67 100644 --- a/internal/providers/qoder/adapter.go +++ b/internal/providers/qoder/adapter.go @@ -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 @@ -153,9 +154,6 @@ 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{}, @@ -163,6 +161,14 @@ func (c *Client) Adapter() providers.Adapter { 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, } } @@ -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 @@ -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 } @@ -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 } diff --git a/internal/providers/qoder/adapter_test.go b/internal/providers/qoder/adapter_test.go index fe9f470a..054d1690 100644 --- a/internal/providers/qoder/adapter_test.go +++ b/internal/providers/qoder/adapter_test.go @@ -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") @@ -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 diff --git a/internal/providers/qoder/bridge.go b/internal/providers/qoder/bridge.go index 9ba51de8..64ce4c20 100644 --- a/internal/providers/qoder/bridge.go +++ b/internal/providers/qoder/bridge.go @@ -78,6 +78,11 @@ type Bridge struct { accountMu sync.Mutex account *qauth.AccountStatus accountTs int64 // unix seconds + // quotaFetchedAt is the wall-clock instant the cached quota buckets were + // last pulled from upstream. It only advances on a successful OpenAPI + // fetch, so a snapshot served from cache after a failed refresh keeps its + // true (older) timestamp instead of masquerading as freshly read. + quotaFetchedAt time.Time // openAPIMu guards the OpenAPI bearer cache (global region only). openAPIMu sync.Mutex @@ -232,9 +237,27 @@ func (b *Bridge) fetchAccountStatus(ctx context.Context, userID string) (*qauth. // Failures preserve the last known snapshot so a temporary account-service // outage never breaks the caller's accounting loop. func (b *Bridge) EnsureAccountStatus(ctx context.Context) *qauth.AccountStatus { + st, err := b.refreshAccountStatus(ctx) + if err != nil { + log.Printf("[qoder-bridge] WARN account status refresh failed: %v", err) + } + return st +} + +// RefreshAccountStatus is EnsureAccountStatus but returns the failure so the +// runtime probe can tell "quota genuinely refreshed" apart from "served a +// cached snapshot after an upstream error". On error it still returns the last +// known snapshot (identity may have advanced), matching auth.NilIsBestEffort. +func (b *Bridge) RefreshAccountStatus(ctx context.Context) (*qauth.AccountStatus, error) { + return b.refreshAccountStatus(ctx) +} + +// refreshAccountStatus pulls /user/status (identity, 1h TTL) and the +// authoritative OpenAPI quota/usage cycle. It advances quotaFetchedAt only when +// the quota fetch succeeds, so a cached snapshot keeps its true timestamp. +func (b *Bridge) refreshAccountStatus(ctx context.Context) (*qauth.AccountStatus, error) { if err := b.EnsureFreshSession(ctx); err != nil { - log.Printf("[qoder-bridge] WARN account status unavailable (session: %v)", err) - return b.AccountStatus() + return b.AccountStatus(), err } userID := "" if ident := b.currentIdentity(); ident != nil { @@ -249,12 +272,12 @@ func (b *Bridge) EnsureAccountStatus(ctx context.Context) *qauth.AccountStatus { bearer, err := b.quotaBearer(ctx) if err != nil { log.Printf("[qoder-bridge] WARN quota/usage token unavailable: %v", err) - return st + return st, err } quota, err := qauth.FetchQuotaUsage(ctx, bearer, b.Region) if err != nil { log.Printf("[qoder-bridge] WARN quota/usage refresh failed: %v", err) - return st + return st, err } if st == nil { st = &qauth.AccountStatus{} @@ -278,8 +301,9 @@ func (b *Bridge) EnsureAccountStatus(ctx context.Context) *qauth.AccountStatus { b.accountMu.Lock() b.account = cloneAccountStatus(st) + b.quotaFetchedAt = time.Now() b.accountMu.Unlock() - return cloneAccountStatus(st) + return cloneAccountStatus(st), nil } // AccountStatus returns the cached subscription state, or nil when it has @@ -644,6 +668,17 @@ func (b *Bridge) CurrentSecurityOauth() string { return b.currentSecurityOauth() // Region returns the bridge's region config. func (b *Bridge) RegionConfig() *qauth.RegionConfig { return b.Region } +// UID returns the gateway user id (aid) once a session has been bootstrapped, +// or "" before the first jobToken exchange. The runtime probe records it as the +// account's remote_uid so a PAT login shows the same identity a browser login +// syncs from the worker /health endpoint. +func (b *Bridge) UID() string { + if ident := b.currentIdentity(); ident != nil { + return ident.Aid + } + return "" +} + // QuotaSnapshot converts a cached AccountStatus into the cli2api store // shape. Returns nil when no quota has been resolved yet. // @@ -657,15 +692,28 @@ func (b *Bridge) QuotaSnapshot() *accounts.QuotaSnapshot { if st == nil || st.UserQuota == nil { return nil } - buckets := []*qauth.Quota{st.UserQuota, st.AddOnQuota} + // Mirror the worker-path quota.go aggregation: only buckets the upstream + // did not flag unavailable participate in the headline, so an org resource + // package the member may not spend never inflates the balance. The add-on + // Quota carries no availability flag (quota.go treats a nil flag as + // available), so it always participates; only the org package can be + // unavailable. + buckets := []*qauth.Quota{st.UserQuota} + if st.AddOnQuota != nil { + buckets = append(buckets, st.AddOnQuota) + } + resourceAvailable := false if st.OrgResourcePackage != nil { - buckets = append(buckets, &qauth.Quota{ - Total: st.OrgResourcePackage.Cap, - Used: st.OrgResourcePackage.Used, - Remaining: st.OrgResourcePackage.Remaining, - Percentage: st.OrgResourcePackage.Percentage, - Unit: st.OrgResourcePackage.Unit, - }) + resourceAvailable = st.OrgResourcePackage.Available + if resourceAvailable { + buckets = append(buckets, &qauth.Quota{ + Total: st.OrgResourcePackage.Cap, + Used: st.OrgResourcePackage.Used, + Remaining: st.OrgResourcePackage.Remaining, + Percentage: st.OrgResourcePackage.Percentage, + Unit: st.OrgResourcePackage.Unit, + }) + } } var used, total, remaining float64 unit := "" @@ -680,6 +728,7 @@ func (b *Bridge) QuotaSnapshot() *accounts.QuotaSnapshot { unit = q.Unit } } + fetchedAt := b.quotaFetchedAtString() snap := &accounts.QuotaSnapshot{ Used: used, Total: total, @@ -687,7 +736,7 @@ func (b *Bridge) QuotaSnapshot() *accounts.QuotaSnapshot { Percentage: 0, Unit: unit, Exceeded: st.IsQuotaExceeded, - FetchedAt: time.Now().UTC().Format(time.RFC3339), + FetchedAt: fetchedAt, Plan: st.Plan, } if total > 0 { @@ -697,8 +746,12 @@ func (b *Bridge) QuotaSnapshot() *accounts.QuotaSnapshot { snap.ExpiresAt = st.NextResetAtMs / 1000 } // Exceeded keeps its previous meaning: the account is out of credits unless - // an add-on or organization package still holds some. - if snap.Exceeded && remaining > 0 { + // a spendable add-on or organization package still holds some. + hasSpendable := st.AddOnQuota != nil && st.AddOnQuota.Remaining > 0 + if st.OrgResourcePackage != nil && resourceAvailable && st.OrgResourcePackage.Remaining > 0 { + hasSpendable = true + } + if snap.Exceeded && hasSpendable { snap.Exceeded = false } if st.AddOnQuota != nil { @@ -707,6 +760,8 @@ func (b *Bridge) QuotaSnapshot() *accounts.QuotaSnapshot { snap.AddOnTotal = st.AddOnQuota.Total snap.AddOnRemaining = st.AddOnQuota.Remaining snap.AddOnUnit = st.AddOnQuota.Unit + avail := true + snap.AddOnAvailable = &avail } if st.OrgResourcePackage != nil { snap.HasResourcePackage = true @@ -714,12 +769,24 @@ func (b *Bridge) QuotaSnapshot() *accounts.QuotaSnapshot { snap.ResourcePackageTotal = st.OrgResourcePackage.Cap snap.ResourcePackageRemaining = st.OrgResourcePackage.Remaining snap.ResourcePackageUnit = st.OrgResourcePackage.Unit - avail := st.OrgResourcePackage.Available + avail := resourceAvailable snap.ResourcePackageAvailable = &avail } return snap } +// quotaFetchedAtString reports when the cached quota buckets were last pulled +// from upstream. An empty value means no successful quota fetch has happened +// yet, which callers treat as "unknown" rather than a fabricated "now". +func (b *Bridge) quotaFetchedAtString() string { + b.accountMu.Lock() + defer b.accountMu.Unlock() + if b.quotaFetchedAt.IsZero() { + return "" + } + return b.quotaFetchedAt.UTC().Format(time.RFC3339) +} + // fmtResetTime renders an epoch-millis reset instant for logs, or "unknown". func fmtResetTime(ms int64) string { if ms <= 0 { diff --git a/internal/providers/qoder/bridge_quota_test.go b/internal/providers/qoder/bridge_quota_test.go index 76632896..6e78c097 100644 --- a/internal/providers/qoder/bridge_quota_test.go +++ b/internal/providers/qoder/bridge_quota_test.go @@ -2,6 +2,7 @@ package qoder import ( "testing" + "time" "github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth" ) @@ -87,4 +88,47 @@ func TestBridgeQuotaSnapshotNilAddOnKeepsDetailFields(t *testing.T) { if snap.HasAddOn || snap.HasResourcePackage { t.Fatalf("absent buckets must not be fabricated: %+v", snap) } + // No successful quota fetch has happened, so FetchedAt must be empty rather + // than a fabricated "now" that hides a stale balance. + if snap.FetchedAt != "" { + t.Fatalf("FetchedAt = %q, want empty before any upstream fetch", snap.FetchedAt) + } +} + +// A snapshot served from cache must keep the real last-fetch time rather than +// restamping every read as "now", which made a stale balance look fresh. +func TestBridgeQuotaSnapshotFetchedAtIsRealTime(t *testing.T) { + b := NewBridge("pat", qauth.CN, "mid", "mtok", "mac") + b.account = &qauth.AccountStatus{UserQuota: quotaBlock(100, 10, 90)} + // Simulate a successful fetch that happened a while ago. + past := time.Now().Add(-45 * time.Minute).UTC().Truncate(time.Second) + b.quotaFetchedAt = past + snap := b.QuotaSnapshot() + if snap == nil { + t.Fatal("QuotaSnapshot = nil") + } + if snap.FetchedAt != past.Format(time.RFC3339) { + t.Fatalf("FetchedAt = %q, want the real fetch time %q", snap.FetchedAt, past.Format(time.RFC3339)) + } +} + +// An unavailable org package must not inflate the headline (worker-path parity). +func TestBridgeQuotaSnapshotSkipsUnavailableResourcePackage(t *testing.T) { + b := NewBridge("pat", qauth.CN, "mid", "mtok", "mac") + b.account = &qauth.AccountStatus{ + UserQuota: quotaBlock(300, 100, 200), + OrgResourcePackage: &qauth.OrgResourcePackage{ + Cap: 500, Used: 0, Remaining: 500, Available: false, Unit: "credits", + }, + } + snap := b.QuotaSnapshot() + if snap == nil { + t.Fatal("QuotaSnapshot = nil") + } + if snap.Remaining != 200 || snap.Total != 300 { + t.Fatalf("headline = %v/%v, want 200/300 (unavailable package excluded)", snap.Remaining, snap.Total) + } + if !snap.HasResourcePackage || snap.ResourcePackageAvailable == nil || *snap.ResourcePackageAvailable { + t.Fatalf("resource package detail must report unavailable: %+v", snap) + } } diff --git a/internal/providers/qoder/checkin_test.go b/internal/providers/qoder/checkin_test.go index 367f5012..35eb0af0 100644 --- a/internal/providers/qoder/checkin_test.go +++ b/internal/providers/qoder/checkin_test.go @@ -44,8 +44,8 @@ func TestCheckinWorkerTransport(t *testing.T) { if fixture.want != "" && (err != nil || result.Status != fixture.want) { t.Fatalf("result=%+v err=%v", result, err) } - if client.Adapter().Checkin == nil || client.Adapter().Prober != nil { - t.Fatal("checkin must not register a Qoder prober") + if client.Adapter().Checkin == nil { + t.Fatal("checkin must be registered on the Qoder adapter") } }) } diff --git a/internal/providers/qoder/pat_legacy_test.go b/internal/providers/qoder/pat_legacy_test.go index bcb7eb0d..dcbec907 100644 --- a/internal/providers/qoder/pat_legacy_test.go +++ b/internal/providers/qoder/pat_legacy_test.go @@ -19,6 +19,19 @@ func (legacyPATStore) SaveCredentialPayload(context.Context, string, string, []b return nil } +// patFixtureStore returns a fixed credential payload for the adapter probe tests. +type patFixtureStore struct { + format string + payload []byte +} + +func (s patFixtureStore) LoadCredentialPayload(context.Context, string) (string, []byte, error) { + return s.format, s.payload, nil +} +func (patFixtureStore) SaveCredentialPayload(context.Context, string, string, []byte) error { + return nil +} + // A legacy account (CLI blob only, no credential-payload row) must surface // ErrPATRequired so Models/Quota/Checkin fall back to the Node worker. func TestBridgeLegacyAccountWithoutPayloadReturnsErrPATRequired(t *testing.T) { diff --git a/internal/runtime/manager_accounts.go b/internal/runtime/manager_accounts.go index c45543b7..9c6bb368 100644 --- a/internal/runtime/manager_accounts.go +++ b/internal/runtime/manager_accounts.go @@ -222,7 +222,9 @@ func (m *Manager) Accounts(ctx context.Context) ([]AccountView, error) { if !item.NextRestartAt.IsZero() && time.Now().Before(item.NextRestartAt) { view.NextRestartAt = item.NextRestartAt.UTC().Format(time.RFC3339) } - view.Quota = item.Quota + if item.Quota != nil { + view.Quota = item.Quota + } view.ModelCooldowns = activeModelCooldowns(item.ModelDownUntil) if !item.DownUntil.IsZero() && time.Now().Before(item.DownUntil) { view.DownUntil = item.DownUntil.UTC().Format(time.RFC3339) diff --git a/internal/runtime/manager_maintenance.go b/internal/runtime/manager_maintenance.go index 80e0c42b..38d9a440 100644 --- a/internal/runtime/manager_maintenance.go +++ b/internal/runtime/manager_maintenance.go @@ -119,9 +119,10 @@ func (manager *Manager) refreshCheckinQuota(ctx context.Context, accountID strin if err != nil { return } - // Qoder's registered adapter omits a Prober, so the generic branch would - // refresh the worker quota and miss the OpenAPI add-on grant a check-in - // just claimed. Refresh the real upstream OpenAPI quota in-process instead. + // Qoder check-in lands its reward in the OpenAPI add-on bucket. Refresh the + // real upstream OpenAPI quota in-process explicitly (the same force path the + // adapter Prober uses) so the grant shows up even if the generic probe ever + // changes shape. if account.Provider == "qoder" { if registered, ok := manager.providers.Get(account.Provider); ok { if q, ok := registered.Chat.(*qoder.Client); ok { @@ -140,11 +141,9 @@ func (manager *Manager) refreshCheckinQuota(ctx context.Context, accountID strin // refreshQoderQuota pulls the real upstream OpenAPI quota (which includes the // daily check-in add-on grant) and persists it. It never synthesizes a number. func (manager *Manager) refreshQoderQuota(ctx context.Context, accountID string, client *qoder.Client) { - release, ok := manager.acquireQuotaFetch(ctx, accountID) - if !ok { - return - } - defer release() + // quotaSnapshot already refreshes upstream itself; do not take the shared + // quota-fetch gate here, or a concurrent fetchProviderQuotaErr (manual + // refresh) would deadlock until this returns. snapshot, err := client.QuotaSnapshot(ctx, accountID, true) if err != nil || snapshot == nil { return diff --git a/internal/runtime/manager_probe.go b/internal/runtime/manager_probe.go index 3a365598..37e2843c 100644 --- a/internal/runtime/manager_probe.go +++ b/internal/runtime/manager_probe.go @@ -50,7 +50,7 @@ func (m *Manager) refreshOne(ctx context.Context, item Item, forceQuota bool) er return nil } if item.Runtime == string(providers.RuntimeInProcess) || strings.TrimSpace(item.URL) == "" { - return m.refreshInProcess(ctx, item) + return m.refreshInProcess(ctx, item, forceQuota) } client := qoder.WorkerClient{HTTP: m.httpClient} health, statusCode, err := client.Health(ctx, item.URL) @@ -95,7 +95,7 @@ func (m *Manager) refreshOne(ctx context.Context, item Item, forceQuota bool) er return nil } -func (m *Manager) refreshInProcess(ctx context.Context, item Item) error { +func (m *Manager) refreshInProcess(ctx context.Context, item Item, forceQuota bool) error { adapter, ok := m.providers.Get(item.Provider) if !ok || adapter.Prober == nil { // No prober registered: leave pool state alone and never hit ""+/health. @@ -108,7 +108,14 @@ func (m *Manager) refreshInProcess(ctx context.Context, item Item) error { health, err := adapter.Prober.Probe(ctx, item.ID) if err != nil { m.pool.MergeHealth(item.ID, false, false, 0, item.Restarts, err.Error()) - _ = m.store.Observe(ctx, item.ID, "", "error", err.Error(), KindUnavailable) + // A Qoder gateway 401/403 means the PAT is no longer valid, not a + // transient outage: mark the account login_required so the card shows + // "re-login" instead of a generic error. + status, kind := "error", KindUnavailable + if qoder.IsAuthError(err) { + status, kind = "login_required", KindAuth + } + _ = m.store.Observe(ctx, item.ID, "", status, err.Error(), kind) return fmt.Errorf("probe account %s: %w", item.ID, err) } m.pool.MergeHealth(item.ID, health.Ready, health.Hot, health.InFlight, item.Restarts, health.LastError) @@ -134,13 +141,27 @@ func (m *Manager) refreshInProcess(ctx context.Context, item Item) error { if err := m.store.Observe(ctx, item.ID, health.UID, status, health.LastError, kind); err != nil { return err } - if health.Ready || health.Hot { + if !health.Ready && !health.Hot { + return nil + } + m.fetchAccountModels(ctx, item) + // Quota is fetched after readiness so an upstream quota outage never flips + // Ready. A manual "refresh credits" (forceQuota) must hit upstream and + // complete before returning, so the console row it replaces carries the + // fresh number; the background/manual-list path stays async to keep latency + // off the list endpoint. + if forceQuota { + quotaCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if qerr := m.fetchProviderQuotaErr(quotaCtx, item.ID, adapter.Prober); qerr != nil { + return qerr + } + } else { go func(accountID string, prober providers.AccountProber) { quotaCtx, cancel := context.WithTimeout(m.runCtx, 5*time.Second) defer cancel() m.fetchProviderQuota(quotaCtx, accountID, prober) }(item.ID, adapter.Prober) - m.fetchAccountModels(ctx, item) } return nil } diff --git a/internal/runtime/manager_quota.go b/internal/runtime/manager_quota.go index bac81f61..e5a62b88 100644 --- a/internal/runtime/manager_quota.go +++ b/internal/runtime/manager_quota.go @@ -38,6 +38,39 @@ func (m *Manager) fetchProviderQuota(ctx context.Context, accountID string, prob // makes the account card render 0/0 and hides a later real reading. return } + m.persistQuota(ctx, accountID, quotaSnapshotFromInfo(info)) + m.clearRecoveredDevinQuota(ctx, accountID, info) +} + +// fetchProviderQuotaErr is fetchProviderQuota but surfaces a quota-probe +// failure (and an empty/unknown snapshot) so a manual "refresh credits" can +// report that the number could not be refreshed. A quota outage still never +// flips readiness in persistQuota. +func (m *Manager) fetchProviderQuotaErr(ctx context.Context, accountID string, prober providers.AccountProber) error { + if prober == nil { + return nil + } + release, ok := m.acquireQuotaFetch(ctx, accountID) + if !ok { + return &QuotaRefreshError{Err: ctx.Err()} + } + defer release() + info, err := prober.Quota(ctx, accountID) + if err != nil { + return &QuotaRefreshError{Err: fmt.Errorf("fetch quota account=%s: %w", accountID, err)} + } + if info == nil || !quotaInfoHasWindows(info) { + return &QuotaRefreshError{Err: fmt.Errorf("fetch quota account=%s: empty response", accountID)} + } + m.persistQuota(ctx, accountID, quotaSnapshotFromInfo(info)) + m.clearRecoveredDevinQuota(ctx, accountID, info) + return nil +} + +// quotaSnapshotFromInfo maps a provider QuotaInfo onto the persisted snapshot, +// keeping provider-specific fields (WorkBuddy expiry/packages, Codex plan) from +// leaking onto other providers. +func quotaSnapshotFromInfo(info *providers.QuotaInfo) *QuotaSnapshot { unit := info.Unit if unit == "" { unit = "credits" @@ -52,6 +85,23 @@ func (m *Manager) fetchProviderQuota(ctx context.Context, accountID string, prob FetchedAt: info.FetchedAt, Windows: quotaWindowsFromInfo(info.Windows), } + // Qoder reports a plan/add-on/organization-package breakdown that the + // browser/worker path persists; carry it through so a token account's card + // shows the same detail lines instead of collapsing to a single headline. + if info.ProviderID == "qoder" { + quota.HasAddOn = info.HasAddOn + quota.AddOnUsed = info.AddOnUsed + quota.AddOnTotal = info.AddOnTotal + quota.AddOnRemaining = info.AddOnRemaining + quota.AddOnUnit = info.AddOnUnit + quota.AddOnAvailable = info.AddOnAvailable + quota.HasResourcePackage = info.HasResourcePackage + quota.ResourcePackageUsed = info.ResourcePackageUsed + quota.ResourcePackageTotal = info.ResourcePackageTotal + quota.ResourcePackageRemaining = info.ResourcePackageRemaining + quota.ResourcePackageUnit = info.ResourcePackageUnit + quota.ResourcePackageAvailable = info.ResourcePackageAvailable + } // Package-expiry detail is currently only produced by WorkBuddy's // get-user-resource packs; keep other providers from leaking a stale or // fabricated expiry into the snapshot. @@ -63,8 +113,7 @@ func (m *Manager) fetchProviderQuota(ctx context.Context, accountID string, prob if info.ProviderID == "codex" { quota.Plan = strings.TrimSpace(info.Plan) } - m.persistQuota(ctx, accountID, quota) - m.clearRecoveredDevinQuota(ctx, accountID, info) + return quota } // clearRecoveredDevinQuota drops an account-wide quota cooldown once diff --git a/internal/runtime/providers_test.go b/internal/runtime/providers_test.go index 7a5bebbf..47e71f6b 100644 --- a/internal/runtime/providers_test.go +++ b/internal/runtime/providers_test.go @@ -3,6 +3,7 @@ package runtime_test import ( "context" "encoding/json" + "errors" "github.com/caigee-cmd/cli2api/internal/accounts" "github.com/caigee-cmd/cli2api/internal/executor" accountruntime "github.com/caigee-cmd/cli2api/internal/runtime" @@ -65,6 +66,7 @@ type fakeProber struct { quotaN int quotaDone chan struct{} err error + quotaErr error } func (f *fakeProber) Probe(ctx context.Context, accountID string) (providers.AccountHealth, error) { @@ -77,7 +79,7 @@ func (f *fakeProber) Quota(ctx context.Context, accountID string) (*providers.Qu if f.quotaDone != nil { close(f.quotaDone) } - return f.quota, nil + return f.quota, f.quotaErr } func TestManagerRefreshUsesInProcessProber(t *testing.T) { @@ -163,6 +165,90 @@ func TestManagerRefreshUsesInProcessProber(t *testing.T) { } } +// A manual single-account refresh with quota=1 (forceQuota) must fetch quota +// synchronously and persist it before returning, so the console row it replaces +// carries the freshly-read number; the background list path stays async. +func TestManagerRefreshAccountForceQuotaIsSynchronous(t *testing.T) { + ctx := context.Background() + store, err := sqlstore.OpenStore(filepath.Join(t.TempDir(), "qoder.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + account, err := store.Create(ctx, accounts.CreateAccount{ + Name: "WB", Provider: "workbuddy", Region: "cn", Enabled: false, + }) + if err != nil { + t.Fatal(err) + } + prober := &fakeProber{ + health: providers.AccountHealth{Ready: true, Hot: true, UID: "wb-uid"}, + quota: &providers.QuotaInfo{ + Used: 5, Total: 100, Remaining: 95, Percentage: 5, Unit: "credits", + FetchedAt: "2026-08-26T00:00:00Z", + }, + } + registry := providers.NewRegistry() + registry.Register(providers.Adapter{ID: "workbuddy", Prober: prober}) + manager := accountruntime.NewManager(accountruntime.ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{}) + manager.SetProviders(registry) + manager.Pool().Upsert(executor.Item{ID: account.ID, Provider: "workbuddy", Runtime: "in_process"}) + + if err := manager.RefreshAccount(ctx, account.ID, true); err != nil { + t.Fatalf("refresh err = %v", err) + } + // No polling: the row must already carry the quota when RefreshAccount returns. + item, _ := manager.Pool().ByID(account.ID) + if item.Quota == nil || item.Quota.Remaining != 95 { + t.Fatalf("quota not persisted synchronously: %+v", item.Quota) + } + stored, err := store.Get(ctx, account.ID) + if err != nil { + t.Fatal(err) + } + if stored.Quota == nil || stored.Quota.Remaining != 95 { + t.Fatalf("store quota not persisted synchronously: %+v", stored.Quota) + } +} + +// A failed forced quota refresh must surface as a QuotaRefreshError so the +// console can report "could not refresh" instead of silently leaving a stale +// number. +func TestManagerRefreshAccountForceQuotaSurfacesError(t *testing.T) { + ctx := context.Background() + store, err := sqlstore.OpenStore(filepath.Join(t.TempDir(), "qoder.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + account, err := store.Create(ctx, accounts.CreateAccount{ + Name: "WB", Provider: "workbuddy", Region: "cn", Enabled: false, + }) + if err != nil { + t.Fatal(err) + } + prober := &fakeProber{ + health: providers.AccountHealth{Ready: true, Hot: true, UID: "wb-uid"}, + quotaErr: errors.New("quota endpoint down"), + } + registry := providers.NewRegistry() + registry.Register(providers.Adapter{ID: "workbuddy", Prober: prober}) + manager := accountruntime.NewManager(accountruntime.ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{}) + manager.SetProviders(registry) + manager.Pool().Upsert(executor.Item{ID: account.ID, Provider: "workbuddy", Runtime: "in_process"}) + + err = manager.RefreshAccount(ctx, account.ID, true) + var qerr *accountruntime.QuotaRefreshError + if !errors.As(err, &qerr) { + t.Fatalf("err = %v, want QuotaRefreshError", err) + } + // Readiness is unaffected by a quota outage. + item, _ := manager.Pool().ByID(account.ID) + if item.Ready == nil || !*item.Ready { + t.Fatalf("quota failure flipped readiness: %+v", item) + } +} + func TestManagerRefreshPersistsQuotaWindows(t *testing.T) { ctx := context.Background() store, err := sqlstore.OpenStore(filepath.Join(t.TempDir(), "qoder.db")) @@ -226,6 +312,46 @@ func TestManagerRefreshPersistsQuotaWindows(t *testing.T) { } } +// A Qoder-style QuotaInfo breakdown must survive the generic quota path so the +// persisted snapshot keeps its add-on / resource-package detail lines. +func TestManagerRefreshPersistsQoderBreakdown(t *testing.T) { + ctx := context.Background() + store, err := sqlstore.OpenStore(filepath.Join(t.TempDir(), "qoder.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + account, err := store.Create(ctx, accounts.CreateAccount{Name: "Q", Provider: "qoder", Region: "cn", Enabled: false}) + if err != nil { + t.Fatal(err) + } + addrTrue := true + prober := &fakeProber{ + health: providers.AccountHealth{Ready: true, Hot: true, UID: "q-uid"}, + quota: &providers.QuotaInfo{ + Used: 400, Total: 3400, Remaining: 1499, Percentage: 11.8, Unit: "credits", + FetchedAt: "2026-11-14T00:00:00Z", ProviderID: "qoder", Plan: "PLAN_TIER_FREE", + HasAddOn: true, AddOnUsed: 1501, AddOnTotal: 3000, AddOnRemaining: 1499, AddOnUnit: "credits", AddOnAvailable: &addrTrue, + }, + } + registry := providers.NewRegistry() + registry.Register(providers.Adapter{ID: "qoder", Prober: prober}) + manager := accountruntime.NewManager(accountruntime.ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{}) + manager.SetProviders(registry) + manager.Pool().Upsert(executor.Item{ID: account.ID, Provider: "qoder", Runtime: "in_process"}) + + if err := manager.RefreshAccount(ctx, account.ID, true); err != nil { + t.Fatalf("refresh err = %v", err) + } + stored, err := store.Get(ctx, account.ID) + if err != nil { + t.Fatal(err) + } + if stored.Quota == nil || !stored.Quota.HasAddOn || stored.Quota.AddOnRemaining != 1499 || stored.Quota.AddOnAvailable == nil || !*stored.Quota.AddOnAvailable { + t.Fatalf("qoder breakdown lost through generic path: %+v", stored.Quota) + } +} + func TestManagerRefreshClearsRecoveredDevinQuotaCooldown(t *testing.T) { ctx := context.Background() store, err := sqlstore.OpenStore(filepath.Join(t.TempDir(), "qoder.db")) @@ -364,29 +490,58 @@ func TestManagerRefreshUsesQoderAdapterCatalog(t *testing.T) { } } -func TestQoderAdapterRegistrationDoesNotProbeEmptyURL(t *testing.T) { +func TestQoderAdapterProberSkipsWorkerURLAccounts(t *testing.T) { ctx := context.Background() store, err := sqlstore.OpenStore(filepath.Join(t.TempDir(), "qoder.db")) if err != nil { t.Fatal(err) } defer store.Close() - account, err := store.Create(ctx, accounts.CreateAccount{Name: "EmptyQoder", Enabled: false}) + // Worker/legacy account: no PAT payload. Even with the Prober registered, + // refreshOne must take the worker /health branch (URL is non-empty) and not + // call Prober.Probe, which would otherwise hit the Bridge fallback. + account, err := store.Create(ctx, accounts.CreateAccount{Name: "LegacyQoder", Provider: "qoder", Enabled: false}) if err != nil { t.Fatal(err) } + var probeHits int + worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + probeHits++ + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "ready": true, "hot": true, "uid": "legacy-uid"}) + case "/admin/quota": + _ = json.NewEncoder(w).Encode(map[string]any{"quota": map[string]any{ + "isQuotaExceeded": false, "fetchedAt": "now", + "userQuota": map[string]any{"total": 100.0, "used": 10.0, "remaining": 90.0, "unit": "credits"}, + }}) + default: + http.NotFound(w, r) + } + })) + defer worker.Close() manager := accountruntime.NewManager(accountruntime.ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{}) client := qoder.NewClient() client.Bind(manager.AccountURL, manager.ProxyAPIKey) registry := providers.NewRegistry() registry.Register(client.Adapter()) manager.SetProviders(registry) - manager.Pool().Upsert(executor.Item{ID: account.ID, Provider: "qoder", Runtime: "child_process"}) - if err := manager.RefreshAll(ctx, false); err != nil { - t.Fatalf("empty-URL qoder with Adapter must stay a no-op, got %v", err) + manager.Pool().Upsert(executor.Item{ID: account.ID, URL: worker.URL, Provider: "qoder", Runtime: "child_process"}) + if err := manager.RefreshAccount(ctx, account.ID, true); err != nil { + t.Fatalf("worker-URL qoder refresh failed: %v", err) } item, _ := manager.Pool().ByID(account.ID) - if item.Ready != nil || item.LastError != "" || item.Models != nil { - t.Fatalf("pool should be untouched, got %+v", item) + if item.Quota == nil || item.Quota.Remaining != 90 { + t.Fatalf("worker quota not persisted through worker branch: %+v", item.Quota) + } + stored, err := store.Get(ctx, account.ID) + if err != nil { + t.Fatal(err) + } + if stored.RemoteUID != "legacy-uid" { + t.Fatalf("worker uid not observed: %q", stored.RemoteUID) + } + if probeHits == 0 { + t.Fatal("worker branch was not exercised: /health never hit") } } diff --git a/internal/store/store.go b/internal/store/store.go index 44d5ec7e..7b6b468a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -752,6 +752,24 @@ ON CONFLICT(account_id) DO UPDATE SET format=excluded.format, payload=excluded.p return nil } +// SetCredentialAuthType records the credential kind (e.g. "pat" / "oauth") +// without touching the stored blob. SaveCredentialPayload writes a PAT but +// historically left auth_type at its create-time default, so a PAT account +// showed as "none" on the card (hiding export, blanking the type filter). Login +// calls this after a successful PAT import to keep the account metadata honest. +func (s *Store) SetCredentialAuthType(ctx context.Context, accountID, authType string) error { + result, err := s.db.ExecContext(ctx, `UPDATE accounts SET auth_type = ?, updated_at = ? WHERE id = ?`, + strings.TrimSpace(authType), formatTime(time.Now().UTC()), strings.TrimSpace(accountID)) + if err != nil { + return fmt.Errorf("update credential type: %w", err) + } + changed, _ := result.RowsAffected() + if changed == 0 { + return accounts.ErrAccountNotFound + } + return nil +} + func (s *Store) LoadCredentialPayload(ctx context.Context, accountID string) (string, []byte, error) { var format string var payload []byte