diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index be9adb283488..c33eaffefa18 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -30,6 +30,7 @@ const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude", antigravity: "Antigravity", + muse: "Muse Code", }; const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; diff --git a/docs/user/providers-muse.md b/docs/user/providers-muse.md index 9013e97dfa97..38d509de6df8 100644 --- a/docs/user/providers-muse.md +++ b/docs/user/providers-muse.md @@ -9,8 +9,8 @@ and enable **Muse Code**. Set **Binary path** if Muse is not on the host's `PATH Refresh provider status after installation or login, then select Muse in a thread's model picker. -Provider status does not check your login. If you are signed out, messages -fail until you run `muse login` on the host. +Provider status shows when Muse is signed out. Run `muse login` on the host, +then refresh its status. ## Sign-in and API keys @@ -43,6 +43,16 @@ Muse can list models that your account cannot use. If a message fails with "does not exist or you lack access", pick another model, or add one your account can use under **Custom models** in the instance settings. +## Usage limits + +**Usage → Limits** shows your Meta subscription's session and weekly windows. +Muse reports them with each reply, so they appear after Muse's first reply +since the T3 server started. The same Meta account signed in on several hosts +counts once. + +With an API key, Limits shows none: a key names no Meta account to count +them against. + ## Permissions and limitations Muse offers two [permission modes](./permission-modes.md): **Supervised** asks diff --git a/docs/user/usage.md b/docs/user/usage.md index ed1dd19ce1a1..6f0cb6c1cb64 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -118,6 +118,10 @@ Grok reports the remaining subscription allowance and reset time for its current after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication or endpoint configurations do not report subscription limits. +Muse Code reports your Meta subscription's session and weekly windows with each reply, not on +request, so they appear after Muse's first reply since the server started. With an API key, Muse +does not report subscription limits. + API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index bac7fbd3a52a..e565783afbe8 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -207,6 +207,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( previous: state.snapshot.usageLimits, update, checkedAt: update.checkedAt, + ...(update.replace ? { replace: true } : {}), }); // `applyUsageLimitsUpdate` hands back the same object when nothing // moved, which is the common case for Codex's per-tick notification. diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index 7832e944410f..641e37616c8b 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -18,9 +18,13 @@ export interface ManagedServerProvider { /** * Fold a runtime rate-limit update into the published snapshot without * waiting for the next status probe. Sparse: windows merge by id and an - * update with no usable window leaves the snapshot untouched. + * update with no usable window leaves the snapshot untouched. With + * `replace`, the update's windows are the whole set instead. */ readonly applyUsageLimits: ( - update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, + update: ProviderUsageLimitsUpdate & { + readonly checkedAt: string; + readonly replace?: boolean; + }, ) => Effect.Effect; } diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 9b305a74b98e..2a9daac467b3 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -62,6 +62,45 @@ describe("applyUsageLimitsUpdate", () => { ).toBe(published); }); + it("replaces the windows outright when the update is the account's whole state", () => { + // Muse reports a window that has reset since with no reset time; a sparse + // merge would keep the expired one. + const restarted = { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 0, + windowDurationMins: 300, + } as const; + expect( + applyUsageLimitsUpdate({ + previous: published, + checkedAt: "2026-09-03T15:00:00.000Z", + update: { windows: [restarted] }, + replace: true, + }), + ).toEqual({ checkedAt: "2026-09-03T15:00:00.000Z", windows: [restarted] }); + // The same report again publishes nothing new. + expect( + applyUsageLimitsUpdate({ + previous: published, + checkedAt, + update: { windows: [session, weekly] }, + replace: true, + }), + ).toBe(published); + // A later report with the same windows publishes its time, so this account's + // report still counts as the freshest when Limits merges environments. + expect( + applyUsageLimitsUpdate({ + previous: published, + checkedAt: "2026-09-03T12:00:05.000Z", + update: { windows: [session, weekly] }, + replace: true, + }), + ).toEqual({ ...published, checkedAt: "2026-09-03T12:00:05.000Z" }); + }); + it("preserves reset credits when a streamed window update changes usage", () => { const resetCredits = { availableCount: 2, nextExpiresAt: "2026-10-01T00:00:00.000Z" }; const next = applyUsageLimitsUpdate({ diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index ea8d0d1d029f..41a28ff17b0a 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -61,31 +61,48 @@ export function applyUsageLimitsUpdate(input: { readonly previous: ServerProviderUsageLimits | undefined; readonly update: ProviderUsageLimitsUpdate; readonly checkedAt: string; + /** + * The update is the account's whole state: windows it leaves out are + * dropped, and a reset time it omits is cleared rather than kept. + */ + readonly replace?: boolean; }): ServerProviderUsageLimits | undefined { const { previous, update } = input; + const replace = input.replace === true; if (update.windows.length === 0 || previous?.unavailable?.reason === "unsupported") { return previous; } - const merged = new Map(previous?.windows.map((window) => [window.id, window] as const)); + const published = new Map(previous?.windows.map((window) => [window.id, window] as const)); + const merged = replace + ? new Map() + : new Map(published); // Codex sends this notification beside every token-usage tick, almost // always with unchanged numbers. Decide "nothing changed" per window on - // the way through so the no-op case never allocates a new snapshot. - let changed = false; + // the way through so the no-op case never allocates a new snapshot. A + // whole-state report that only moves the time is still news: Limits picks + // the freshest report for an account across environments by `checkedAt`. + let changed = + replace && + (update.windows.length !== published.size || input.checkedAt !== previous?.checkedAt); for (const window of update.windows) { - const existing = merged.get(window.id); + const existing = published.get(window.id); const next: ServerProviderUsageWindow = { ...window, usedPercent: clampPercent(window.usedPercent), - ...(window.resetsAt === undefined && existing?.resetsAt !== undefined + ...(!replace && window.resetsAt === undefined && existing?.resetsAt !== undefined ? { resetsAt: existing.resetsAt } : {}), - ...(window.windowDurationMins === undefined && existing?.windowDurationMins !== undefined + ...(!replace && + window.windowDurationMins === undefined && + existing?.windowDurationMins !== undefined ? { windowDurationMins: existing.windowDurationMins } : {}), }; if (existing === undefined || !usageWindowEquals(existing, next)) { merged.set(window.id, next); changed = true; + } else if (replace) { + merged.set(window.id, existing); } } if (!changed && previous !== undefined && previous.unavailable === undefined) { diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index c09ac92efa50..bf967935c03d 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -33,7 +33,7 @@ import * as Stream from "effect/Stream"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; -import type { MuseItem } from "./protocol.ts"; +import type { MuseItem, MuseSubscriptionUsage } from "./protocol.ts"; import type { MuseSdkHost } from "./sdk.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -173,6 +173,11 @@ const makeFakeMuse = Effect.fnUntraced(function* (idPrefix = "native") { Effect.sync(() => { notify({ jsonrpc: "2.0", method, params: { sessionId, ...params } }); }), + /** A notification about the host or account rather than a session, as `usage/changed` is. */ + emitHostWide: (method: string, params: Record) => + Effect.sync(() => { + notify({ jsonrpc: "2.0", method, params }); + }), takeCall: Effect.fnUntraced(function* (method: string) { while (true) { const call = yield* Queue.take(requests); @@ -191,7 +196,12 @@ const makeHarness = Effect.fnUntraced(function* ( policy = runtimePolicy, overrides: Pick< MuseAdapterV2Options, - "createHost" | "nativeEventLogger" | "modelCatalog" | "continuationRequests" + | "createHost" + | "nativeEventLogger" + | "modelCatalog" + | "continuationRequests" + | "onSubscriptionUsage" + | "usageAccountGeneration" > = {}, ) { let hostCount = 0; @@ -339,6 +349,14 @@ const startConversation = Effect.fnUntraced(function* ( return { nativeId: call.commandId!, providerTurn: event.providerTurn }; }); +// `usage/changed` as Muse 1.4.3 sent it after a reply (2026-10-08), with a placeholder tier. +const SUBSCRIPTION_USAGE = { + window: { usedPercent: 0, windowDurationMins: 300, resetsAtMs: 1791465557000 }, + weekly: { usedPercent: 5, resetsAtMs: 1791763200000 }, + tier: "tier-1", + observedAtMs: 1791447762610, +} satisfies MuseSubscriptionUsage; + const approval = (turnId: string) => ({ approvalId: "approval-1", turnId, @@ -548,6 +566,42 @@ describe("MuseAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("hands Meta's subscription usage to the instance, with or without a turn", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const reports: Array = []; + let generation = 3; + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + runtimePolicy, + { + onSubscriptionUsage: (usage, accountGeneration) => + Effect.sync(() => void reports.push([usage, accountGeneration])), + usageAccountGeneration: Effect.sync(() => generation), + }, + ); + // A report counts for the generation its host started under, not the current one. + generation = 4; + yield* fake.emitHostWide("usage/changed", SUBSCRIPTION_USAGE); + yield* fake.emitHostWide("usage/changed", { window: "not usage" }); + // A reset no Date can hold would throw once formatted, so the report is skipped too. + yield* fake.emitHostWide("usage/changed", { + ...SUBSCRIPTION_USAGE, + weekly: { usedPercent: 5, resetsAtMs: 1e16 }, + }); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "completed"); + assert.deepStrictEqual(reports, [[SUBSCRIPTION_USAGE, 3]]); + assert.strictEqual(fake.closeCount(), 0); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("moves a resumed session on another model back to the catalog default", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index 2694b5990784..7dc88a426bab 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -50,6 +50,7 @@ import { MuseDelta, MuseItemEvent, MuseSessionResult, + MuseSubscriptionUsage, MuseTokenUsageEvent, MuseTodoList, MuseTurnRetryScheduled, @@ -201,6 +202,16 @@ export interface MuseAdapterV2Options { request: ProviderContinuationRequests.ProviderContinuationRequest, ) => Effect.Effect; }; + /** + * Receives each subscription usage report (`usage/changed`) from this adapter's hosts, + * with the account generation the reporting host started under. + */ + readonly onSubscriptionUsage?: ( + usage: MuseSubscriptionUsage, + accountGeneration: number, + ) => Effect.Effect; + /** The instance's account generation, read as each host starts, since reports name no account. */ + readonly usageAccountGeneration?: Effect.Effect; } interface ActiveTurn { @@ -261,6 +272,7 @@ const INFORMATIONAL_NOTIFICATIONS = new Set([ "session/tokenUsage", "session/todoListChanged", "turn/retryScheduled", + "usage/changed", ]); // Tools whose results already show as their own rows: T3's todo list and question // rows, and the workflow item a `workflow` call launches. @@ -328,6 +340,8 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( const eventPermit = yield* Semaphore.make(1); let host: MuseSdkHost; let hostEpoch = 0; + // The account generation the current host started under; see usageAccountGeneration. + let hostAccountGeneration = 0; let closed = false; let broken = false; let nativeSessionId: string | undefined; @@ -1285,6 +1299,13 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( }); const handleNotification = Effect.fnUntraced(function* (method: string, data: unknown) { const params = yield* decode(recordSchema, data); + // Account-wide, so it names no session: Meta's subscription windows after a response. + if (method === "usage/changed") { + const usage = yield* decode(MuseSubscriptionUsage, params); + if (options.onSubscriptionUsage) + yield* options.onSubscriptionUsage(usage, hostAccountGeneration); + return; + } if (params.sessionId !== nativeSessionId) return; if (method === "view/gap") return yield* protocolError("Muse delivery gap requires session recovery"); @@ -1648,6 +1669,9 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( ); const launchHost = Effect.fnUntraced(function* () { const epoch = ++hostEpoch; + hostAccountGeneration = options.usageAccountGeneration + ? yield* options.usageAccountGeneration + : 0; const mcpSession = yield* mcpSessions.read(input.threadId); const environment = McpProviderSession.withAgentDeviceEnvironment( options.environment, diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 9a70f74eed83..712497ab0647 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -1,10 +1,13 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { MuseSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -21,7 +24,15 @@ import { checkMuseProviderStatus, makePendingMuseProvider } from "./status.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "./maintenance.ts"; +import type { MuseSubscriptionUsage } from "./protocol.ts"; import { makeMuseEnvironment } from "./sdk.ts"; +import { + museStatusUsageLimits, + museUsageWindows, + nextMuseUsageAccount, + type MuseUsageAccount, +} from "./usageLimits.ts"; +import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -92,6 +103,41 @@ export const MuseDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + // Meta reports subscription usage only with a model response, and a status + // check makes none. Keep the newest report from any of this instance's + // sessions so a refresh re-derives the windows instead of dropping them. + // Each report counts for the account generation its host started under. + const usageAccount = yield* Ref.make({ + generation: 0, + identity: undefined, + }); + const latestUsage = yield* Ref.make< + { readonly usage: MuseSubscriptionUsage; readonly generation: number } | undefined + >(undefined); + // Reports and status checks take turns, so an older report cannot publish after a + // newer one, and none publishes for a generation a check has just replaced. + const usagePermit = yield* Semaphore.make(1); + const withUsageLimits = (provider: ServerProviderDraft) => + usagePermit.withPermits(1)( + Effect.gen(function* () { + const { generation } = yield* Ref.updateAndGet(usageAccount, (account) => + nextMuseUsageAccount(account, provider.auth), + ); + const { retained, dropped } = yield* Ref.modify(latestUsage, (current) => { + const drop = current !== undefined && current.generation !== generation; + const kept = drop ? undefined : current; + return [{ retained: kept, dropped: drop }, kept] as const; + }); + const usageLimits = museStatusUsageLimits({ + auth: provider.auth, + enabled: provider.enabled, + observation: retained?.usage, + dropped, + nowMs: DateTime.toEpochMillis(yield* DateTime.now), + }); + return usageLimits ? { ...provider, usageLimits } : provider; + }), + ); const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { binaryPath: effectiveConfig.binaryPath, @@ -126,6 +172,7 @@ export const MuseDriver: ProviderDriver = { initialSnapshot: (settings) => makePendingMuseProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider: checkMuseProviderStatus(effectiveConfig, processEnvironment, cwd).pipe( + Effect.flatMap(withUsageLimits), Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -162,6 +209,34 @@ export const MuseDriver: ProviderDriver = { settings: effectiveConfig, environment: processEnvironment, modelCatalog, + usageAccountGeneration: Ref.get(usageAccount).pipe( + Effect.map((account) => account.generation), + ), + onSubscriptionUsage: (usage, hostGeneration) => + usagePermit.withPermits(1)( + Effect.gen(function* () { + const account = yield* Ref.get(usageAccount); + // A host from before a logout or another login reports for an account + // that is gone, and signed out there is no account to report for. + if (hostGeneration !== account.generation || account.identity === null) return; + // Every session's host reports; an older report arriving late must not win. + const newer = yield* Ref.modify(latestUsage, (previous) => + previous && previous.usage.observedAtMs >= usage.observedAtMs + ? [false, previous] + : [true, { usage, generation: hostGeneration }], + ); + // An API key names no account; see museStatusUsageLimits. + if (!newer || (yield* snapshot.getSnapshot).auth.type === "apiKey") return; + const now = yield* DateTime.now; + yield* snapshot.applyUsageLimits({ + windows: museUsageWindows(usage, DateTime.toEpochMillis(now)), + checkedAt: DateTime.formatIso(DateTime.makeUnsafe(usage.observedAtMs)), + // A report is the account's whole state, so a window that has reset + // since loses its old reset time instead of keeping it. + replace: true, + }); + }), + ), ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), continuationRequests, }); diff --git a/packages/provider-muse/src/server/modelCatalog.test.ts b/packages/provider-muse/src/server/modelCatalog.test.ts index 628628a161bd..da1efa0ceb94 100644 --- a/packages/provider-muse/src/server/modelCatalog.test.ts +++ b/packages/provider-muse/src/server/modelCatalog.test.ts @@ -1,19 +1,24 @@ import { describe, expect, it } from "@effect/vitest"; import { MuseSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import type { MuseSdkHost } from "./sdk.ts"; import { - discoverMuseModels, museModelCapabilities, + probeMuseHost, resolveMuseReasoningEffort, } from "./modelCatalog.ts"; const settings = Schema.decodeSync(MuseSettings)({}); -const host = (catalog: Record): MuseSdkHost => ({ +const host = ( + catalog: Record, + account?: Record, +): MuseSdkHost => ({ connection: { - request: async () => catalog, + request: async (method: string) => (method === "account/read" && account ? account : catalog), command: async () => ({}), mintCommandId: () => "test", onNotification: () => {}, @@ -39,7 +44,7 @@ const row = (modelId: string, extra: Record = {}) => ({ describe("Muse model catalog", () => { it.effect("uses the labels, efforts and defaults that model/list sends", () => Effect.gen(function* () { - const models = yield* discoverMuseModels(settings, {}, undefined, async () => + const { models, account } = yield* probeMuseHost(settings, {}, undefined, async () => host({ providerId: "meta", profileId: "tbh", @@ -79,6 +84,62 @@ describe("Muse model catalog", () => { // Older hosts and "unknown" fall back to Muse's documented tiers. expect(models[1]?.capabilities).toEqual(museModelCapabilities()); expect(models[2]?.capabilities).toEqual(museModelCapabilities()); + // A host that does not answer account/read in the expected shape leaves the account unknown. + expect(account).toBeUndefined(); + }), + ); + + it.effect("reads the signed-in account from the experimental account/read", () => + Effect.gen(function* () { + const { account } = yield* probeMuseHost(settings, {}, undefined, async () => + host( + { providerId: "meta", models: [row("muse-spark-1.3", { isDefault: true })] }, + { + state: "accountLogin", + label: "person@example.com", + avatarUrl: "https://example.com/a.png", + credentialRequired: true, + }, + ), + ).pipe(Effect.scoped); + expect(account).toEqual({ + state: "accountLogin", + label: "person@example.com", + credentialRequired: true, + }); + }), + ); + + it.effect("keeps the models when account/read never answers", () => + Effect.gen(function* () { + const base = host({ + providerId: "meta", + models: [row("muse-spark-1.3", { isDefault: true })], + }); + let askedForAccount = () => {}; + const accountRequested = new Promise((resolve) => { + askedForAccount = resolve; + }); + const probing = yield* probeMuseHost(settings, {}, undefined, async () => ({ + ...base, + connection: { + ...base.connection, + request: (method: string) => { + if (method !== "account/read") return base.connection.request(method); + askedForAccount(); + return new Promise(() => {}); + }, + }, + })).pipe(Effect.scoped, Effect.forkChild); + yield* Effect.promise(() => accountRequested); + yield* Effect.yieldNow; + yield* TestClock.adjust("2 seconds"); + // Bounded in real time, so a probe that waits on account/read fails here instead of hanging. + const { models, account } = yield* TestClock.withLive( + Fiber.join(probing).pipe(Effect.timeout("1 second")), + ); + expect(models.map(({ slug }) => slug)).toEqual(["muse-spark-1.3"]); + expect(account).toBeUndefined(); }), ); diff --git a/packages/provider-muse/src/server/modelCatalog.ts b/packages/provider-muse/src/server/modelCatalog.ts index f390d062803c..bc9e9dfc4398 100644 --- a/packages/provider-muse/src/server/modelCatalog.ts +++ b/packages/provider-muse/src/server/modelCatalog.ts @@ -3,7 +3,9 @@ import { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; import { MuseSettings } from "../settings.ts"; import { createModelCapabilities, getProviderOptionDescriptors } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { MuseAccountState } from "./protocol.ts"; import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts"; type MuseReasoningEffort = NonNullable["reasoningEffort"]>; @@ -43,6 +45,7 @@ const ModelCatalogEntry = Schema.Struct({ const decodeModelList = Schema.decodeUnknownEffect( Schema.Struct({ providerId: Schema.String, models: Schema.Array(ModelCatalogEntry) }), ); +const decodeAccountState = Schema.decodeUnknownEffect(MuseAccountState); /** Builds the reasoning picker from a `model/list` row, or the fallback when called without one. */ export function museModelCapabilities( @@ -97,7 +100,12 @@ class MuseCatalogError extends Schema.TaggedError()("MuseCatal detail: Schema.String, }) {} -export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* ( +/** + * What a read-only host reports about this machine's Muse: the model catalog + * and, where the experimental `account/read` is served, the credential in + * effect. Neither starts a session or a model call. + */ +export const probeMuseHost = Effect.fn("probeMuseHost")(function* ( settings: MuseSettings, environment: NodeJS.ProcessEnv | undefined, cwd?: string, @@ -110,6 +118,7 @@ export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* ( ...(environment ? { environment } : {}), ...(cwd ? { cwd } : {}), readOnly: true, + experimentalApi: true, startupTimeoutMs: 8_000, }, createHost, @@ -123,7 +132,7 @@ export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* ( return yield* new MuseCatalogError({ detail: "Muse returned a catalog for another provider." }); } const seen = new Set(); - return catalog.models.flatMap((model): ServerProviderModel[] => { + const models = catalog.models.flatMap((model): ServerProviderModel[] => { if (model.providerId !== "meta" || seen.has(model.modelId)) return []; seen.add(model.modelId); return [ @@ -136,4 +145,14 @@ export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* ( }, ]; }); + // A host without the experimental surface leaves the account unknown, as before, + // and so does one that never answers: the models found above must not wait on it. + // The timeout ends the wait, not the request; the probe's host closes right after, + // which drops a late answer. + const account = yield* Effect.tryPromise(() => host.connection.request("account/read", {})).pipe( + Effect.flatMap(decodeAccountState), + Effect.timeout(2_000), + Effect.option, + ); + return { models, account: Option.getOrUndefined(account) }; }); diff --git a/packages/provider-muse/src/server/protocol.ts b/packages/provider-muse/src/server/protocol.ts index e7413a181a04..106d04bc0f5d 100644 --- a/packages/provider-muse/src/server/protocol.ts +++ b/packages/provider-muse/src/server/protocol.ts @@ -158,6 +158,37 @@ export const MuseTurnCompleted = Schema.Struct({ error: Schema.optional(Schema.Struct({ message: Schema.String })), usage: Schema.optional(MuseUsage), }); +/** The latest instant a JavaScript Date holds; formatting a later one throws. */ +const LATEST_DATE_MS = 8.64e15; +const DateMillis = Schema.Finite.check( + Schema.isGreaterThan(0), + Schema.isLessThanOrEqualTo(LATEST_DATE_MS), +); +/** + * `usage/changed` params: the Meta subscription windows the latest model + * response reported. Percentages are Meta's, verbatim, and may exceed 100. + */ +export const MuseSubscriptionUsage = Schema.Struct({ + observedAtMs: DateMillis, + tier: Schema.String, + window: Schema.Struct({ + usedPercent: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)), + windowDurationMins: Schema.Finite.check(Schema.isGreaterThan(0)), + resetsAtMs: DateMillis, + }), + weekly: Schema.Struct({ + usedPercent: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)), + resetsAtMs: DateMillis, + }), +}); +export type MuseSubscriptionUsage = typeof MuseSubscriptionUsage.Type; +/** Experimental `account/read` result: the credential lane in effect and, for a login, its account. */ +export const MuseAccountState = Schema.Struct({ + state: Schema.String, + label: Schema.optional(Schema.String), + credentialRequired: Schema.optional(Schema.Boolean), +}); +export type MuseAccountState = typeof MuseAccountState.Type; export const MuseTurnRetryScheduled = Schema.Struct({ turnId: NonEmptyString, nextAttempt: Schema.Int.check(Schema.isGreaterThan(0)), diff --git a/packages/provider-muse/src/server/sdk.ts b/packages/provider-muse/src/server/sdk.ts index c950e3ad1b9c..b900fbc0b684 100644 --- a/packages/provider-muse/src/server/sdk.ts +++ b/packages/provider-muse/src/server/sdk.ts @@ -51,6 +51,8 @@ export interface MuseSdkHostOptions { readonly readOnly?: boolean; /** Read-only generation needs durable logging for turn/item notifications (verified through 1.1.1). */ readonly sessionLogging?: boolean; + /** Opts into MSP's experimental methods, such as `account/read`. */ + readonly experimentalApi?: boolean; readonly signal?: AbortSignal; readonly startupTimeoutMs?: number; } @@ -154,10 +156,13 @@ export function museServeArgs( } /** What T3 sends in MSP `initialize`; only full hosts ask for session MCP servers. */ -export function museInitializeParams(readOnly = false) { +export function museInitializeParams(readOnly = false, experimentalApi = false) { return { clientInfo: { name: "t3_code", title: "T3 Code", version: "1" }, - capabilities: { requestedCapabilities: readOnly ? [] : ["sessionMcp"] }, + capabilities: { + requestedCapabilities: readOnly ? [] : ["sessionMcp"], + ...(experimentalApi ? { experimentalApi: true } : {}), + }, }; } @@ -199,7 +204,7 @@ export async function createMuseSdkHost( timer.unref(); try { const host = await Promise.race([ - handshake.initialize(museInitializeParams(options.readOnly)), + handshake.initialize(museInitializeParams(options.readOnly, options.experimentalApi)), interrupted, ]); if (host.initializeResult.schema?.version !== 1) { diff --git a/packages/provider-muse/src/server/status.test.ts b/packages/provider-muse/src/server/status.test.ts index 8f7031de0ad7..6763bcc5baf9 100644 --- a/packages/provider-muse/src/server/status.test.ts +++ b/packages/provider-muse/src/server/status.test.ts @@ -9,14 +9,16 @@ import { vi } from "vite-plus/test"; import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; import type { MuseSdkHost } from "./sdk.ts"; import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; -import { checkMuseProviderStatus } from "./status.ts"; +import { checkMuseProviderStatus, museAccountAuth } from "./status.ts"; const settings = Schema.decodeSync(MuseSettings); -const makeHost = (catalog: Record) => { +const makeHost = (catalog: Record, account?: Record) => { const host: MuseSdkHost = { initializeResult: { grantedCapabilities: [] }, connection: { - request: vi.fn(async () => catalog), + request: vi.fn(async (method: string) => + method === "account/read" && account ? account : catalog, + ), command: vi.fn(async () => ({})), mintCommandId: () => "test-command", onNotification: () => {}, @@ -147,6 +149,84 @@ it.layer(NodeServices.layer)("Muse status", (it) => { ), ); + it.effect("names the signed-in account from the experimental account/read", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeCli(); + const host = makeHost(metaCatalog, { + state: "accountLogin", + label: "person@example.com", + credentialRequired: true, + }); + const snapshot = yield* checkMuseProviderStatus( + settings({ enabled: true, binaryPath }), + undefined, + undefined, + async () => host, + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "accountLogin", + email: "person@example.com", + }); + expect(host.connection.request).toHaveBeenCalledWith("account/read", {}); + }), + ), + ); + + it.effect("reports a signed-out Muse instead of waiting for the first turn to fail", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeCli(); + const snapshot = yield* checkMuseProviderStatus( + settings({ enabled: true, binaryPath }), + undefined, + undefined, + async () => makeHost(metaCatalog, { state: "loggedOut", credentialRequired: true }), + ); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth).toEqual({ status: "unauthenticated" }); + expect(snapshot.message).toContain("muse login"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["muse-discovered"]); + }), + ), + ); + + it.effect("maps every account/read credential lane", () => + Effect.sync(() => { + expect(museAccountAuth({ state: "apiKey" })).toEqual({ + status: "authenticated", + type: "apiKey", + label: "API key", + }); + expect(museAccountAuth({ state: "envKey", label: "META_API_KEY" })).toMatchObject({ + type: "apiKey", + }); + expect(museAccountAuth({ state: "accountLogin", label: "Team login" })).toEqual({ + status: "authenticated", + type: "accountLogin", + label: "Team login", + }); + expect(museAccountAuth({ state: "accountLogin", label: "Team @ Meta" })).toEqual({ + status: "authenticated", + type: "accountLogin", + label: "Team @ Meta", + }); + expect(museAccountAuth({ state: "accountLogin", label: " dev@example.com " })).toEqual({ + status: "authenticated", + type: "accountLogin", + email: "dev@example.com", + }); + // A keyless endpoint needs no login, and a lane this version does not know stays unknown. + expect(museAccountAuth({ state: "loggedOut", credentialRequired: false })).toEqual({ + status: "unknown", + }); + expect(museAccountAuth({ state: "somethingNew" })).toEqual({ status: "unknown" }); + expect(museAccountAuth(undefined)).toEqual({ status: "unknown" }); + }), + ); + it.effect("keeps an empty discovered catalog empty instead of advertising a fallback model", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-muse/src/server/status.ts b/packages/provider-muse/src/server/status.ts index a0a51b732e0d..8ef41230c24a 100644 --- a/packages/provider-muse/src/server/status.ts +++ b/packages/provider-muse/src/server/status.ts @@ -1,4 +1,4 @@ -import { ServerProviderModel } from "@t3tools/contracts"; +import { type ServerProvider, ServerProviderModel } from "@t3tools/contracts"; import { MuseSettings } from "../settings.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as DateTime from "effect/DateTime"; @@ -9,7 +9,8 @@ import * as ChildProcess from "effect/process/ChildProcess"; import { createMuseSdkHost, makeMuseEnvironment } from "./sdk.ts"; import { parseMuseVersion } from "./maintenance.ts"; -import { discoverMuseModels, museModelCapabilities } from "./modelCatalog.ts"; +import { museModelCapabilities, probeMuseHost } from "./modelCatalog.ts"; +import type { MuseAccountState } from "./protocol.ts"; import { buildServerProvider, COMPACT_SLASH_COMMAND, @@ -30,6 +31,36 @@ const MUSE_PRESENTATION = { } as const; const FALLBACK_CAPABILITIES = museModelCapabilities(); +// A login usually names its account by email. A display name such as +// "Team @ Meta" stays a label so Settings never shows it as an address. +const EMAIL_ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * The credential `account/read` reports. A login names its account, which is + * how Limits counts one Meta subscription once across environments. + * States this version does not know stay `unknown`, as without the method. + */ +export function museAccountAuth(account: MuseAccountState | undefined): ServerProvider["auth"] { + const label = account?.label?.trim(); + switch (account?.state) { + case "accountLogin": + return { + status: "authenticated", + type: "accountLogin", + ...(label ? (EMAIL_ADDRESS.test(label) ? { email: label } : { label }) : {}), + }; + case "apiKey": + case "envKey": + return { status: "authenticated", type: "apiKey", label: "API key" }; + case "loggedOut": + // A keyless endpoint needs no login, and nothing here can vouch for it. + return account.credentialRequired === false + ? { status: "unknown" } + : { status: "unauthenticated" }; + default: + return { status: "unknown" }; + } +} export const makePendingMuseProvider = Effect.fn("makePendingMuseProvider")(function* ( settings: MuseSettings, @@ -118,12 +149,12 @@ export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(func }); } - const catalog = yield* discoverMuseModels(settings, museEnvironment, cwd, createHost).pipe( + const probe = yield* probeMuseHost(settings, museEnvironment, cwd, createHost).pipe( Effect.scoped, Effect.timeoutOption(12_000), Effect.result, ); - if (Result.isFailure(catalog) || Option.isNone(catalog.success)) { + if (Result.isFailure(probe) || Option.isNone(probe.success)) { return snapshot({ installed: true, version, @@ -133,14 +164,27 @@ export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(func "Muse Code SDK could not read the model catalog. Check your Muse installation and run `muse login` on this T3 server host.", }); } - const models = catalog.success.value; - // MSP has no auth signal, and model/list answers from Muse's cache even when logged out. - // Auth failures surface on the first turn instead. + const { models, account } = probe.success.value; + // model/list answers from Muse's cache even when logged out, so the login comes from + // the experimental account/read. Without it, auth failures surface on the first turn. + const auth = museAccountAuth(account); + if (auth.status === "unauthenticated") { + return snapshot( + { + installed: true, + version, + status: "error", + auth, + message: "Muse Code is not signed in. Run `muse login` on this T3 server host.", + }, + models, + ); + } return snapshot( { installed: true, version, - auth: { status: "unknown" }, + auth, ...(models.length > 0 ? { status: "ready" } : { diff --git a/packages/provider-muse/src/server/usageLimits.test.ts b/packages/provider-muse/src/server/usageLimits.test.ts new file mode 100644 index 000000000000..24484266ddbf --- /dev/null +++ b/packages/provider-muse/src/server/usageLimits.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + museStatusUsageLimits, + museUsageAccount, + museUsageLimits, + museUsageWindows, + nextMuseUsageAccount, +} from "./usageLimits.ts"; + +// What Meta reported after a reply on 2026-10-08, as Muse forwards it. +const observation = { + observedAtMs: 1791447762610, + window: { usedPercent: 0, windowDurationMins: 300, resetsAtMs: 1791465557000 }, + weekly: { usedPercent: 5, resetsAtMs: 1791763200000 }, +}; +const reported = observation.observedAtMs; + +describe("Muse usage limits", () => { + it("maps Meta's window and week onto session and weekly rows", () => { + expect(museUsageWindows(observation, reported)).toEqual([ + { + id: "window", + kind: "session", + label: "Session", + windowDurationMins: 300, + usedPercent: 0, + resetsAt: "2026-10-08T13:19:17.000Z", + }, + { + id: "weekly", + kind: "weekly", + label: "Weekly", + windowDurationMins: 10080, + usedPercent: 5, + resetsAt: "2026-10-12T00:00:00.000Z", + }, + ]); + // Dated by the report, so the freshest report wins across environments. + expect(museUsageLimits(observation, reported).checkedAt).toBe("2026-10-08T08:22:42.610Z"); + }); + + it("caps over-quota percentages and starts a reset window over empty", () => { + const windows = museUsageWindows( + { + ...observation, + window: { ...observation.window, usedPercent: 104 }, + weekly: { ...observation.weekly, usedPercent: 101 }, + }, + observation.window.resetsAtMs + 60_000, + ); + // The session window has reset since the report; Meta opens the next one on use. + expect(windows[0]).toEqual({ + id: "window", + kind: "session", + label: "Session", + windowDurationMins: 300, + usedPercent: 0, + }); + expect(windows[1]).toMatchObject({ id: "weekly", usedPercent: 100 }); + }); + + it("publishes a login's report, waits for one, and shows none for an API key", () => { + const login = { + status: "authenticated", + type: "accountLogin", + email: "a@example.com", + } as const; + const status = ( + auth: Parameters[0]["auth"], + withReport: boolean, + enabled = true, + ) => + museStatusUsageLimits({ + auth, + enabled, + observation: withReport ? observation : undefined, + nowMs: reported, + }); + + expect(status(login, true)).toEqual(museUsageLimits(observation, reported)); + expect(status(login, false)?.unavailable).toEqual({ + reason: "probeFailed", + message: "Muse reports usage limits with its next reply.", + }); + // An API key names no account, so its reports could not be merged across environments. + const apiKey = { status: "authenticated", type: "apiKey", label: "API key" } as const; + expect(status(apiKey, true)?.unavailable?.reason).toBe("unsupported"); + // Without account/read the lane is unknown: show what Muse reports, but promise nothing. + expect(status({ status: "unknown" }, true)).toEqual(museUsageLimits(observation, reported)); + expect(status({ status: "unknown" }, false)).toBeUndefined(); + expect(status(login, false, false)).toBeUndefined(); + // Signed out, the last report has no account to show under. + expect(status({ status: "unauthenticated" }, true)).toBeUndefined(); + // Just after another account signs in, the old report is gone but its windows are still + // published; no usage clears them, where "waiting for a report" would keep them. + expect( + museStatusUsageLimits({ + auth: { ...login, email: "b@example.com" }, + enabled: true, + observation: undefined, + dropped: true, + nowMs: reported, + }), + ).toBeUndefined(); + }); + + it("starts a new account generation on a logout or another login", () => { + const login = { + status: "authenticated", + type: "accountLogin", + email: "a@example.com", + } as const; + const apiKey = { status: "authenticated", type: "apiKey", label: "API key" } as const; + // The first login a check names is the one the hosts so far started under. + const named = nextMuseUsageAccount({ generation: 0, identity: undefined }, login); + expect(named).toEqual({ generation: 0, identity: "a@example.com" }); + expect(nextMuseUsageAccount(named, login)).toBe(named); + // Nothing names the login without account/read, and an API key names no account. + expect(nextMuseUsageAccount(named, { status: "unknown" })).toBe(named); + expect(nextMuseUsageAccount(named, apiKey)).toBe(named); + expect(museUsageAccount(apiKey)).toBeUndefined(); + const other = nextMuseUsageAccount(named, { ...login, email: "b@example.com" }); + expect(other).toEqual({ generation: 1, identity: "b@example.com" }); + const signedOut = nextMuseUsageAccount(other, { status: "unauthenticated" }); + expect(signedOut).toEqual({ generation: 2, identity: null }); + // Signing in again, even as the same account, starts another: a host from before the + // logout may hold either login. + expect(nextMuseUsageAccount(signedOut, { ...login, email: "b@example.com" })).toEqual({ + generation: 3, + identity: "b@example.com", + }); + }); +}); diff --git a/packages/provider-muse/src/server/usageLimits.ts b/packages/provider-muse/src/server/usageLimits.ts new file mode 100644 index 000000000000..a61c90ffd4ea --- /dev/null +++ b/packages/provider-muse/src/server/usageLimits.ts @@ -0,0 +1,155 @@ +/** + * Muse Code subscription usage. Meta reports the account's windows with every + * model response, and Muse forwards them as `usage/changed`. + * + * Meta has no usage endpoint, so the last report is all there is between + * responses. Windows are re-derived from it against the clock: a window whose + * reset has passed starts over empty. + */ +import type { + ServerProviderAuth, + ServerProviderUsageLimits, + ServerProviderUsageWindow, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; + +const WEEK_MINS = 7 * 24 * 60; + +/** One report of the account's windows, as Muse forwards it. */ +export interface MuseUsageObservation { + readonly observedAtMs: number; + /** Meta's five-hour-class block. */ + readonly window?: { + readonly usedPercent: number; + readonly windowDurationMins: number; + readonly resetsAtMs: number; + }; + readonly weekly?: { readonly usedPercent: number; readonly resetsAtMs: number }; +} + +const isoFromMillis = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); + +function makeWindow( + id: "window" | "weekly", + windowDurationMins: number, + usedPercent: number, + resetsAtMs: number, + nowMs: number, +): ServerProviderUsageWindow { + const kind = windowDurationMins >= WEEK_MINS ? "weekly" : "session"; + // Meta opens the next window with the account's next request, so a window + // that has reset since the report is empty and its new reset is unknown. + const current = resetsAtMs > nowMs; + return { + id, + kind, + label: kind === "weekly" ? "Weekly" : "Session", + windowDurationMins: Math.round(windowDurationMins), + usedPercent: current ? clampPercent(usedPercent) : 0, + ...(current ? { resetsAt: isoFromMillis(resetsAtMs) } : {}), + }; +} + +export function museUsageWindows( + observation: MuseUsageObservation, + nowMs: number, +): ReadonlyArray { + const { window, weekly } = observation; + return [ + ...(window + ? [ + makeWindow( + "window", + window.windowDurationMins, + window.usedPercent, + window.resetsAtMs, + nowMs, + ), + ] + : []), + ...(weekly + ? [makeWindow("weekly", WEEK_MINS, weekly.usedPercent, weekly.resetsAtMs, nowMs)] + : []), + ]; +} + +/** Limits as of `nowMs`, dated when Meta reported them so the freshest report wins across environments. */ +export function museUsageLimits( + observation: MuseUsageObservation, + nowMs: number, +): ServerProviderUsageLimits { + return makeUsageLimits({ + checkedAt: isoFromMillis(observation.observedAtMs), + windows: museUsageWindows(observation, nowMs), + }); +} + +/** The login a status check names: its address or label. */ +export function museUsageAccount(auth: ServerProviderAuth): string | undefined { + return auth.type === "accountLogin" ? (auth.email ?? auth.label) : undefined; +} + +/** + * Which login an instance's usage reports belong to. Reports name no account, + * so each one counts for the generation its host started under, and a logout + * or another login starts a new generation: a report from an older host is + * never shown under the account that replaced it. + */ +export interface MuseUsageAccount { + readonly generation: number; + /** The login last named, `null` once signed out, `undefined` before any status check names one. */ + readonly identity: string | null | undefined; +} + +export function nextMuseUsageAccount( + account: MuseUsageAccount, + auth: ServerProviderAuth, +): MuseUsageAccount { + const identity = auth.status === "unauthenticated" ? null : museUsageAccount(auth); + if (identity === undefined || identity === account.identity) return account; + // The first login a check names is the one the hosts so far started under. + if (account.identity === undefined) return { ...account, identity }; + return { generation: account.generation + 1, identity }; +} + +/** + * What a status check publishes for an instance. An API key names no Meta + * account, so its reports cannot be told apart across environments and none + * are shown. Otherwise the newest report stands, or a login says when to + * expect one. + */ +export function museStatusUsageLimits(input: { + readonly auth: ServerProviderAuth; + readonly enabled: boolean; + readonly observation: MuseUsageObservation | undefined; + /** This check dropped the kept report: a logout or another login started a new generation. */ + readonly dropped?: boolean; + readonly nowMs: number; +}): ServerProviderUsageLimits | undefined { + const checkedAt = isoFromMillis(input.nowMs); + if (input.auth.type === "apiKey") { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Muse uses an API key here, which names no Meta account to report limits for.", + }); + } + // Signed out, there is no account to report on. + if (input.auth.status === "unauthenticated") return undefined; + if (input.observation) return museUsageLimits(input.observation, input.nowMs); + // The dropped report's windows are still published. "Waiting for a report" would + // keep them, as a failed probe keeps the last good bars, so publish none to clear them. + if (input.dropped) return undefined; + if (!input.enabled || input.auth.type !== "accountLogin") return undefined; + return makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Muse reports usage limits with its next reply.", + }); +}