Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const DRIVER_LABEL: Partial<Record<string, string>> = {
codex: "Codex",
claudeAgent: "Claude",
antigravity: "Antigravity",
muse: "Muse Code",
};
const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const;

Expand Down
14 changes: 12 additions & 2 deletions docs/user/providers-muse.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
1 change: 1 addition & 0 deletions packages/provider-core/src/server/managedProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions packages/provider-core/src/server/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}
39 changes: 39 additions & 0 deletions packages/provider-core/src/server/usageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
29 changes: 23 additions & 6 deletions packages/provider-core/src/server/usageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerProviderUsageWindow["id"], ServerProviderUsageWindow>()
: 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) {
Comment thread
ntindle marked this conversation as resolved.
Expand Down
58 changes: 56 additions & 2 deletions packages/provider-muse/src/server/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>) =>
Effect.sync(() => {
notify({ jsonrpc: "2.0", method, params });
}),
takeCall: Effect.fnUntraced(function* (method: string) {
while (true) {
const call = yield* Queue.take(requests);
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<readonly [MuseSubscriptionUsage, number]> = [];
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();
Expand Down
24 changes: 24 additions & 0 deletions packages/provider-muse/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
MuseDelta,
MuseItemEvent,
MuseSessionResult,
MuseSubscriptionUsage,
MuseTokenUsageEvent,
MuseTodoList,
MuseTurnRetryScheduled,
Expand Down Expand Up @@ -201,6 +202,16 @@ export interface MuseAdapterV2Options {
request: ProviderContinuationRequests.ProviderContinuationRequest,
) => Effect.Effect<void>;
};
/**
* 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<void>;
/** The instance's account generation, read as each host starts, since reports name no account. */
readonly usageAccountGeneration?: Effect.Effect<number>;
}

interface ActiveTurn {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const mcpSession = yield* mcpSessions.read(input.threadId);
const environment = McpProviderSession.withAgentDeviceEnvironment(
options.environment,
Expand Down
Loading
Loading