mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(status): keep multi-agent diagnostics owner-safe (#123826)
* fix(status): preserve explicit multi-agent inventory ownership * chore: leave changelog to release automation
This commit is contained in:
committed by
GitHub
parent
3c5e2ff296
commit
b3f1cd36db
@@ -31,6 +31,11 @@ openclaw channels dead-letters list --channel telegram --account default
|
||||
|
||||
`channels list` shows chat channels only: configured accounts by default, with `installed`, `configured`, and `enabled` status tags per account (`--json` for machine output). Pass `--all` to also surface bundled channels that have no configured account yet and installable catalog channels that are not yet on disk. Provider auth and model usage live elsewhere: `openclaw models auth list` for provider auth profiles, `openclaw status` or `openclaw models list` for usage/quota.
|
||||
|
||||
In an explicit multi-agent setup, workspace-scoped channel plugins come from
|
||||
`agents.defaults.systemAgent.agentId`. Without that owner, `channels list`
|
||||
returns the shared bundled, managed, and global inventory with a diagnostic;
|
||||
it does not guess one agent workspace.
|
||||
|
||||
## Status / capabilities / resolve / logs
|
||||
|
||||
- `channels status`: `--channel <name>`, `--probe`, `--timeout <ms>` (default `10000`), `--json`
|
||||
|
||||
@@ -59,6 +59,9 @@ and `openclaw memory status --deep`.
|
||||
## Usage and quota
|
||||
|
||||
- `--usage` prints normalized provider usage windows as `X% left`.
|
||||
- In an explicit multi-agent setup, `--usage` reads the auth profiles owned by
|
||||
`agents.defaults.systemAgent.agentId`. Set that owner before using `--usage`;
|
||||
OpenClaw does not guess one agent's credentials from an ambiguous roster.
|
||||
- MiniMax's raw `usage_percent` / `usagePercent` fields are remaining quota,
|
||||
so OpenClaw inverts them before display; count-based fields win when
|
||||
present. `model_remains` responses prefer the chat-model entry, derive the
|
||||
|
||||
@@ -87,6 +87,7 @@ describe("read-only channel plugin legacy workspace discovery", () => {
|
||||
});
|
||||
|
||||
expect(resolution.plugins.map((plugin) => plugin.id)).toContain("research-chat");
|
||||
expect(resolution.manifestRecords.map((plugin) => plugin.id)).toContain("research-chat-plugin");
|
||||
expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspaceDir: path.resolve("/srv/ops") }),
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ type ReadOnlyChannelPluginOptions = {
|
||||
|
||||
type ReadOnlyChannelPluginResolution = {
|
||||
plugins: ChannelPlugin[];
|
||||
manifestRecords: readonly PluginManifestRecord[];
|
||||
configuredChannelIds: string[];
|
||||
missingConfiguredChannelIds: string[];
|
||||
loadFailures: ReadOnlyChannelPluginLoadFailure[];
|
||||
@@ -93,6 +94,7 @@ function cloneReadOnlyChannelPluginResolution(
|
||||
): ReadOnlyChannelPluginResolution {
|
||||
return {
|
||||
plugins: [...resolution.plugins],
|
||||
manifestRecords: [...resolution.manifestRecords],
|
||||
configuredChannelIds: [...resolution.configuredChannelIds],
|
||||
missingConfiguredChannelIds: [...resolution.missingConfiguredChannelIds],
|
||||
loadFailures: resolution.loadFailures.map((failure) => ({ ...failure })),
|
||||
@@ -862,6 +864,7 @@ export function resolveReadOnlyChannelPluginsForConfig(
|
||||
const plugins = [...byId.values()];
|
||||
const resolution = {
|
||||
plugins,
|
||||
manifestRecords,
|
||||
configuredChannelIds,
|
||||
missingConfiguredChannelIds: configuredChannelIds.filter((channelId) => !byId.has(channelId)),
|
||||
loadFailures,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
|
||||
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { resolvePluginControlPlaneWorkspace } from "../plugins/control-plane-workspace.js";
|
||||
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -23,8 +24,10 @@ const mocks = vi.hoisted(() => ({
|
||||
listManifestInstalledChannelIds: vi.fn<() => Set<string>>(() => new Set()),
|
||||
resolveMissingOfficialExternalChannelPluginRepairHint: vi.fn(),
|
||||
callGateway: vi.fn(),
|
||||
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
|
||||
resolveDefaultAgentId: vi.fn(() => "main"),
|
||||
resolvePluginControlPlaneWorkspace: vi.fn<typeof resolvePluginControlPlaneWorkspace>(() => ({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
workspaceScope: "selected",
|
||||
})),
|
||||
resolvePluginMetadataSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -44,6 +47,10 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
|
||||
resolvePluginMetadataSnapshot: mocks.resolvePluginMetadataSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/control-plane-workspace.js", () => ({
|
||||
resolvePluginControlPlaneWorkspace: mocks.resolvePluginControlPlaneWorkspace,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/command-secret-targets.js", () => ({
|
||||
getChannelsCommandSecretTargetIds: () => new Set<string>(),
|
||||
}));
|
||||
@@ -69,11 +76,6 @@ vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({
|
||||
mocks.resolveMissingOfficialExternalChannelPluginRepairHint,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
|
||||
}));
|
||||
|
||||
import { channelsListCommand } from "./channels/list.js";
|
||||
|
||||
function createMockChannelPlugin(overrides: {
|
||||
@@ -139,6 +141,11 @@ describe("channels list", () => {
|
||||
mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue(null);
|
||||
mocks.callGateway.mockReset();
|
||||
mocks.callGateway.mockRejectedValue(new Error("gateway unavailable"));
|
||||
mocks.resolvePluginControlPlaneWorkspace.mockReset();
|
||||
mocks.resolvePluginControlPlaneWorkspace.mockReturnValue({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
workspaceScope: "selected",
|
||||
});
|
||||
mocks.resolvePluginMetadataSnapshot.mockReturnValue(mocks.metadataSnapshot);
|
||||
});
|
||||
|
||||
@@ -192,6 +199,88 @@ describe("channels list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps shared inventory when an explicit multi-agent roster has no system owner", async () => {
|
||||
const runtime = createTestRuntime();
|
||||
const config = {
|
||||
agents: {
|
||||
ownership: "explicit" as const,
|
||||
entries: { main: {}, research: {} },
|
||||
},
|
||||
};
|
||||
mocks.resolvePluginControlPlaneWorkspace.mockReturnValue({
|
||||
workspaceScope: "omitted",
|
||||
diagnostic: {
|
||||
level: "warn",
|
||||
code: "workspace-scope-omitted",
|
||||
message: "Workspace plugin discovery was skipped for this explicit roster.",
|
||||
},
|
||||
});
|
||||
mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([
|
||||
createCatalogEntry("qqbot", "QQ Bot"),
|
||||
]);
|
||||
mocks.listManifestInstalledChannelIds.mockReturnValue(new Set(["qqbot"]));
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config });
|
||||
|
||||
await channelsListCommand({ all: true, json: true }, runtime);
|
||||
|
||||
expect(mocks.resolvePluginControlPlaneWorkspace).toHaveBeenCalledWith({
|
||||
config,
|
||||
env: process.env,
|
||||
});
|
||||
expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledWith({
|
||||
config,
|
||||
env: process.env,
|
||||
allowWorkspaceScopedCurrent: true,
|
||||
});
|
||||
expect(mocks.listTrustedChannelPluginCatalogEntries).toHaveBeenCalledWith({
|
||||
cfg: config,
|
||||
discovery: mocks.metadataSnapshot.discovery,
|
||||
});
|
||||
const payload = JSON.parse(loggedText(runtime)) as {
|
||||
chat: Record<string, { installed: boolean }>;
|
||||
diagnostics?: Array<{ code?: string }>;
|
||||
};
|
||||
expect(payload.chat.qqbot?.installed).toBe(true);
|
||||
expect(payload.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "workspace-scope-omitted" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the named system owner for workspace-scoped channel inventory", async () => {
|
||||
const runtime = createTestRuntime();
|
||||
const config = {
|
||||
agents: {
|
||||
ownership: "explicit" as const,
|
||||
defaults: { systemAgent: { agentId: "research" } },
|
||||
entries: { main: {}, research: { workspace: "/tmp/research-workspace" } },
|
||||
},
|
||||
};
|
||||
mocks.resolvePluginControlPlaneWorkspace.mockReturnValue({
|
||||
workspaceDir: "/tmp/research-workspace",
|
||||
workspaceScope: "selected",
|
||||
});
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config });
|
||||
|
||||
await channelsListCommand({ all: true, json: true }, runtime);
|
||||
|
||||
expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledWith({
|
||||
config,
|
||||
env: process.env,
|
||||
workspaceDir: "/tmp/research-workspace",
|
||||
allowWorkspaceScopedCurrent: true,
|
||||
});
|
||||
expect(mocks.listTrustedChannelPluginCatalogEntries).toHaveBeenCalledWith({
|
||||
cfg: config,
|
||||
workspaceDir: "/tmp/research-workspace",
|
||||
discovery: mocks.metadataSnapshot.discovery,
|
||||
});
|
||||
expect(mocks.listManifestInstalledChannelIds).toHaveBeenCalledWith({
|
||||
cfg: config,
|
||||
workspaceDir: "/tmp/research-workspace",
|
||||
index: mocks.metadataSnapshot.index,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps JSON output valid when only channels are provided (no usage field)", async () => {
|
||||
const runtime = createTestRuntime();
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Implements `openclaw channels list` across runtime accounts, local config, and catalog-only entries.
|
||||
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
|
||||
import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
|
||||
import { isChannelVisibleInConfiguredLists } from "../../channels/plugins/exposure.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
type RuntimeChannelStatusPayload,
|
||||
} from "../../channels/status/read-model.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { resolvePluginControlPlaneWorkspace } from "../../plugins/control-plane-workspace.js";
|
||||
import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js";
|
||||
import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
|
||||
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
|
||||
@@ -153,7 +153,11 @@ export async function channelsListCommand(
|
||||
return;
|
||||
}
|
||||
const showAll = opts.all === true;
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
const workspace = resolvePluginControlPlaneWorkspace({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
});
|
||||
const workspaceDir = workspace.workspaceDir;
|
||||
// Plugin metadata is process-stable. Resolve it once and carry its manifest,
|
||||
// discovery, and installed-index facts through every list projection.
|
||||
const metadataSnapshot = resolvePluginMetadataSnapshot({
|
||||
@@ -323,12 +327,18 @@ export async function channelsListCommand(
|
||||
origin: line.configured ? "configured" : line.installed ? "available" : "installable",
|
||||
};
|
||||
}
|
||||
writeRuntimeJson(runtime, { chat });
|
||||
writeRuntimeJson(runtime, {
|
||||
chat,
|
||||
...(workspace.diagnostic ? { diagnostics: [workspace.diagnostic] } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.heading("Chat channels:"));
|
||||
if (workspace.diagnostic) {
|
||||
lines.push(theme.warn(`- ${workspace.diagnostic.message}`));
|
||||
}
|
||||
if (accountLines.length === 0 && catalogOnlyLines.length === 0) {
|
||||
lines.push(
|
||||
theme.muted(
|
||||
|
||||
@@ -27,8 +27,9 @@ vi.mock("../../channels/account-inspection.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/read-only.js", () => ({
|
||||
resolveReadOnlyChannelPluginsForConfig: () => ({
|
||||
plugins: mocks.listReadOnlyChannelPluginsForConfig(),
|
||||
resolveReadOnlyChannelPluginsForConfig: (...args: unknown[]) => ({
|
||||
plugins: mocks.listReadOnlyChannelPluginsForConfig(...args),
|
||||
manifestRecords: [],
|
||||
configuredChannelIds: [],
|
||||
missingConfiguredChannelIds: [
|
||||
...new Set([
|
||||
@@ -108,6 +109,24 @@ describe("buildChannelsTable", () => {
|
||||
expect(detailRow?.Notes).toContain("credential available in gateway runtime");
|
||||
});
|
||||
|
||||
it("summarizes channels without selecting an owner from an explicit multi-agent roster", async () => {
|
||||
const config = {
|
||||
agents: {
|
||||
ownership: "explicit" as const,
|
||||
entries: { ops: {}, research: {} },
|
||||
},
|
||||
channels: { discord: { enabled: true } },
|
||||
};
|
||||
|
||||
const table = await buildChannelsTable(config);
|
||||
|
||||
expect(table.rows).toContainEqual(expect.objectContaining({ id: "discord", state: "warn" }));
|
||||
expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(config, {
|
||||
activationSourceConfig: config,
|
||||
includeSetupFallbackPlugins: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when a configured token is unavailable and there is no live account proof", async () => {
|
||||
const table = await buildChannelsTable({ channels: { discord: { enabled: true } } });
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import fs from "node:fs";
|
||||
import { asRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveInspectedChannelAccount } from "../../channels/account-inspection.js";
|
||||
import { hasConfiguredUnavailableCredentialStatus } from "../../channels/account-snapshot-fields.js";
|
||||
import {
|
||||
@@ -29,7 +28,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatPhoneNumberForCli } from "../../infra/phone-number-presentation.js";
|
||||
import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
|
||||
import { resolveMissingOfficialExternalChannelPluginRepairHints } from "../../plugins/official-external-plugin-repair-hints.js";
|
||||
import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
|
||||
import {
|
||||
summarizeTokenConfig,
|
||||
type ChannelAccountTokenSummaryRow,
|
||||
@@ -257,17 +255,9 @@ export async function buildChannelsTable(
|
||||
const sourceConfig = opts?.sourceConfig ?? cfg;
|
||||
const includeSetupFallbackPlugins = opts?.includeSetupFallbackPlugins ?? true;
|
||||
const credentialResolutionSkipped = opts?.credentialResolutionSkipped === true;
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
const metadataSnapshot = resolvePluginMetadataSnapshot({
|
||||
config: cfg,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
env: process.env,
|
||||
allowWorkspaceScopedCurrent: true,
|
||||
});
|
||||
const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(cfg, {
|
||||
activationSourceConfig: sourceConfig,
|
||||
includeSetupFallbackPlugins,
|
||||
metadataSnapshot,
|
||||
});
|
||||
for (const plugin of readOnlyPlugins.plugins) {
|
||||
// Use the plugin's default account even when no accounts are configured so setup guidance is concrete.
|
||||
@@ -530,7 +520,7 @@ export async function buildChannelsTable(
|
||||
config: cfg,
|
||||
activationSourceConfig: sourceConfig,
|
||||
channelIds: missingCandidateChannelIds,
|
||||
manifestRecords: metadataSnapshot.plugins,
|
||||
manifestRecords: readOnlyPlugins.manifestRecords,
|
||||
}).map((hint) => [hint.channelId, hint]),
|
||||
);
|
||||
for (const channelId of missingCandidateChannelIds) {
|
||||
|
||||
@@ -143,6 +143,41 @@ describe("status-runtime-shared", () => {
|
||||
expect(usageCall.agentDir).toContain("main");
|
||||
});
|
||||
|
||||
it("uses the named system agent for agent-scoped usage credentials", async () => {
|
||||
const config = {
|
||||
agents: {
|
||||
ownership: "explicit" as const,
|
||||
defaults: { systemAgent: { agentId: "ops" } },
|
||||
entries: {
|
||||
main: { agentDir: "/tmp/status-main-agent" },
|
||||
ops: { agentDir: "/tmp/status-ops-agent" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await resolveStatusUsageSummary({ config });
|
||||
|
||||
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({
|
||||
timeoutMs: undefined,
|
||||
config,
|
||||
agentDir: "/tmp/status-ops-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a system owner for usage credentials in an explicit multi-agent roster", async () => {
|
||||
await expect(
|
||||
resolveStatusUsageSummary({
|
||||
config: {
|
||||
agents: {
|
||||
ownership: "explicit",
|
||||
entries: { main: {}, ops: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Set agents.defaults.systemAgent.agentId");
|
||||
expect(mocks.loadProviderUsageSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds Codex synthetic usage for configured OpenAI Codex runtime routes without profiles", async () => {
|
||||
mocks.loadProviderUsageSummary
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Shared runtime probes used by status text and JSON commands.
|
||||
// Heavy modules stay lazily loaded so fast status output avoids security/provider/gateway costs.
|
||||
|
||||
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
|
||||
import { resolveSystemAgentTargetAgentId } from "../agents/agent-scope-config.js";
|
||||
import { resolveAgentDir } from "../agents/agent-scope.js";
|
||||
import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
|
||||
import { resolveModelAuthLabel } from "../agents/model-auth-label.js";
|
||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
@@ -46,15 +47,18 @@ function loadGatewayCallModule() {
|
||||
function shouldUseConfiguredCodexSyntheticUsage(params: {
|
||||
config: OpenClawConfig;
|
||||
agentDir: string;
|
||||
agentId?: string;
|
||||
}): boolean {
|
||||
const configuredDefault = resolveDefaultModelForAgent({
|
||||
cfg: params.config,
|
||||
agentId: params.agentId,
|
||||
allowPluginNormalization: false,
|
||||
});
|
||||
const policy = resolveAgentHarnessPolicy({
|
||||
config: params.config,
|
||||
provider: configuredDefault.provider,
|
||||
modelId: configuredDefault.model,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
if (
|
||||
!shouldUseCodexSyntheticUsageForRuntime({
|
||||
@@ -108,19 +112,27 @@ export async function resolveStatusSecurityAudit(params: {
|
||||
type StatusUsageSummaryOptions = {
|
||||
config: OpenClawConfig;
|
||||
timeoutMs?: number;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
};
|
||||
|
||||
/** Loads provider usage for status output, defaulting to the config's default agent directory. */
|
||||
/** Loads provider usage for status output from an explicit or ambient system-agent scope. */
|
||||
export async function resolveStatusUsageSummary(params: StatusUsageSummaryOptions) {
|
||||
const { loadProviderUsageSummary } = await loadProviderUsage();
|
||||
const agentDir = params.agentDir ?? resolveDefaultAgentDir(params.config);
|
||||
let agentId = params.agentId
|
||||
? resolveSystemAgentTargetAgentId(params.config, params.agentId)
|
||||
: undefined;
|
||||
let agentDir = params.agentDir;
|
||||
if (!agentDir) {
|
||||
agentId ??= resolveSystemAgentTargetAgentId(params.config);
|
||||
agentDir = resolveAgentDir(params.config, agentId);
|
||||
}
|
||||
const usage = await loadProviderUsageSummary({
|
||||
timeoutMs: params.timeoutMs,
|
||||
config: params.config,
|
||||
agentDir,
|
||||
});
|
||||
if (!shouldUseConfiguredCodexSyntheticUsage({ config: params.config, agentDir })) {
|
||||
if (!shouldUseConfiguredCodexSyntheticUsage({ config: params.config, agentDir, agentId })) {
|
||||
return usage;
|
||||
}
|
||||
const codexUsage = await loadProviderUsageSummary({
|
||||
|
||||
Reference in New Issue
Block a user