diff --git a/src/gateway/server-methods/usage.status-cache.test.ts b/src/gateway/server-methods/usage.status-cache.test.ts index 4f72d484a4a0..0ef5d546a47b 100644 --- a/src/gateway/server-methods/usage.status-cache.test.ts +++ b/src/gateway/server-methods/usage.status-cache.test.ts @@ -60,6 +60,9 @@ function createStore(access = "access-one") { expires: 1_000_000, }, }, + // Run bookkeeping stamps this on the real store; declared so a test can add + // it without widening the profile shape the rotation case narrows on. + usageStats: undefined as Record | undefined, }; } diff --git a/ui/src/pages/model-providers/model-providers-page.test.ts b/ui/src/pages/model-providers/model-providers-page.test.ts index 8ecabbfb1b39..81256479b1ad 100644 --- a/ui/src/pages/model-providers/model-providers-page.test.ts +++ b/ui/src/pages/model-providers/model-providers-page.test.ts @@ -220,6 +220,38 @@ describe("ModelProvidersPage usage convergence", () => { ); }); + it("reports a stalled provider refresh once the retry budget is spent", async () => { + vi.useFakeTimers(); + const harness = createHarness("main"); + harness.setUsageStatus({ updatedAt: 1, providers: [], refreshing: true }); + const page = appendPage(harness.context); + await page.updateComplete; + + // Nothing is visible while retries are still in flight: a converging load is + // not a failure and must not warn. + expect(page.textContent ?? "").not.toContain("did not finish loading"); + + await vi.advanceTimersByTimeAsync(15_000); + await page.updateComplete; + + // Budget spent and the payload is still incomplete. Rendering the ordinary + // cards with no usage and no notice is indistinguishable from a provider + // that simply reports none. + expect(page.textContent ?? "").toContain("did not finish loading"); + + // The notice says "Refresh to retry", so a manual refresh has to hand back a + // budget — otherwise the button is a dead end and nothing ever converges. + const callsBeforeManual = harness.request.mock.calls.filter( + ([method]) => method === "usage.status", + ).length; + page.querySelector(".settings-section__actions button")?.click(); + await page.updateComplete; + await vi.advanceTimersByTimeAsync(15_000); + expect( + harness.request.mock.calls.filter(([method]) => method === "usage.status").length, + ).toBeGreaterThan(callsBeforeManual + 1); + }); + it("replaces a pending pre-disconnect load before it can publish", async () => { const harness = createHarness("main"); harness.setUsageStatus({ updatedAt: 1, providers: [] }); diff --git a/ui/src/pages/model-providers/model-providers-page.ts b/ui/src/pages/model-providers/model-providers-page.ts index 71cd158f6590..8d7bc2cf97ce 100644 --- a/ui/src/pages/model-providers/model-providers-page.ts +++ b/ui/src/pages/model-providers/model-providers-page.ts @@ -83,6 +83,8 @@ export class ModelProvidersPage extends OpenClawLightDomElement { @state() private addProviderKey = ""; @state() private defaultsDraft: DefaultModelSelection | null = null; @state() private selectedAgentId = ""; + /** Retry budget spent while usage was still incomplete; the cards lack usage. */ + @state() private providerUsageStalled = false; /** Client the current data was loaded from; a new client means stale data. */ private dataClient: GatewayBrowserClient | null = null; @@ -166,13 +168,14 @@ export class ModelProvidersPage extends OpenClawLightDomElement { this.dataClient = client; // The connection epoch scopes the retry budget: a reconnect is a fresh // Gateway cache generation and must not inherit the old attempt count. - // The "exhausted" state is deliberately dropped here: these cards render from - // models.authStatus and stay useful without usage, and this page's - // failed-usage notice is owned separately. Usage owns the visible outcome. - this.usageRetry.observe( - data !== null && isUsageIncomplete(data.providerUsage), - this.connectionLifecycle.epoch, - ); + // Once the budget is spent nothing will converge the payload, so the page + // has to say so — cards without usage and without a notice read exactly + // like providers that report no usage at all. + this.providerUsageStalled = + this.usageRetry.observe( + data !== null && isUsageIncomplete(data.providerUsage), + this.connectionLifecycle.epoch, + ) === "exhausted"; } override updated() { @@ -211,6 +214,8 @@ export class ModelProvidersPage extends OpenClawLightDomElement { private resetConnectionState(client: GatewayBrowserClient | null, connected: boolean) { this.usageRetry.useConnection(this.connectionLifecycle.epoch); + // The new connection gets a fresh budget, so the old verdict is stale. + this.providerUsageStalled = false; // A null run advances Task's call id, so a late pre-transition result cannot // publish even when the underlying request ignores AbortSignal. void this.refreshTask.run([null, this.selectedAgentId, false]); @@ -271,6 +276,13 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!client || !this.selectedAgentId) { return Promise.resolve(); } + if (opts.force) { + // The stalled notice tells the operator to refresh, so a user-initiated + // refresh has to hand back a budget to spend. Only the forced path resets + // it: doing this on the retry callback's own refresh would make the + // budget unspendable and the notice unreachable. + this.usageRetry.startCycle(); + } return this.refreshTask.run([client, this.selectedAgentId, opts.force]); } @@ -634,6 +646,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { ), canMutate: this.canMutate(), mutationBlockedReason: blockedReason, + providerUsageStalled: this.providerUsageStalled, probeAvailable: !this.probeUnsupported && advertised !== false, busy: this.busy, messages: this.messages, diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index f354b92e5f91..3eb312a91b2b 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -46,6 +46,7 @@ function props(overrides: Partial = {}): ModelProviders unconfiguredProviders: [{ id: "anthropic", displayName: "Anthropic" }], canMutate: true, mutationBlockedReason: null, + providerUsageStalled: false, probeAvailable: true, busy: {}, messages: {}, diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index dd8db0585792..6928208fea12 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -60,6 +60,8 @@ type ModelProvidersViewProps = { unconfiguredProviders: ProviderOption[]; canMutate: boolean; mutationBlockedReason: string | null; + /** Usage never converged before the retry budget ran out; cards lack usage. */ + providerUsageStalled: boolean; probeAvailable: boolean; busy: Record; messages: Record; @@ -644,6 +646,9 @@ export function renderModelProviders(props: ModelProvidersViewProps) { providerRows, )} ${props.quickAddSupported ? renderAddProvider(props) : nothing} + ${props.providerUsageStalled + ? html`
${t("usage.providerUsage.stalled")}
` + : nothing} ${props.mutationBlockedReason ? html`
${props.mutationBlockedReason}
` : nothing}