diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 9bd3566e559a8..2a068fcda972d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -4601,6 +4601,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { if (lastErrorClass === "usage_limit") { assert.deepEqual(candidate, { id: sqlShell.id, + projectId: sqlShell.projectId, + modelSelection: sqlShell.modelSelection, status: sqlShell.status, lastErrorClass: sqlShell.lastErrorClass, usageLimitResetAt: sqlShell.usageLimitResetAt, @@ -4769,6 +4771,18 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { (row) => row.id === threadId, ), ); + for (const [since, found] of [ + [DateTime.subtract(now, { minutes: 1 }), true], + [DateTime.add(now, { minutes: 1 }), false], + ] as const) { + assert.strictEqual( + (yield* store.getLimitRecoveryCandidates({ + ...recoveryOptions, + switchAccountsSince: since, + })).some((row) => row.id === threadId), + found, + ); + } const reset = DateTime.makeUnsafe(limitItem.failure.resetAt); const recovery = { runId: original.id, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 95c0f7a4bae53..d834bea8ce1e5 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -171,6 +171,8 @@ export type ProjectionRecoveryKind = export type ProjectionLimitRecoveryCandidate = Pick< OrchestrationV2ThreadShell, | "id" + | "projectId" + | "modelSelection" | "status" | "lastErrorClass" | "latestRunId" @@ -507,6 +509,8 @@ export interface ProjectionStoreV2Shape { readonly now: DateTime.Utc; readonly autoResume: boolean; readonly snooze: boolean; + /** Also returns limits hit since then whose window has not reset, to move to another account. */ + readonly switchAccountsSince?: DateTime.Utc; }) => Effect.Effect, ProjectionStoreV2Error>; /** Epoch ms of the next armed reset or snooze end after `now`, when a waiting thread becomes a candidate. */ readonly getLimitRecoveryWakeAt: ( @@ -3879,6 +3883,11 @@ export const layer: Layer.Layer = AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) > julianday(COALESCE(r.completed_at, json_extract(t.payload_json, '$.updatedAt'))) AND ( ( + ${booleanInt(options.switchAccountsSince !== undefined)} + AND julianday(COALESCE(r.completed_at, json_extract(t.payload_json, '$.updatedAt'))) >= julianday(${DateTime.formatIso(options.switchAccountsSince ?? options.now)}) + AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) > julianday(${DateTime.formatIso(options.now)}) + ) + OR ( json_extract(t.payload_json, '$.limitRecovery.runId') IS r.run_id AND json_extract(t.payload_json, '$.limitRecovery.resetAt') IS json_extract(item.payload_json, '$.failure.resetAt') AND json_extract(t.payload_json, '$.limitRecovery.autoResume') = 1 @@ -3916,6 +3925,8 @@ export const layer: Layer.Layer = if (summary.lastErrorClass !== "usage_limit") continue; candidates.push({ id: thread.id, + projectId: thread.projectId, + modelSelection: thread.modelSelection, status: "failed", lastErrorClass: summary.lastErrorClass, usageLimitResetAt: summary.usageLimitResetAt, diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index ead417400e10f..74e92b6a04885 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -10,12 +10,16 @@ import { EventId, GuestMessageId, MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, RuntimeRequestId, ThreadId, TurnItemId, type OrchestrationV2Command, type OrchestrationV2ServerCommand, type OrchestrationV2StoredEvent, + type ServerProvider, type UserInputAttachments, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -27,6 +31,7 @@ import { resolveAttachmentPath, } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import { OrchestratorCommandPreviouslyRejectedError, @@ -40,6 +45,9 @@ const layerIntakeTest = ServerConfig.layerTest(process.cwd(), { }).pipe( Layer.provideMerge(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + ), ); const layerFailingDispatchHandler = @@ -426,3 +434,85 @@ it.effect("sends a guest message with the guest's files, never ones the client n expect(command.attachments).toEqual([guestFile]); }).pipe(Effect.provide(layerIntakeTest)), ); + +it.effect("sends on another account of the same provider when the chosen one is out of usage", () => + Effect.gen(function* () { + const claude = (id: string, usedPercent: number): ServerProvider => ({ + instanceId: ProviderInstanceId.make(id), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-13T00:00:00.000Z", + models: [{ slug: "claude-opus-5-5", name: "Opus 5.5", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-09-13T00:00:00.000Z", + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent, + resetsAt: "2099-01-01T00:00:00.000Z", + }, + ], + }, + }); + const captured: OrchestrationV2ServerCommand[] = []; + const selection = { + instanceId: ProviderInstanceId.make("claude_work"), + model: "claude-opus-5-5", + }; + yield* dispatchCommand({ + type: "message.dispatch", + commandId: CommandId.make("send-limited"), + threadId: ThreadId.make("thread-limited"), + messageId: MessageId.make("message-limited"), + text: "go", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + dispatch: layerFailingDispatchHandler(captured), + getThreadSnapshotWindow: () => + Effect.succeed({ + projection: { + thread: { + projectId: ProjectId.make("project-limited"), + modelSelection: selection, + }, + }, + } as never), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + claude("claude_work", 100), + claude("claude_personal", 30), + ]), + }), + ServerSettings.layerTest({ + fallbackToAvailableAccounts: true, + providerInstances: Object.fromEntries( + ["claude_work", "claude_personal"].map((id) => [ + ProviderInstanceId.make(id), + { driver: ProviderDriverKind.make("claudeAgent"), enabled: true }, + ]), + ), + }), + ), + ), + Effect.result, + ); + expect(captured[0]).toMatchObject({ + modelSelection: { instanceId: "claude_personal", model: "claude-opus-5-5" }, + }); + }).pipe(Effect.provide(layerIntakeTest)), +); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index 987a1deb246b6..5c5d213b604ea 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -1,10 +1,19 @@ import { remapComposerContextAttachments } from "@t3tools/shared/composerContextReferences"; import { appendUserInputAttachmentPaths } from "../provider/userInputAttachments.ts"; -import type { ChatAttachment, OrchestrationV2Command } from "@t3tools/contracts"; +import type { + ChatAttachment, + ModelSelection, + OrchestrationV2Command, + ProjectId, +} from "@t3tools/contracts"; +import { availableAccountSelection } from "@t3tools/shared/usageLimits"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as AttachmentClaims from "./AttachmentClaims.ts"; @@ -81,11 +90,53 @@ const withGuestAttachments = Effect.fn("ThreadMessageIntake.withGuestAttachments return { ...command, attachments: message?.attachments ?? [] }; }); +/** Null unless the user lets sends move off an account that is out of usage. */ +const readAccountChoice = Effect.fn("ThreadMessageIntake.readAccountChoice")(function* () { + const settings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.flatMap((service) => service.getSettings), + Effect.orElseSucceed(() => null), + ); + if (!settings?.fallbackToAvailableAccounts) return null; + const providers = yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + ); + const now = DateTime.toEpochMillis(yield* DateTime.now); + return (selection: ModelSelection, projectId: ProjectId) => + availableAccountSelection({ selection, providers, now, settings, projectId }) ?? selection; +}); + +const withAvailableAccount = Effect.fn("ThreadMessageIntake.withAvailableAccount")(function* ( + command: Extract, +) { + // Steering stays on the active run's account; server continuations validate their own. + if ( + command.dispatchMode.type === "steer_active" || + command.dispatchMode.type === "restart_active" || + command.usageLimitContinuationOfRunId !== undefined || + command.restartContinuationOfRunId !== undefined + ) { + return command; + } + const choose = yield* readAccountChoice(); + if (choose === null) return command; + const threads = yield* ThreadManagement.ThreadManagementService; + const window = yield* threads + .getThreadSnapshotWindow(command.threadId, { rowLimit: 1 }) + .pipe(Effect.orElseSucceed(() => null)); + if (window === null) return command; + const { thread } = window.projection; + const selection = command.modelSelection ?? thread.modelSelection; + const next = choose(selection, thread.projectId); + return next === selection ? command : { ...command, modelSelection: next }; +}); + export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")(function* ( incoming: OrchestrationV2Command, ) { const command = - incoming.type === "message.dispatch" ? yield* withGuestAttachments(incoming) : incoming; + incoming.type === "message.dispatch" + ? yield* withAvailableAccount(yield* withGuestAttachments(incoming)) + : incoming; const threads = yield* ThreadManagement.ThreadManagementService; if (command.type === "runtime-request.respond" && command.attachmentsByQuestionId) { const config = yield* ServerConfig.ServerConfig; @@ -207,9 +258,14 @@ export const sendToThread = Effect.fn("ThreadMessageIntake.sendToThread")(functi }); export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(function* ( - input: ThreadLaunch.ThreadLaunchInput, + requested: ThreadLaunch.ThreadLaunchInput, ) { const launches = yield* ThreadLaunch.ThreadLaunchService; + const choose = yield* readAccountChoice(); + const input = + choose === null + ? requested + : { ...requested, modelSelection: choose(requested.modelSelection, requested.projectId) }; yield* AttachmentClaims.validateAttachmentLimits(input.initialMessage?.attachments ?? []); if (!input.initialMessage?.attachments.some(AttachmentClaims.attachmentIsPendingUpload)) { return yield* launches.launch(input); diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts index 430018adcb06a..aef259b8b7813 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts @@ -1,5 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; -import { RunId, ThreadId, type OrchestrationV2DomainEvent } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, + type OrchestrationV2DomainEvent, + type ServerProvider, +} from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -8,13 +17,14 @@ import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; -import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; +import { limitRecoveryCommand, limitSwitchCommand } from "./UsageLimitRecoveryWorker.ts"; it.effect("idle sweeps skip SQL until the next wake time or a relevant event", () => Effect.gen(function* () { @@ -39,6 +49,7 @@ it.effect("idle sweeps skip SQL until the next wake time or a relevant event", ( ), }), Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(Orchestrator.OrchestratorV2)({ streamDomainEvents: Stream.fromQueue(events), }), @@ -77,6 +88,11 @@ const failedThread = (failedAgoMs: number, resetAgoMs: number) => { const failedAt = DateTime.makeUnsafe(nowMs - failedAgoMs); const candidate: ProjectionStore.ProjectionLimitRecoveryCandidate = { id: ThreadId.make("thread:limited"), + projectId: ProjectId.make("project:limited"), + modelSelection: { + instanceId: ProviderInstanceId.make("claude_work"), + model: "claude-opus-5-5", + }, status: "failed", lastErrorClass: "usage_limit", usageLimitResetAt: DateTime.formatIso(DateTime.makeUnsafe(nowMs - resetAgoMs)), @@ -116,3 +132,67 @@ describe("limitRecoveryCommand arming", () => { assert.isNull(limitRecoveryCommand(thread, true, nowMs)); }); }); + +describe("limitSwitchCommand", () => { + const claude = (id: string, usedPercent: number): ServerProvider => ({ + instanceId: ProviderInstanceId.make(id), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-19T11:00:00.000Z", + models: [{ slug: "claude-opus-5-5", name: "Opus 5.5", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-10-19T11:00:00.000Z", + windows: [{ id: "five_hour", kind: "session", label: "Session", usedPercent }], + }, + }); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + fallbackToAvailableAccounts: true, + providerInstances: { + [ProviderInstanceId.make("claude_work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + }, + [ProviderInstanceId.make("claude_personal")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + }, + }, + }; + // The work account's bars are stale: the turn that just failed is the proof it is spent. + const providers = [claude("claude_work", 40), claude("claude_personal", 20)]; + const input = { providers, settings, nowMs, limitedUntil: new Map() }; + const justLimited = failedThread(60_000, -HOUR_MS); + + it("continues the same model on another account with usage left", () => { + assert.deepInclude(limitSwitchCommand(justLimited, input), { + type: "message.dispatch", + manualContinuationOfRunId: RunId.make("run:limited"), + modelSelection: { + instanceId: ProviderInstanceId.make("claude_personal"), + model: "claude-opus-5-5", + }, + }); + }); + + it.each([ + ["the setting is off", justLimited, { ...input, settings: DEFAULT_SERVER_SETTINGS }], + ["the limit was hit long ago", failedThread(HOUR_MS, -HOUR_MS), input], + [ + "the other account hit its own limit here and has not reset", + justLimited, + { + ...input, + limitedUntil: new Map([[ProviderInstanceId.make("claude_personal"), nowMs + HOUR_MS]]), + }, + ], + ])("waits when %s", (_name, thread, options) => { + assert.isNull(limitSwitchCommand(thread, options)); + }); +}); diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index e46ac34c8be61..4965b3f89352a 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -1,4 +1,12 @@ -import { CommandId, MessageId, type OrchestrationV2Command } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + type OrchestrationV2Command, + type ProviderInstanceId, + type ServerProvider, + type ServerSettings as ServerSettingsValue, +} from "@t3tools/contracts"; +import { availableAccountSelection } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,6 +14,7 @@ import * as Stream from "effect/Stream"; import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; import * as DueHint from "../scheduling/DueHint.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import { forkParked } from "../serverActivation.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -13,6 +22,61 @@ import * as Orchestrator from "./Orchestrator.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; const MAX_OVERDUE_ARM_MS = 24 * 60 * 60 * 1000; +// Only limits hit this recently move accounts, so turning the setting on never wakes old threads. +const ACCOUNT_SWITCH_WINDOW_MS = 10 * 60 * 1000; + +/** Continues a thread that just hit a usage limit on another account with usage left. */ +export function limitSwitchCommand( + thread: ProjectionStore.ProjectionLimitRecoveryCandidate, + input: { + readonly providers: ReadonlyArray; + readonly settings: ServerSettingsValue; + readonly nowMs: number; + /** Accounts that hit a limit here until the given reset, whatever their stale bars say. */ + readonly limitedUntil: ReadonlyMap; + }, +): OrchestrationV2Command | null { + if ( + thread.status !== "failed" || + thread.lastErrorClass !== "usage_limit" || + !thread.latestRunId || + thread.archivedAt !== null || + thread.settledOverride === "settled" || + thread.pendingRuntimeRequest !== null || + input.nowMs - DateTime.toEpochMillis(thread.latestRunCompletedAt ?? thread.updatedAt) > + ACCOUNT_SWITCH_WINDOW_MS || + (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > input.nowMs) + ) + return null; + const selection = thread.modelSelection; + const next = availableAccountSelection({ + selection, + providers: input.providers.filter( + (provider) => + provider.instanceId === selection.instanceId || + (input.limitedUntil.get(provider.instanceId) ?? 0) <= input.nowMs, + ), + now: input.nowMs, + settings: input.settings, + projectId: thread.projectId, + exhausted: true, + }); + if (next === null) return null; + const identity = `${thread.id}:${thread.latestRunId}`; + return { + type: "message.dispatch", + commandId: CommandId.make(`limit-switch:${identity}`), + messageId: MessageId.make(`limit-switch:${identity}`), + threadId: thread.id, + manualContinuationOfRunId: thread.latestRunId, + modelSelection: next, + text: "Continue where you left off.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "server", + }; +} export function limitRecoveryCommand( thread: ProjectionStore.ProjectionLimitRecoveryCandidate, @@ -92,6 +156,10 @@ const makeSweep = Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; const orchestrator = yield* Orchestrator.OrchestratorV2; const settings = yield* ServerSettings.ServerSettingsService; + const registry = yield* ProviderRegistry.ProviderRegistry; + const limitedUntil = new Map(); + // A rejected switch is not retried every tick; the thread falls back to waiting for the reset. + const attemptedSwitches = new Set(); const due = DueHint.make(); yield* forkParked( Stream.runForEach(orchestrator.streamDomainEvents, (event) => @@ -109,9 +177,34 @@ const makeSweep = Effect.gen(function* () { now, autoResume: preferences.autoResumeLimitedThreads, snooze: preferences.snoozeLimitedThreads, + ...(preferences.fallbackToAvailableAccounts + ? { switchAccountsSince: DateTime.makeUnsafe(nowMs - ACCOUNT_SWITCH_WINDOW_MS) } + : {}), }); + const providers = preferences.fallbackToAvailableAccounts ? yield* registry.getProviders : []; let acted = false; for (const thread of candidates) { + const switchCommand = preferences.fallbackToAvailableAccounts + ? limitSwitchCommand(thread, { providers, settings: preferences, nowMs, limitedUntil }) + : null; + if (switchCommand !== null && !attemptedSwitches.has(switchCommand.commandId)) { + attemptedSwitches.add(switchCommand.commandId); + acted = true; + const resetMs = Date.parse(thread.usageLimitResetAt ?? ""); + limitedUntil.set( + thread.modelSelection.instanceId, + Number.isFinite(resetMs) ? resetMs : nowMs + ACCOUNT_SWITCH_WINDOW_MS, + ); + yield* threads.dispatch(switchCommand).pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.limit-recovery.account-switch-failed", { + threadId: thread.id, + cause, + }), + ), + ); + continue; + } const command = limitRecoveryCommand( thread, preferences.autoResumeLimitedThreads, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index abc7c71f4b8b2..36d40d6e40e28 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1277,6 +1277,7 @@ export const layerWsRpc = ( | FileSystem.FileSystem | ServerConfig.ServerConfig | ServerSettings.ServerSettingsService + | ProviderRegistry.ProviderRegistry | DocumentText.DocumentText >(); const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 55d951d1bdf80..42bf58a27f45d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2577,6 +2577,9 @@ export default function ChatView(props: ChatViewProps) { const fallbackWindow = fallback ? `${fallback.provider.instanceId}:${fallback.resetsAt ?? ""}` : null; + const fallbackProvider = providerStatuses.find( + (provider) => provider.instanceId === fallbackSelection?.instanceId, + ); const fallbackItems: ComposerBannerStackItem[] = fallback && fallbackSelection && @@ -2585,10 +2588,12 @@ export default function ChatView(props: ChatViewProps) { ? [ modelFallbackBannerItem({ fallbackModelName: - providerStatuses - .find((provider) => provider.instanceId === fallbackSelection.instanceId) - ?.models.find((model) => model.slug === fallbackSelection.model)?.name ?? - fallbackSelection.model, + fallbackProvider?.models.find((model) => model.slug === fallbackSelection.model) + ?.name ?? fallbackSelection.model, + fallbackProviderName: + fallbackProvider?.displayName ?? + fallbackProvider?.driver ?? + fallbackSelection.instanceId, defaultProviderName: fallback.provider.displayName ?? fallback.provider.driver, defaultModelName: fallback.provider.models.find((model) => model.slug === defaultSelection.model) diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index b46614a8b418d..6aab26765600c 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -71,6 +71,7 @@ export function usageLimitsBannerItem( export function modelFallbackBannerItem({ fallbackModelName, + fallbackProviderName, defaultProviderName, defaultModelName, resetsAt, @@ -79,6 +80,7 @@ export function modelFallbackBannerItem({ onDismiss, }: { readonly fallbackModelName: string; + readonly fallbackProviderName: string; readonly defaultProviderName: string; readonly defaultModelName: string; readonly resetsAt: string | null; @@ -90,7 +92,7 @@ export function modelFallbackBannerItem({ id: "fallback-model", variant: "info", icon: , - title: `Using ${fallbackModelName}`, + title: `Using ${fallbackModelName} on ${fallbackProviderName}`, description: `${defaultProviderName} is out of usage${resetsAt ? ` · resets ${formatUpcomingTimestamp(resetsAt, timestampFormat)}` : ""}`, actions: (