fix(ui): report a stalled provider-usage refresh on Model Providers

The page observed the incomplete-usage marker but discarded the exhausted
outcome, so once the retry budget was spent it rendered ordinary provider
cards with no usage and no explanation — indistinguishable from providers
that report no usage at all. Keep the outcome and render the warning the
Usage page already owns, reusing usage.providerUsage.stalled rather than
minting a Model Providers key so no locale baseline churns.

A user-initiated refresh now restarts the retry budget. The notice tells the
operator to refresh, so the button has to hand back attempts to spend; only
the forced path resets it, or the budget could never exhaust.

Also fixes tsgo:core:test on the current head: createStore's inferred literal
had no usageStats, so the run-bookkeeping case could not stamp it, and
view.test.ts needed the new prop.

Closes the ClawSweeper P2 at model-providers-page.ts:169-175.
This commit is contained in:
Sasan Sotoodehfar
2026-08-17 19:18:23 -04:00
parent 58f9fa2db4
commit 417d43b65d
5 changed files with 61 additions and 7 deletions
@@ -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<string, { lastUsed: number }> | undefined,
};
}
@@ -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<HTMLButtonElement>(".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: [] });
@@ -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,
@@ -46,6 +46,7 @@ function props(overrides: Partial<ModelProvidersViewProps> = {}): ModelProviders
unconfiguredProviders: [{ id: "anthropic", displayName: "Anthropic" }],
canMutate: true,
mutationBlockedReason: null,
providerUsageStalled: false,
probeAvailable: true,
busy: {},
messages: {},
+5
View File
@@ -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<string, boolean>;
messages: Record<string, ModelProviderRowMessage>;
@@ -644,6 +646,9 @@ export function renderModelProviders(props: ModelProvidersViewProps) {
providerRows,
)}
${props.quickAddSupported ? renderAddProvider(props) : nothing}
${props.providerUsageStalled
? html`<div class="callout warning" role="status">${t("usage.providerUsage.stalled")}</div>`
: nothing}
${props.mutationBlockedReason
? html`<div class="callout warning">${props.mutationBlockedReason}</div>`
: nothing}