diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 2f7c24536caa..2da8015fce1e 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -21,6 +21,7 @@ import { projectCommand } from "./cli/project.ts"; import { threadCommand } from "./cli/thread.ts"; import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { updateCommand } from "./cli/update.ts"; +import { usageCommand } from "./cli/usage.ts"; import { uninstallCommand } from "./cli/uninstall.ts"; import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; @@ -78,6 +79,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => browserCommand, projectCommand, threadCommand, + usageCommand, serviceCommand, updateCommand, uninstallCommand, diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 989028afccb9..28f5ce36fbfb 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -129,7 +129,7 @@ const isThreadCliError = Schema.is(ThreadCliError); * These errors are expected outcomes for a script, so they print as one line * on stderr instead of the runtime's logged stack trace. */ -const reportThreadErrors = (effect: Effect.Effect) => +export const reportThreadErrors = (effect: Effect.Effect) => effect.pipe( Effect.tapError((error) => isThreadCliError(error) ? Console.error(error.message) : Effect.void, @@ -138,7 +138,7 @@ const reportThreadErrors = (effect: Effect.Effect) => const decodeFailureText = Schema.decodeUnknownOption(Schema.fromJsonString(OrchestratorMcpFailure)); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const printJson = (value: unknown) => Console.log(encodeJson(value)); +export const printJson = (value: unknown) => Console.log(encodeJson(value)); const ToolResult = Schema.Struct({ isError: Schema.optional(Schema.Boolean), @@ -202,9 +202,13 @@ export type ThreadTools = ReturnType; * Signs in as a short-lived MCP client of the running server, the same way an * outside agent does, and revokes that session when the command ends. */ -const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* ( +export const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* ( flags: { readonly baseDir: Option.Option }, run: (tools: ThreadTools) => Effect.Effect, + client: { readonly label: string; readonly name: string } = { + label: "t3 thread cli", + name: "t3-thread-cli", + }, ) { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveCliAuthConfig(flags, logLevel); @@ -217,7 +221,7 @@ const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* new ThreadServerUnreachableError({ origin, cause }))); return yield* run(makeThreadTools(session, origin)); }).pipe( diff --git a/apps/server/src/cli/usage.ts b/apps/server/src/cli/usage.ts new file mode 100644 index 000000000000..6e38a97d092f --- /dev/null +++ b/apps/server/src/cli/usage.ts @@ -0,0 +1,181 @@ +import { + OrchestratorMcpUsageLimitsResult, + OrchestratorMcpUsageSpendResult, + type OrchestratorMcpUsageWindow, +} from "@t3tools/contracts"; +import { formatCount, formatPercent, formatTokens, formatUsd } from "@t3tools/shared/usageFormat"; +import { formatDuration } from "@t3tools/shared/usageLimits"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import { Command, Flag } from "effect/cli"; + +import { projectLocationFlags } from "./config.ts"; +import { printJson, reportThreadErrors, withThreadTools } from "./thread.ts"; + +type Reading = Pick< + OrchestratorMcpUsageLimitsResult["providers"][number], + "ageMins" | "windows" | "unavailable" +>; + +const MINUTE = 60_000; + +const formatWindow = (window: OrchestratorMcpUsageWindow) => { + const reset = + window.resetsInMins === null + ? "" + : window.resetsInMins === 0 + ? " · resets now" + : ` · resets in ${formatDuration(window.resetsInMins * MINUTE)}`; + return ` ${window.label.padEnd(16)} ${String(window.remainingPercent).padStart(3)}% left${reset}`; +}; + +const formatReading = (title: string, reading: Reading) => [ + reading.ageMins === null + ? title + : `${title} · read ${formatDuration(reading.ageMins * MINUTE)} ago`, + ...(reading.unavailable === null + ? reading.windows.map(formatWindow) + : [` ${reading.unavailable}`]), +]; + +const formatNotices = (notices: ReadonlyArray) => + notices.length === 0 ? [] : ["Notices", ...notices.map((notice) => ` ${notice}`)]; + +const formatLimits = (limits: OrchestratorMcpUsageLimitsResult) => { + const lines = [ + ...limits.providers.flatMap((provider) => + formatReading( + String(provider.providerInstanceId) === String(provider.driverKind) + ? provider.providerInstanceId + : `${provider.providerInstanceId} [${provider.driverKind}]`, + provider, + ), + ), + ...limits.pooledAccounts.flatMap((account) => + formatReading(`${account.source} · ${account.accountId} [${account.driverKind}]`, account), + ), + ]; + return [ + ...(lines.length === 0 ? ["No providers are enabled."] : lines), + ...formatNotices(limits.notices), + ].join("\n"); +}; + +const amount = (costUsd: number | null, totalTokens: number) => + `${(costUsd === null ? "unpriced" : formatUsd(costUsd)).padStart(11)} ${formatTokens(totalTokens).padStart(6)} tokens`; + +const formatSpend = (spend: OrchestratorMcpUsageSpendResult) => { + const { window } = spend; + const range = + window.days === 1 + ? "past 24 hours" + : `${window.days} days, ${window.sinceDay} to ${window.untilDay}`; + const width = Math.max( + 0, + ...[ + ...spend.providers.map((provider) => provider.provider), + ...spend.models.map((model) => model.model), + ...spend.periods.map((period) => period.start), + ].map((label) => label.length), + ); + return [ + `Spend · ${range} (${window.timeZone}) · API estimate`, + ` ${formatUsd(spend.costUsd)} · ${formatTokens(spend.totalTokens)} tokens · ${formatCount(spend.sessions)} sessions${ + spend.unpricedShare > 0 ? ` · ${formatPercent(spend.unpricedShare)} unpriced` : "" + }`, + " By provider", + ...spend.providers.map( + (provider) => + ` ${provider.provider.padEnd(width)} ${amount(provider.costUsd, provider.totalTokens)} ${formatCount(provider.sessions)} sessions`, + ), + " By model", + ...spend.models.map( + (model) => ` ${model.model.padEnd(width)} ${amount(model.costUsd, model.totalTokens)}`, + ), + window.days === 1 ? " By hour" : " By day", + ...spend.periods.map( + (period) => ` ${period.start.padEnd(width)} ${formatUsd(period.costUsd).padStart(11)}`, + ), + ...formatNotices(spend.notices), + ].join("\n"); +}; + +const jsonFlag = Flag.Boolean("json").pipe( + Flag.withDescription("Print JSON instead of text."), + Flag.withDefault(false), +); + +const usageClient = { label: "t3 usage cli", name: "t3-usage-cli" }; + +const usageLimitsCommand = Command.make("limits", { + ...projectLocationFlags, + refresh: Flag.Boolean("refresh").pipe( + Flag.withDescription("Re-read every provider before printing. Slower."), + Flag.withDefault(false), + ), + json: jsonFlag, +}).pipe( + Command.withDescription( + "Show each provider's remaining subscription limits and when they reset.", + ), + Command.withHandler((flags) => + withThreadTools( + flags, + Effect.fn("cli.usage.limits")(function* (tools) { + const limits = yield* tools.call( + "t3_usage_limits", + { refresh: flags.refresh }, + OrchestratorMcpUsageLimitsResult, + ); + if (flags.json) { + return yield* printJson(limits); + } + yield* Console.log(formatLimits(limits)); + }), + usageClient, + ).pipe(reportThreadErrors), + ), +); + +const usageSpendCommand = Command.make("spend", { + ...projectLocationFlags, + days: Flag.ChoiceWithValue("days", [ + ["1", 1], + ["7", 7], + ["30", 30], + ["90", 90], + ] as const).pipe( + Flag.withDescription("Window in days, as on the Usage tab. 1 shows the past 24 hours."), + Flag.withDefault(30), + ), + refresh: Flag.Boolean("refresh").pipe( + Flag.withDescription("Wait for sources that are still loading. Slower."), + Flag.withDefault(false), + ), + json: jsonFlag, +}).pipe( + Command.withDescription( + "Show the Usage tab's token and dollar spend for this environment, with days in the server's time zone.", + ), + Command.withHandler((flags) => + withThreadTools( + flags, + Effect.fn("cli.usage.spend")(function* (tools) { + const spend = yield* tools.call( + "t3_usage_spend", + { days: flags.days, refresh: flags.refresh }, + OrchestratorMcpUsageSpendResult, + ); + yield* flags.json ? printJson(spend) : Console.log(formatSpend(spend)); + }), + usageClient, + ).pipe(reportThreadErrors), + ), +); + +export const usageCommand = Command.make("usage").pipe( + Command.withDescription( + "Show what the Usage tab shows, from the running server: limits or spend.", + ), + Command.withSubcommands([usageLimitsCommand, usageSpendCommand]), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5c6c4fece44b..862409f1c530 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -62,6 +62,7 @@ import { import * as WorktreeHandlers from "./toolkits/worktree/handlers.ts"; import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; +import * as UsageReport from "../usage/UsageReport.ts"; import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; import * as DeviceHandlers from "./toolkits/device/handlers.ts"; @@ -1011,7 +1012,11 @@ export const layerPreviewToolkit = Layer.mergeAll( export const layerOrchestratorToolkit = toolkitRegistration( OrchestratorToolkit, OrchestratorHandlers.layer, -).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer)); +).pipe( + Layer.provide(OrchestratorMcpService.layer), + Layer.provide(ThreadMetadataMcpService.layer), + Layer.provide(UsageReport.layer), +); export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 09b4bd4f424d..997d01467689 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -44,12 +44,18 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as UsageLimitSources from "../usage/UsageLimitSources.ts"; +import * as UsageService from "../usage/UsageService.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(UsageLimitSources.UsageLimitSources)({})), + Layer.provide(Layer.mock(UsageService.UsageService)({})), + Layer.provide(Layer.mock(ServerEnvironment.ServerEnvironment)({})), ); // Effect returns a declared tool failure as `isError` with its encoded payload diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 02216cec576d..f58c7cb80db5 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -51,10 +51,16 @@ import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; import { dispatchFailure } from "../threadAccess.ts"; import * as EnvironmentHandlers from "./environment/handlers.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; +import * as UsageLimitSources from "../../usage/UsageLimitSources.ts"; +import * as UsageService from "../../usage/UsageService.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(UsageLimitSources.UsageLimitSources)({})), + Layer.provide(Layer.mock(UsageService.UsageService)({})), + Layer.provide(Layer.mock(ServerEnvironment.ServerEnvironment)({})), ); // Effect returns a declared tool failure as `isError` with its encoded payload diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 0df604a85011..703eedbe81a9 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -7,6 +7,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; +import * as UsageReport from "../../../usage/UsageReport.ts"; const handlers = { orchestrator_capabilities: McpToolAccess.reads((input) => @@ -16,6 +17,28 @@ const handlers = { return yield* service.capabilities(scope, input); }), ), + t3_usage_limits: McpToolAccess.reads((input) => + Effect.gen(function* () { + const report = yield* UsageReport.UsageReport; + return yield* report.readLimits({ refresh: input.refresh === true }); + }), + ), + t3_usage_spend: McpToolAccess.reads((input) => + Effect.gen(function* () { + const report = yield* UsageReport.UsageReport; + return yield* report + .readSpend({ + days: input.days ?? 30, + refresh: input.refresh === true, + }) + .pipe( + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ code: "orchestration_error", message: error.detail }), + ), + ); + }), + ), delegate_task: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 111870be9bbd..03f79eeebecb 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -18,6 +18,10 @@ import { OrchestratorMcpTaskCancelInput, OrchestratorMcpTaskCancelResult, OrchestratorMcpUpdateScheduledTaskInput, + OrchestratorMcpUsageLimitsInput, + OrchestratorMcpUsageLimitsResult, + OrchestratorMcpUsageSpendInput, + OrchestratorMcpUsageSpendResult, OrchestratorMcpTaskStatusInput, OrchestratorMcpThreadInterruptInput, OrchestratorMcpThreadInterruptResult, @@ -36,6 +40,7 @@ import { Tool, Toolkit } from "effect/ai"; import * as Schema from "effect/Schema"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as UsageReport from "../../../usage/UsageReport.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; @@ -65,6 +70,34 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); +const UsageLimitsTool = Tool.make("t3_usage_limits", { + description: + "Read each enabled provider instance's subscription limits, such as Codex and Claude, to compare headroom before choosing where to start or delegate work. Each window gives remainingPercent and resetsInMins; a label such as 'Weekly · Fable' marks a window that applies to one model. unavailable explains an empty windows list: no subscription limits (API key or Bedrock), a failed read, or a provider that is not installed, signed in, or healthy. Limits can be old while no T3 client is open; ageMins says how old. pooledAccounts lists accounts from usage limit sources such as a CLI Proxy hub. For token and dollar spend, use t3_usage_spend.", + parameters: OrchestratorMcpUsageLimitsInput, + success: OrchestratorMcpUsageLimitsResult, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [UsageReport.UsageReport], +}) + .annotate(Tool.Title, "Get usage limits") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + +const UsageSpendTool = Tool.make("t3_usage_spend", { + description: + "Read the token and API-priced USD spend the Usage tab calculates from this environment's provider transcripts: totals, then cost and tokens by provider and model, and cost by day (or hour for days=1) in the server's time zone. notices say when a source is missing or still loading. For remaining quota and reset times, use t3_usage_limits.", + parameters: OrchestratorMcpUsageSpendInput, + success: OrchestratorMcpUsageSpendResult, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [UsageReport.UsageReport], +}) + .annotate(Tool.Title, "Get usage spend") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + const DelegateTaskTool = Tool.make("delegate_task", { description: "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Set isolation to worktree for a separate checkout based on the parent local commit, with project setup completed before the child starts. Uncommitted edits are not copied; omitted isolation shares the parent checkout. Use delegate_task when model options are requested or the child keeps the parent's provider instance and model with nonempty inheritedOptions from orchestrator_capabilities, even for native-supported models. Native subagents cannot reliably apply T3 model options. Set target.options using advertised IDs: Claude {effort: 'max', fastMode: true}; Codex {reasoningEffort: 'max', serviceTier: 'priority'}. Check providerInstanceId, model, and options in the result. Provider/model and modes inherit unless overridden. Options inherit only for the same instance and model; explicit entries override individual values and [] or {} clears them. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Leave runtimeMode unset unless the user asks for a supervised child: approval-required and auto-accept-edits usually make the child wait for a person at each action that needs approval. Prefer mode='async' for long work; mode='wait' blocks until completion, a blocking question, a pending approval, or timeout. If hasPendingQuestion, pass threadId=childThreadId to t3_pending_request_list, t3_pending_request_read (with requestId) and t3_pending_request_respond, or ask the user; the task's completion still wakes this thread. If pendingApproval is set, read the fresh full request in childThreadId before responding: the record can outlive its provider callback. It grants no approval authority; decisions remain with the child's existing approval holder, and pending request tools answer questions only. The child keeps running and its completion still wakes this thread. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", @@ -275,6 +308,8 @@ const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { export const OrchestratorToolkit = Toolkit.make( OrchestratorCapabilitiesTool, + UsageLimitsTool, + UsageSpendTool, DelegateTaskTool, TaskStatusTool, TaskCancelTool, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 97ebde00ac27..175993e37e4b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1438,6 +1438,8 @@ export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*"; // ClaudeAdapterV2.test.ts cross-checks this list against the toolkit. export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__orchestrator_capabilities", + "mcp__t3-code__t3_usage_limits", + "mcp__t3-code__t3_usage_spend", "mcp__t3-code__list_scheduled_tasks", "mcp__t3-code__t3_thread_list", "mcp__t3-code__t3_thread_wait", diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index a278470c286d..cd82c7ce66be 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1464,6 +1464,108 @@ it.layer( }), ); + it.effect("a fresh refresh re-probes past a cached reading and an in-flight pass", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("claudeAgent"); + const instanceId = ProviderInstanceId.make("claudeAgent"); + const provider = (checkedAt: string) => + ({ + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt, + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }) as const satisfies ServerProvider; + const probes = yield* Ref.make(0); + const cached = yield* Ref.make(null); + const firstProbeStarted = yield* Deferred.make(); + const releaseFirstProbe = yield* Deferred.make(); + const instances = [ + { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "claude:instance" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider("2026-06-10T00:00:00.000Z")), + refresh: Effect.gen(function* () { + const hit = yield* Ref.get(cached); + if (hit !== null) return provider(hit); + const probe = yield* Ref.updateAndGet(probes, (count) => count + 1); + const checkedAt = `2026-06-10T00:0${probe}:00.000Z`; + yield* Ref.set(cached, checkedAt); + if (probe === 1) { + yield* Deferred.succeed(firstProbeStarted, undefined); + yield* Deferred.await(releaseFirstProbe); + } + return provider(checkedAt); + }), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + invalidateCaches: Ref.set(cached, null), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }, + ] satisfies ReadonlyArray; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(instances.find((instance) => instance.instanceId === requestedId)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-fresh-refresh-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const checkedAt = (providers: ReadonlyArray) => + providers.find((candidate) => candidate.instanceId === instanceId)?.checkedAt; + const inFlight = yield* registry.refresh().pipe(Effect.forkChild); + yield* Deferred.await(firstProbeStarted); + assert.strictEqual( + checkedAt(yield* registry.refresh(undefined, { fresh: true })), + "2026-06-10T00:02:00.000Z", + ); + yield* Deferred.succeed(releaseFirstProbe, undefined); + yield* Fiber.join(inFlight); + assert.strictEqual(yield* Ref.get(probes), 2); + yield* registry.refresh(); + assert.strictEqual(yield* Ref.get(probes), 2); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { const cursorDriver = ProviderDriverKind.make("cursor"); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 97da44aa93ed..e723be37e66e 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -53,6 +53,7 @@ export class ProviderRegistry extends Context.Service< readonly refresh: ( provider?: ProviderDriverKind, + options?: { readonly fresh?: boolean }, ) => Effect.Effect>; /** @@ -646,7 +647,27 @@ export const layer = Layer.effect( return yield* Deferred.await(claimed.completion); }); - const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { + const refresh = Effect.fn("refresh")(function* ( + provider?: ProviderDriverKind, + options?: { readonly fresh?: boolean }, + ) { + if (options?.fresh) { + const instances = yield* instanceRegistry.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => provider === undefined || instance.driverKind === provider, + ), + (instance) => instance.invalidateCaches ?? Effect.void, + { discard: true }, + ); + } + if (provider === undefined && options?.fresh) { + yield* Effect.forEach(yield* getLiveSources, refreshOneSource, { + concurrency: "unbounded", + discard: true, + }); + return yield* Ref.get(providersRef); + } if (provider === undefined) { return yield* refreshAll(); } @@ -1010,8 +1031,8 @@ export const layer = Layer.effect( return { getProviders: Ref.get(providersRef), - refresh: (provider?: ProviderDriverKind) => - refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), + refresh: (provider?: ProviderDriverKind, options?: { readonly fresh?: boolean }) => + refresh(provider, options).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), refreshWorkspaceSnapshot: (input) => diff --git a/apps/server/src/usage/UsageReport.test.ts b/apps/server/src/usage/UsageReport.test.ts new file mode 100644 index 000000000000..8bec2f96fe2a --- /dev/null +++ b/apps/server/src/usage/UsageReport.test.ts @@ -0,0 +1,207 @@ +import { assert, it } from "@effect/vitest"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + USAGE_CONTRACT_VERSION, + UsageDay, + UsageLimitSourceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageBucket, + type UsageLimitSourceSnapshot, + type UsageSource, + type UsageSummaryInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; +import * as UsageLimitSources from "./UsageLimitSources.ts"; +import * as UsageReport from "./UsageReport.ts"; +import * as UsageService from "./UsageService.ts"; + +const checkedAt = "2026-10-21T10:00:00.000Z"; + +const limits: ServerProviderUsageLimits = { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "5h", + usedPercent: 27.6, + resetsAt: "2026-10-21T12:00:00.000Z", + windowDurationMins: 300, + }, + { id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 60 }, + ], +}; + +const provider = (id: string, overrides: Partial = {}): ServerProvider => ({ + instanceId: ProviderInstanceId.make(id), + driver: ProviderDriverKind.make(id), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + ...overrides, +}); + +const bucket: UsageBucket = { + day: UsageDay.make("2026-10-20"), + provider: "codex", + model: "gpt-5.5", + totals: { + uncachedInputTokens: 1_000, + cachedInputTokens: 9_000, + cacheCreationTokens: 0, + outputTokens: 500, + reasoningTokens: 200, + }, + costUsd: 1.005, + cacheSavingsUsd: 0.5, + costSource: "modelPriced", + records: 4, + unpricedRecords: 0, + sessions: 1, +}; + +const source = (usageProvider: UsageSource["fingerprint"]["provider"]): UsageSource => ({ + fingerprint: { + hostId: "host", + provider: usageProvider, + resolvedHomePath: `/home/dev/.${usageProvider}`, + volumeId: "1:1", + }, + status: "ok", + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 2, + message: null, +}); + +const layerUsage = (requests: Array): Layer.Layer => + Layer.mock(UsageService.UsageService)({ + readSummary: (input) => + Effect.sync(() => { + requests.push(input); + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: checkedAt, + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: [bucket], + sources: [source("codex")], + pricing: { status: "fresh", source: "test", fetchedAt: checkedAt, knownModels: 1 }, + scanDurationMs: 0, + }; + }), + }); + +interface Dependencies { + readonly registry?: Layer.Layer; + readonly sources?: ReadonlyArray; + readonly usage?: Layer.Layer; +} + +const layerReport = (dependencies: Dependencies) => + UsageReport.layer.pipe( + Layer.provide(dependencies.registry ?? ProviderRegistryMock.layer([])), + Layer.provide( + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.succeed(dependencies.sources ?? []), + refresh: Effect.void, + }), + ), + Layer.provide(dependencies.usage ?? layerUsage([])), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("usage-report-test"), + label: "Test environment", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }), + }), + ), + ); + +const readLimits = (dependencies: Dependencies) => + Effect.flatMap(UsageReport.UsageReport, (report) => report.readLimits({ refresh: false })).pipe( + Effect.provide(layerReport(dependencies)), + ); + +const readSpend = (dependencies: Dependencies, days: 1 | 7 | 30 | 90) => + Effect.flatMap(UsageReport.UsageReport, (report) => + report.readSpend({ days, refresh: false }), + ).pipe(Effect.provide(layerReport(dependencies))); + +it.effect("reads limits from the provider registry and limit hubs", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-10-21T12:00:00Z")); + const report = yield* readLimits({ + registry: ProviderRegistryMock.layer([provider("claudeAgent", { usageLimits: limits })]), + sources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "Team hub", + checkedAt, + accounts: [ + { + id: "codex-1.json", + driver: ProviderDriverKind.make("codex"), + usageLimits: limits, + }, + ], + }, + ], + }); + + assert.deepStrictEqual(report.providers, [ + { + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + driverKind: ProviderDriverKind.make("claudeAgent"), + ageMins: 120, + windows: [ + { kind: "session", label: "5h", remainingPercent: 72, resetsInMins: 0 }, + { kind: "weekly", label: "Weekly", remainingPercent: 40, resetsInMins: null }, + ], + unavailable: null, + }, + ]); + assert.deepStrictEqual( + report.pooledAccounts.map((account) => [account.source, account.accountId]), + [["Team hub", "codex-1.json"]], + ); + }), +); + +it.effect("reads spend from the Usage tab's summary for the requested window", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-10-21T12:00:00Z")); + const requests: Array = []; + const spend = yield* readSpend({ usage: layerUsage(requests) }, 7); + + assert.deepStrictEqual( + [requests[0]?.sinceDay, requests[0]?.untilDay, requests[0]?.resolution], + ["2026-10-15", "2026-10-21", "day"], + ); + assert.deepStrictEqual( + spend.providers.map((entry) => [entry.provider, entry.costUsd, entry.totalTokens]), + [["codex", 1.01, 10_500]], + ); + }), +); diff --git a/apps/server/src/usage/UsageReport.ts b/apps/server/src/usage/UsageReport.ts new file mode 100644 index 000000000000..ed4418022209 --- /dev/null +++ b/apps/server/src/usage/UsageReport.ts @@ -0,0 +1,208 @@ +import { + isProviderAvailable, + type EnvironmentId, + USAGE_CONTRACT_VERSION, + type OrchestratorMcpUsageLimitsResult, + type OrchestratorMcpUsageSpendResult, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageSummary, + type UsageTokenTotals, + type UsageLimitSourceSnapshot, + type UsageReadError, +} from "@t3tools/contracts"; +import { makeWindow } from "@t3tools/shared/usageFormat"; +import { limitsNotice, remainingPercent } from "@t3tools/shared/usageLimits"; +import { isModelCostUnknown, mergeUsage } from "@t3tools/shared/usageMerge"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as UsageLimitSources from "./UsageLimitSources.ts"; +import * as UsageService from "./UsageService.ts"; + +type Reading = Pick< + OrchestratorMcpUsageLimitsResult["providers"][number], + "ageMins" | "windows" | "unavailable" +>; +type SpendDays = OrchestratorMcpUsageSpendResult["window"]["days"]; + +const minutes = (milliseconds: number) => Math.max(0, Math.round(milliseconds / 60_000)); + +const reading = (limits: ServerProviderUsageLimits, now: number): Reading => ({ + ageMins: minutes(now - Date.parse(limits.checkedAt)), + windows: limits.windows.map((window) => ({ + kind: window.kind, + label: window.label, + remainingPercent: remainingPercent(window), + resetsInMins: window.resetsAt === undefined ? null : minutes(Date.parse(window.resetsAt) - now), + })), + unavailable: limitsNotice(limits), +}); + +const providerReading = (provider: ServerProvider, now: number): Reading => { + const unusable = !provider.installed + ? "This provider is not installed." + : !isProviderAvailable(provider) + ? (provider.unavailableReason ?? provider.message ?? "This provider is unavailable.") + : provider.status === "error" || provider.status === "disabled" + ? (provider.message ?? `This provider's status is ${provider.status}.`) + : provider.auth.status === "unauthenticated" + ? "This provider is not signed in." + : null; + if (unusable !== null) { + return { ageMins: null, windows: [], unavailable: unusable }; + } + if (provider.usageLimits === undefined) { + return { + ageMins: null, + windows: [], + unavailable: "This provider has not reported usage limits.", + }; + } + return reading(provider.usageLimits, now); +}; + +const summarizeLimits = ( + providers: ReadonlyArray, + sources: ReadonlyArray, + now: number, +): OrchestratorMcpUsageLimitsResult => ({ + providers: providers + .filter((provider) => provider.enabled) + .map((provider) => ({ + providerInstanceId: provider.instanceId, + driverKind: provider.driver, + ...providerReading(provider, now), + })), + pooledAccounts: sources.flatMap((source) => + source.accounts.map((account) => ({ + source: source.label, + accountId: account.id, + driverKind: account.driver, + ...reading(account.usageLimits, now), + })), + ), + notices: sources.flatMap((source) => + source.error === undefined ? [] : [`${source.label}: ${source.error}`], + ), +}); + +const CENTS = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2, useGrouping: false }); +const usd = (value: number) => Number(CENTS.format(value)); + +const totalTokens = (totals: UsageTokenTotals) => + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + totals.outputTokens; + +const summarizeSpend = ( + summary: UsageSummary, + environment: { readonly environmentId: EnvironmentId; readonly label: string }, + days: SpendDays, +): OrchestratorMcpUsageSpendResult => { + const merged = mergeUsage([{ ...environment, summary }], USAGE_CONTRACT_VERSION); + return { + window: { + days, + sinceDay: summary.sinceDay, + untilDay: summary.untilDay, + timeZone: summary.timeZone, + }, + costUsd: usd(merged.costUsd), + totalTokens: totalTokens(merged), + sessions: merged.sessions, + unpricedShare: Math.round(merged.costQuality.unpricedShare * 1000) / 1000, + providers: merged.providers.map((provider) => ({ + provider: provider.provider, + costUsd: usd(provider.costUsd), + totalTokens: provider.totalTokens, + sessions: provider.sessions, + })), + models: merged.models.map((model) => ({ + provider: model.provider, + model: model.model, + costUsd: isModelCostUnknown(model) ? null : usd(model.costUsd), + totalTokens: totalTokens(model.tokens), + })), + periods: + days === 1 + ? merged.hourly.map((period) => ({ start: period.hourStart, costUsd: usd(period.costUsd) })) + : merged.daily.map((period) => ({ start: period.day, costUsd: usd(period.costUsd) })), + notices: summary.sources.flatMap((source) => [ + ...(source.message !== null && + (source.status === "partial" || + source.status === "failed" || + source.fingerprint.provider === "cursor") + ? [source.message] + : []), + ...(source.refreshing + ? [ + `${source.fingerprint.provider} usage is still being read, so spend leaves it out. Pass refresh: true to wait for it.`, + ] + : []), + ]), + }; +}; + +export class UsageReport extends Context.Service< + UsageReport, + { + readonly readLimits: (input: { + readonly refresh: boolean; + }) => Effect.Effect; + readonly readSpend: (input: { + readonly days: SpendDays; + readonly refresh: boolean; + }) => Effect.Effect; + } +>()("t3/usage/UsageReport") {} + +const make = Effect.gen(function* () { + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; + const usage = yield* UsageService.UsageService; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + + const readLimits = Effect.fn("UsageReport.readLimits")(function* (input: { + readonly refresh: boolean; + }) { + if (input.refresh) { + yield* Effect.all( + [providerRegistry.refresh(undefined, { fresh: true }), usageLimitSources.refresh], + { concurrency: "unbounded", discard: true }, + ); + } + const [providers, sources] = yield* Effect.all([ + providerRegistry.getProviders, + usageLimitSources.current, + ]); + return summarizeLimits(providers, sources, DateTime.toEpochMillis(yield* DateTime.now)); + }); + + const readSpend = Effect.fn("UsageReport.readSpend")(function* (input: { + readonly days: SpendDays; + readonly refresh: boolean; + }) { + const now = yield* DateTime.now; + const window = makeWindow( + input.days, + DateTime.toDateUtc(now), + input.days === 1 ? "hour" : "day", + ); + const summary = yield* usage.readSummary({ + ...window, + ...(input.refresh ? { awaitRefresh: true } : {}), + }); + const environment = yield* serverEnvironment.getDescriptor; + return summarizeSpend(summary, environment, input.days); + }); + + return UsageReport.of({ readLimits, readSpend }); +}); + +export const layer = Layer.effect(UsageReport, make); diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 5f312b61bd2a..ce2b78cd73b4 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -215,6 +215,33 @@ user's model order for other providers. `omittedModels` counts the rest, and the `search` input finds them, legacy models included. `delegate_task` still accepts any catalog model. +### `t3_usage_limits` and `t3_usage_spend` + +The Usage tab's two halves, split so an agent that only needs headroom before +choosing a target pays for a small limits response, not a spend report. + +`t3_usage_limits` returns the subscription windows the provider registry +already holds: each driver's own probe (Codex app-server +`account/rateLimits/read`, the Claude Agent SDK's `get_usage`), turn-time +rate-limit events, and any configured usage limit sources. Snapshots only +re-probe while a client is in the foreground, so `refresh: true` clears driver +caches and re-reads every provider before answering. + +`t3_usage_spend` is `UsageService.readSummary` for the Usage tab's window +(`makeWindow`), merged with the tab's own `mergeUsage`, so the totals match the +tab when it shows only this environment, hides no providers, and runs in the +server's time zone. The tool reports the environment that serves it, every +provider, and days in the server's zone; the tab can merge environments, hide +providers, and uses the viewer's zone. + +Both answer what an agent decides with, not everything the tab draws: reset +and read times as minutes from now, no account email, and spend without the +tab's token-type, category, speed, and per-period provider splits. Every agent +pays for a tool's output schema on every turn, so there is no `detail` switch +for the rest; the tab shows it. + +`t3 usage limits` and `t3 usage spend` print the same results. + ### `delegate_task` Creates a T3-owned child thread and immediately dispatches the supplied task diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index f1503ef49b22..ce39bc96e2de 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -114,6 +114,14 @@ the message to read it from stdin. 2 when the agent waits for an answer you give in the app. Add `--json` for a result that always includes the thread id. +`t3 usage limits` prints each provider's remaining subscription limits and when +they reset, so a script can pick the provider with the most headroom. +`t3 usage spend` prints the Usage tab's token and dollar spend for the last 30 +days; `--days 1|7|30|90` picks another window. Spend counts days in the +server's time zone and includes every provider, even ones hidden on the Usage +tab. `--refresh` reads again first, and `--json` prints the same result agents +get from the `t3_usage_limits` and `t3_usage_spend` tools. + ## Remove an agent Approved agents appear under **Settings → Connections** like other clients. diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index df655c1d6c21..86473bab8af1 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -440,6 +440,12 @@ export function summarizeT3ToolCalls( case "capabilities": label = phrase("Checked", "check", `orchestration capabilities ${times}`); break; + case "usage-limits": + label = phrase("Checked", "check", `usage limits ${times}`); + break; + case "usage-spend": + label = phrase("Checked", "check", `usage spend ${times}`); + break; } return { label, failedCount }; } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index c962c3975c17..564db2eb99f8 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -39,6 +39,7 @@ import { } from "./orchestrationV2.ts"; import { ProviderOptionSelection, ProviderOptionSelectionValue } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; +import { UsageProviderKind } from "./usage.ts"; const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({ description: "Complete task or message text for the target agent.", @@ -582,6 +583,104 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ }); export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type; +export const OrchestratorMcpUsageLimitsInput = Schema.Struct({ + refresh: Schema.optional( + Schema.Boolean.annotate({ + description: + "Re-read every provider before answering. Slower; use when ageMins is too old to trust.", + }), + ), +}); +export type OrchestratorMcpUsageLimitsInput = typeof OrchestratorMcpUsageLimitsInput.Type; + +export const OrchestratorMcpUsageWindow = Schema.Struct({ + kind: Schema.Literals(["session", "weekly", "monthly", "other"]), + label: Schema.String, + remainingPercent: Schema.Finite, + resetsInMins: Schema.NullOr(Schema.Finite), +}).annotate({ identifier: "UsageLimitWindow" }); +export type OrchestratorMcpUsageWindow = typeof OrchestratorMcpUsageWindow.Type; + +const OrchestratorMcpUsageReading = { + ageMins: Schema.NullOr(Schema.Finite).annotate({ + description: "Minutes since these limits were read.", + }), + windows: Schema.Array(OrchestratorMcpUsageWindow), + unavailable: Schema.NullOr(Schema.String).annotate({ description: "Why windows is empty." }), +}; + +export const OrchestratorMcpUsageLimitsResult = Schema.Struct({ + providers: Schema.Array( + Schema.Struct({ + providerInstanceId: ProviderInstanceId, + driverKind: ProviderDriverKind, + ...OrchestratorMcpUsageReading, + }), + ), + pooledAccounts: Schema.Array( + Schema.Struct({ + source: Schema.String, + accountId: Schema.String, + driverKind: ProviderDriverKind, + ...OrchestratorMcpUsageReading, + }), + ), + notices: Schema.Array(Schema.String), +}); +export type OrchestratorMcpUsageLimitsResult = typeof OrchestratorMcpUsageLimitsResult.Type; + +const OrchestratorMcpUsageAmount = { + costUsd: Schema.Finite, + totalTokens: Schema.Finite, +}; + +export const OrchestratorMcpUsageSpendInput = Schema.Struct({ + days: Schema.optional( + Schema.Literals([1, 7, 30, 90]).annotate({ + description: + "As on the Usage tab: 1 is the past 24 hours by hour, otherwise calendar days ending today. Defaults to 30.", + }), + ), + refresh: Schema.optional( + Schema.Boolean.annotate({ + description: "Wait for sources that are still loading. Slower.", + }), + ), +}); +export type OrchestratorMcpUsageSpendInput = typeof OrchestratorMcpUsageSpendInput.Type; + +export const OrchestratorMcpUsageSpendResult = Schema.Struct({ + window: Schema.Struct({ + days: Schema.Literals([1, 7, 30, 90]), + sinceDay: Schema.String, + untilDay: Schema.String, + timeZone: Schema.String, + }), + ...OrchestratorMcpUsageAmount, + sessions: Schema.Finite, + unpricedShare: Schema.Finite.annotate({ + description: "Share of usage records with no known price, so missing from costUsd.", + }), + providers: Schema.Array( + Schema.Struct({ + provider: UsageProviderKind, + ...OrchestratorMcpUsageAmount, + sessions: Schema.Finite, + }), + ), + models: Schema.Array( + Schema.Struct({ + provider: UsageProviderKind, + model: Schema.String, + costUsd: Schema.NullOr(Schema.Finite), + totalTokens: Schema.Finite, + }), + ), + periods: Schema.Array(Schema.Struct({ start: Schema.String, costUsd: Schema.Finite })), + notices: Schema.Array(Schema.String), +}); +export type OrchestratorMcpUsageSpendResult = typeof OrchestratorMcpUsageSpendResult.Type; + export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ projectId: OrchestratorMcpProjectTarget, prompt: OrchestratorMcpPrompt.annotate({ diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 21f8be3941f9..95e8cb980adc 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -50,6 +50,8 @@ export type T3McpToolSummaryAction = | "project-delete" | "project-clone" | "environment-read" + | "usage-limits" + | "usage-spend" | "environment-update" | "attachment-prepare" | "attachment-discard" @@ -124,6 +126,8 @@ const T3_MCP_TOOLS: Readonly> = { ["Get", "Getting", "Got", "orchestration capabilities"], "capabilities", ), + t3_usage_limits: tool(["Check", "Checking", "Checked", "usage limits"], "usage-limits"), + t3_usage_spend: tool(["Check", "Checking", "Checked", "usage spend"], "usage-spend"), delegate_task: tool(["Delegate", "Delegating", "Delegated", "a child task"], "delegate"), task_status: tool(["Get", "Getting", "Got", "delegated task status"], "task-status"), task_cancel: tool(