From 538845ac447a515d8add865b7b122045463a2990 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 22:33:08 +0000 Subject: [PATCH 01/23] feat(server,web,mobile): fall back when the default provider is out of usage --- .../features/settings/scheduledTaskDraft.ts | 12 +- .../threads/new-task-flow-provider.tsx | 17 +- apps/server/src/mcp/OrchestratorMcpService.ts | 25 ++- .../src/mcp/toolkits/project/handlers.test.ts | 63 ++++++++ .../src/mcp/toolkits/project/handlers.ts | 16 +- apps/server/src/mcp/toolkits/project/tools.ts | 2 + .../src/serverRuntimeStartup.autoPull.test.ts | 2 + apps/server/src/serverRuntimeStartup.test.ts | 2 + apps/server/src/serverRuntimeStartup.ts | 16 +- apps/server/src/serverSettings.ts | 1 + .../CodexTextGeneration.test.ts | 138 ++++++++++++++++ .../src/textGeneration/TextGeneration.ts | 148 ++++++++++++++---- apps/web/src/components/ChatView.tsx | 63 +++++++- .../src/components/chat/DraftHeroHeadline.tsx | 27 +++- .../settings/ProjectDefaultsSettings.tsx | 63 ++++++++ .../settings/scheduledTasksSettings.logic.ts | 15 +- apps/web/src/hooks/useHandleNewThread.test.ts | 1 + apps/web/src/hooks/useHandleNewThread.ts | 18 ++- docs/user/outside-agents.md | 3 + packages/contracts/src/settings.ts | 4 + packages/shared/src/serverSettings.test.ts | 36 +++++ packages/shared/src/serverSettings.ts | 9 +- packages/shared/src/usageLimits.test.ts | 88 +++++++++++ packages/shared/src/usageLimits.ts | 53 +++++++ 24 files changed, 771 insertions(+), 51 deletions(-) diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 3ef1a2f3faf6e..dcf153dc001b3 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -27,6 +27,8 @@ import { resolveNewTaskModelSelection, } from "../../lib/modelOptions"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; + export function scheduledTaskDefaultModel( config: ServerConfig | null, project: (LegacyProjectSettingsFields & { readonly id: ProjectId }) | null, @@ -37,7 +39,7 @@ export function scheduledTaskDefaultModel( const projectDefaultSelection = resolveDefaultableModelSelection(config, configured, project?.id ?? null) ?? resolveDefaultableModelSelection(config, settings.defaultModelSelection, project?.id ?? null); - return resolveNewTaskModelSelection({ + const defaultSelection = resolveNewTaskModelSelection({ draftSelection: null, projectDefaultSelection, stickySelection: null, @@ -48,6 +50,14 @@ export function scheduledTaskDefaultModel( project?.id ?? null, ), }); + return resolveNewThreadModelSelection({ + defaultSelection, + fallbackSelection: settings.fallbackModelSelection, + providers: config?.providers ?? [], + settings, + projectId: project?.id ?? null, + now: Date.now(), + }).selection; } export type ScheduleDraft = { diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 87e2c5254dbc8..b02ae500273c5 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,4 +1,6 @@ import { ActiveNewTaskContext } from "../../state/active-new-task"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { useNowMinuteMs } from "../../lib/clock"; import * as Dialog from "../../components/ui/Dialog"; import React, { useEffect, useRef, useState } from "react"; @@ -481,6 +483,7 @@ export function NewTaskFlowProvider( selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); + const modelSelectionNow = useNowMinuteMs(); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, @@ -499,12 +502,24 @@ export function NewTaskFlowProvider( selectedProject?.id ?? null, ); - const selectedModel = resolveNewTaskModelSelection({ + const selectedDefaultModel = resolveNewTaskModelSelection({ draftSelection: draftModelSelection, projectDefaultSelection: projectDefaultModelSelection, stickySelection: stickyModelSelection, modelOptions, }); + const selectedModel = resolveNewThreadModelSelection({ + defaultSelection: selectedDefaultModel, + explicitSelection: + draftModelSelection ?? (projectDefaultModelSelection ? null : stickyModelSelection), + fallbackSelection: selectedEnvironmentServerConfig?.settings.fallbackModelSelection ?? null, + providers: selectedEnvironmentServerConfig?.providers ?? [], + ...(selectedEnvironmentServerConfig + ? { settings: selectedEnvironmentServerConfig.settings } + : {}), + projectId: selectedProject?.id ?? null, + now: modelSelectionNow, + }).selection; const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index ea9e8a055641b..e95b723bee961 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,3 +1,5 @@ +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, type RunId, @@ -979,17 +981,30 @@ const make = Effect.gen(function* () { ), ); - const projectDefaultModelSelection = ( - project: Effect.Success>, - ) => - project.defaultModelSelection === null + const projectDefaultModelSelection = Effect.fn( + "OrchestratorMcpService.projectDefaultModelSelection", + )(function* (project: Effect.Success>) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), + ); + const selection = resolveNewThreadModelSelection({ + defaultSelection: resolveProjectSettings(settings, project.id, project).settings + .defaultModelSelection, + fallbackSelection: settings.fallbackModelSelection, + providers: yield* providerRegistry.getProviders, + settings, + projectId: project.id, + now: DateTime.toEpochMillis(yield* DateTime.now), + }).selection; + return yield* selection === null ? Effect.fail( failure( "invalid_request", `Project ${project.id} has no default model, so this caller cannot pick one for it.`, ), ) - : Effect.succeed(project.defaultModelSelection); + : Effect.succeed(selection); + }); const secretRequests = yield* SecretRequests.SecretRequests; const requireCapability = (scope: McpInvocationScope) => diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 3e9fe6a21f93c..5e8d615888e0c 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -1,8 +1,11 @@ +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProjectId, ProviderInstanceId, + ProviderDriverKind, + type ServerProvider, type Project as ProjectRecord, type ModelSelection, type RuntimeMode, @@ -37,6 +40,8 @@ import { ProjectToolkit } from "./tools.ts"; const clientLaunchHarness = (input: { readonly runtimeModeCeiling: RuntimeMode; readonly defaultRuntimeMode?: RuntimeMode; + readonly fallbackModel?: ModelSelection; + readonly providers?: readonly ServerProvider[]; /** Where Settings saves a project's model; the legacy project field stays null. */ readonly overrideModel?: ModelSelection; readonly launched: Array; @@ -53,7 +58,11 @@ const clientLaunchHarness = (input: { model: input.overrideModel === undefined ? modelSelection : null, }; const layerDependencies = Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(input.providers ?? []), + }), ServerSettings.layerTest({ + fallbackModelSelection: input.fallbackModel ?? null, ...(input.defaultRuntimeMode === undefined ? {} : { defaultRuntimeMode: input.defaultRuntimeMode }), @@ -463,3 +472,57 @@ it.effect("a retried worktree launch replays after its base ref is gone", () => ]); }), ); + +it.effect.each([false, true])( + "a client only replaces a limited default, preserving an explicit model (%s)", + (explicit) => + Effect.gen(function* () { + const launched: ThreadLaunch.ThreadLaunchInput[] = []; + const fallbackModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const providers: ServerProvider[] = ["claudeAgent", "codex"].map((driver) => ({ + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(driver === "claudeAgent" + ? { + usageLimits: { + checkedAt: "2026-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + })); + const { projectId, modelSelection, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched, + fallbackModel, + providers, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(dependencies), + ), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", { + projectId, + title: "Fallback", + ...(explicit ? { modelSelection } : {}), + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + expect(result.at(-1)?.result).toMatchObject({ + modelSelection: explicit ? modelSelection : fallbackModel, + }); + expect(launched[0]?.modelSelection).toEqual(explicit ? modelSelection : fallbackModel); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index f5a67ae025a85..e7f33b40755e9 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -18,6 +18,9 @@ import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.t import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; +import * as DateTime from "effect/DateTime"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; @@ -181,7 +184,18 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { input.modelSelection ?? launchedModel ?? caller?.modelSelection ?? - projectDefaults?.defaultModelSelection ?? + (projectDefaults + ? resolveNewThreadModelSelection({ + defaultSelection: projectDefaults.defaultModelSelection, + fallbackSelection: projectDefaults.fallbackModelSelection, + providers: yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + ), + settings: projectDefaults, + projectId, + now: DateTime.toEpochMillis(yield* DateTime.now), + }).selection + : null) ?? undefined; if (modelSelection === undefined) return yield* new OrchestratorMcpFailure({ diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 38931596868b4..3ea5a8ea9e48f 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -23,6 +23,7 @@ import { import * as FileSystem from "effect/FileSystem"; import * as DocumentText from "../../../provider/DocumentText.ts"; import * as ServerConfig from "../../../config.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as Crypto from "effect/Crypto"; @@ -166,6 +167,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { FileSystem.FileSystem, ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, + ProviderRegistry.ProviderRegistry, DocumentText.DocumentText, ], }) diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 59f6b67f04509..cdef8084ba4a6 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -30,6 +30,7 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { ServerActivation } from "./serverActivation.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -72,6 +73,7 @@ it.effect("parks automatic pull until activation without delaying command readin const dependencies: Layer.Layer< Layer.Services> > = Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ServerConfig.ServerConfig)({ ...(yield* ServerConfig.deriveServerPaths(cwd, undefined).pipe( Effect.provide(Path.layer), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 274b149c66efc..b66a8c9d11128 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,3 +1,4 @@ +import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { @@ -69,6 +70,7 @@ it.effect.each([ }; const imports = { importedThreadCount: 0, importedMessageCount: 0 }; const dependencies = Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), NodeCrypto.layer, Path.layer, Layer.mock(ServerConfig.ServerConfig)({ diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index d8d62dc029c24..ac0662c13c050 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -10,6 +10,8 @@ import { ThreadId, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -293,8 +295,18 @@ const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { projectId: project.id, title: "New thread", modelSelection: - resolveProjectSettings(settings, project.id, project).settings.defaultModelSelection ?? - threadModelSelection, + resolveNewThreadModelSelection({ + defaultSelection: + resolveProjectSettings(settings, project.id, project).settings + .defaultModelSelection ?? threadModelSelection, + fallbackSelection: settings.fallbackModelSelection, + providers: yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + ), + settings, + projectId: project.id, + now: DateTime.toEpochMillis(yield* DateTime.now), + }).selection ?? threadModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: resolveProjectSettings(settings, project.id, project).settings .defaultRuntimeMode, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 3131c84d880e7..a1e0d34c7dd53 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -435,6 +435,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "sourceControlWriterModelSelection", "reviewGuideModelSelection", "textGenerationModelSelection", + "fallbackModelSelection", "pullRequestMergeMethod", ]); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index a83e09a4110df..b3431bb071063 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,3 +1,15 @@ +import { + DEFAULT_SERVER_SETTINGS, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, + ProviderDriverKind, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -433,3 +445,129 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); }); + +it.effect.each(["usage-error", "preflight", "ordinary-error", "retry-fails"] as const)( + "text generation fallback: %s", + (scenario) => + Effect.gen(function* () { + const primary = ProviderInstanceId.make("codex"); + const preferred = ProviderInstanceId.make("claude_work"); + const calls: string[] = []; + const snapshots: ServerProvider[] = [ + { + instanceId: primary, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: "1970-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(scenario === "preflight" + ? { + usageLimits: { + checkedAt: "1970-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + }, + { + instanceId: preferred, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: "1970-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + }, + ]; + const instances: ProviderInstance[] = snapshots.map((snapshot) => ({ + instanceId: snapshot.instanceId, + driverKind: snapshot.driver, + continuationIdentity: { driverKind: snapshot.driver, continuationKey: snapshot.instanceId }, + displayName: undefined, + enabled: true, + snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"], + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: TextGeneration.TextGeneration.of({ + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateBranchName: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + generateThreadTitle: (input) => + Effect.gen(function* () { + calls.push(input.modelSelection.instanceId); + if (snapshot.instanceId === primary || scenario === "retry-fails") + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: + scenario === "ordinary-error" + ? "invalid credentials" + : "You've hit your usage limit", + }); + expect(input.modelSelection.model).toBe( + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], + ); + expect(input.modelSelection.options).toBeUndefined(); + return { title: "Fallback title" }; + }), + }), + })); + const service = yield* TextGeneration.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + listInstances: Effect.succeed(instances), + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(snapshots), + }), + Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + fallbackModelSelection: { instanceId: preferred, model: "chat-model" }, + providerInstances: { + [preferred]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, + }, + }), + }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => Effect.die("unused"), + }), + ), + ), + ); + const result = yield* service + .generateThreadTitle({ + cwd: "/tmp", + message: "hello", + linkedContext: "", + modelSelection: { + instanceId: primary, + model: "chat-model", + options: [{ id: "effort", value: "high" }], + }, + }) + .pipe(Effect.result); + expect(calls).toEqual( + scenario === "preflight" + ? [preferred] + : scenario === "ordinary-error" + ? [primary] + : [primary, preferred], + ); + expect(result._tag).toBe( + scenario === "ordinary-error" || scenario === "retry-fails" ? "Failure" : "Success", + ); + }), +); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 96f266c6f8959..0780532649d50 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,12 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { isProviderAvailable, isProviderTextGenerationCapable } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; +import { fallbackTextGenerationModelSelection } from "@t3tools/shared/serverSettings"; +import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -56,44 +61,133 @@ const resolveInstance = ( export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const generate = Effect.fn("TextGeneration.generate")(function* ( + operation: TextGenerationOp, + selection: ModelSelection, + invoke: ( + service: ProviderTextGeneration, + modelSelection: ModelSelection, + ) => Effect.Effect, + ) { + const snapshots = yield* registry.listInstances.pipe( + Effect.flatMap((instances) => + Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot), + ), + ); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const limited = exhaustedUsageWindow( + snapshots.find((provider) => provider.instanceId === selection.instanceId), + now, + ); + const call = (modelSelection: ModelSelection) => + resolveInstance(registry, operation, modelSelection.instanceId).pipe( + Effect.flatMap((service) => invoke(service, modelSelection)), + ); + const retry = Effect.fn("TextGeneration.retryLimitedProvider")(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Could not read fallback model settings.", + cause, + }), + ), + ); + const fallback = fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId) => { + const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); + return ( + instanceId !== selection.instanceId && + provider?.enabled === true && + provider.installed && + isProviderAvailable(provider) && + isProviderTextGenerationCapable(provider) && + !exhaustedUsageWindow(provider, now) + ); + }, + { + includeConfiguredInstances: true, + ...(settings.fallbackModelSelection + ? { preferredInstanceId: settings.fallbackModelSelection.instanceId } + : {}), + }, + ); + return fallback; + }); + if (limited) { + const fallback = yield* retry(); + if (fallback) return yield* call(fallback); + return yield* call(selection); + } + return yield* call(selection).pipe( + Effect.catchTags({ + TextGenerationError: Effect.fn("TextGeneration.handleFailure")(function* (error) { + let cause: unknown = error; + let usageLimited = false; + for (let depth = 0; depth < 8 && cause && typeof cause === "object"; depth++) { + const failure = cause as { + class?: unknown; + code?: unknown; + detail?: unknown; + message?: unknown; + cause?: unknown; + }; + usageLimited ||= + failure.class === "usage_limit" || + [failure.code, failure.detail, failure.message].some( + (text) => + typeof text === "string" && + /usage[_ -]?limit|rate[_ -]?limit|quota.{0,30}(?:exceed|exhaust)|(?:exceed|exhaust).{0,30}quota|out of (?:usage|credits)|(?:you've hit|reached) your limit|too many requests/i.test( + text, + ), + ); + cause = failure.cause; + } + if (!usageLimited) return yield* error; + const fallback = yield* retry(); + return fallback ? yield* call(fallback) : yield* error; + }), + }), + ); + }); return TextGeneration.of({ generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { - return resolveInstance( - registry, - "generateCommitMessage", - input.modelSelection.instanceId, - ).pipe(Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input))); + return generate("generateCommitMessage", input.modelSelection, (service, modelSelection) => + service.generateCommitMessage({ ...input, modelSelection }), + ); }), generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) { - return resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + return generate("generatePrContent", input.modelSelection, (service, modelSelection) => + service.generatePrContent({ ...input, modelSelection }), ); }), generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) { - return resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + return generate("generateBranchName", input.modelSelection, (service, modelSelection) => + service.generateBranchName({ ...input, modelSelection }), ); }), generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) { - return resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateReviewGuide(input)), + return generate("generateReviewGuide", input.modelSelection, (service, modelSelection) => + service.generateReviewGuide({ ...input, modelSelection }), ); }), - generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function (input) { - return resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => - Effect.gen(function* () { - const linkedContext = - input.linkedContext ?? - (yield* ThreadTitleLinks.resolveThreadTitleLinks(input).pipe( - Effect.provideService( - SourceControlProviderRegistry.SourceControlProviderRegistry, - sourceControl, - ), - )); - return yield* textGeneration.generateThreadTitle({ ...input, linkedContext }); - }), - ), + generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function* (input) { + const linkedContext = + input.linkedContext ?? + (yield* ThreadTitleLinks.resolveThreadTitleLinks(input).pipe( + Effect.provideService( + SourceControlProviderRegistry.SourceControlProviderRegistry, + sourceControl, + ), + )); + return yield* generate( + "generateThreadTitle", + input.modelSelection, + (service, modelSelection) => + service.generateThreadTitle({ ...input, linkedContext, modelSelection }), ); }), }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5b44cc0b247a2..666698513aab0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,4 +1,5 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { queuedRunEditBlockedReason } from "@t3tools/client-runtime/queued-run-edit"; import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; @@ -186,6 +187,7 @@ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveDefaultProviderModelSelection, shouldShowInstanceBadge, sortProviderInstanceEntries, } from "../providerInstances"; @@ -585,6 +587,9 @@ export default function ChatView(props: ChatViewProps) { ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null) : null; }); + const composerModelSelectionExplicit = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.modelSelectionExplicit === true, + ); const composerHasAttachments = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; @@ -755,15 +760,26 @@ export default function ChatView(props: ChatViewProps) { }); } } + const nowMinute = useNowMinute(); const localDraftThread = draftThread ? buildLocalDraftThread( threadId, draftThread, - resolveProjectSettings( + resolveNewThreadModelSelection({ + defaultSelection: resolveDefaultProviderModelSelection( + providersByEnvironment.get(draftThread.environmentId) ?? [], + resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection, + ), + fallbackSelection: settings.fallbackModelSelection, + providers: providersByEnvironment.get(draftThread.environmentId) ?? [], settings, - fallbackDraftProject?.id ?? null, - fallbackDraftProject ?? undefined, - ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + projectId: draftThread.projectId, + now: Date.parse(`${nowMinute}:00Z`), + }).selection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined; const isServerThread = serverThread !== null; @@ -1055,7 +1071,20 @@ export default function ChatView(props: ChatViewProps) { const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; const { projectCloneBannerItem, projectCloneSendBlockReason } = useProjectCloneBanner(activeProjectRef); - const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; + const defaultModelResolution = resolveNewThreadModelSelection({ + defaultSelection: resolveDefaultProviderModelSelection( + providersByEnvironment.get(activeThread?.environmentId ?? environmentId) ?? [], + activeProjectSettings.settings.defaultModelSelection, + ), + fallbackSelection: settings.fallbackModelSelection, + providers: providersByEnvironment.get(activeThread?.environmentId ?? environmentId) ?? [], + settings, + projectId: activeProject?.id ?? null, + now: Date.parse(`${nowMinute}:00Z`), + }); + const activeProjectDefaultModelSelection = isServerThread + ? activeProjectSettings.settings.defaultModelSelection + : defaultModelResolution.selection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); }; @@ -2360,7 +2389,6 @@ export default function ChatView(props: ChatViewProps) { closeRightPanelSurface, }); const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; - const nowMinute = useNowMinute(); const { acknowledgeActiveThreadWoke, activeBranchMismatchKey, @@ -2529,12 +2557,34 @@ export default function ChatView(props: ChatViewProps) { }) : null; const composerBannerItems = (() => { + const fallback = + !isServerThread && + !composerModelSelectionExplicit && + defaultModelResolution.fallback && + (!composerSelectedModelSelection || + (composerSelectedModelSelection.instanceId === + defaultModelResolution.selection?.instanceId && + composerSelectedModelSelection.model === defaultModelResolution.selection?.model)) + ? defaultModelResolution.fallback + : null; + const fallbackItems: ComposerBannerStackItem[] = + fallback && defaultModelResolution.selection + ? [ + { + id: "fallback-model", + variant: "info", + icon: null, + title: `${fallback.provider.displayName ?? fallback.provider.driver} is out of usage${fallback.resetsAt ? ` until ${new Date(fallback.resetsAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}` : ""}. Using ${providerStatuses.find((provider) => provider.instanceId === defaultModelResolution.selection?.instanceId)?.models.find((model) => model.slug === defaultModelResolution.selection?.model)?.name ?? defaultModelResolution.selection.model}.`, + }, + ] + : []; const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, @@ -2543,6 +2593,7 @@ export default function ChatView(props: ChatViewProps) { } return [ ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 46fed2ddc002e..b811dc9eb7c54 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -24,7 +24,11 @@ import { projectGroupsSpanEnvironments, } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; -import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { + useEnvironments, + usePrimaryEnvironmentId, + useEnvironmentProviders, +} from "~/state/environments"; import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; @@ -43,6 +47,8 @@ import { InlineButton } from "../ui/button"; import { DraftProjectName } from "./DraftProjectName"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveDefaultProviderModelSelection } from "../../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; const ADD_PROJECT_VALUE = "add-project"; @@ -65,6 +71,7 @@ export function DraftHeroHeadline({ const projects = useProjects(); const threads = useThreadShells(); const { environments } = useEnvironments(); + const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); @@ -188,13 +195,25 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - const environmentSettings = environments.find( + const environmentConfig = environments.find( (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings + )?.serverConfig; + const environmentSettings = environmentConfig?.settings; + const defaultSelection = environmentSettings ? resolveProjectSettings(environmentSettings, project.id, project).settings .defaultModelSelection : project.defaultModelSelection; + const defaultModelSelection = resolveNewThreadModelSelection({ + defaultSelection: resolveDefaultProviderModelSelection( + providersByEnvironment.get(project.environmentId) ?? [], + defaultSelection, + ), + fallbackSelection: environmentSettings?.fallbackModelSelection ?? null, + providers: providersByEnvironment.get(project.environmentId) ?? [], + ...(environmentSettings ? { settings: environmentSettings } : {}), + projectId: project.id, + now: Date.now(), + }).selection; if (defaultModelSelection) { setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index b03ec8678b81b..f9c939dec6fc0 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -86,6 +86,14 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ); const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); + const mixedFallback = useScopedSettingsMixed(["fallbackModelSelection"]); + const fallbackSelection = settings.fallbackModelSelection ?? selection; + const fallbackModelOptions = getCustomModelOptionsByInstance( + settings, + providers, + fallbackSelection?.instanceId, + fallbackSelection?.model, + ); const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]); const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon; const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); @@ -252,6 +260,59 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting /> ); + const fallbackRow = !isProjectScope ? ( + updateSettings({ fallbackModelSelection: null })} + /> + ) : null + } + control={ + fallbackSelection ? ( + { + const reason = modelDisabledReason(instanceId, model); + if (reason) { + toastManager.add({ + type: "error", + title: "Fallback model not saved", + description: reason, + }); + return; + } + updateSettings({ fallbackModelSelection: createModelSelection(instanceId, model) }); + }} + /> + ) : ( + No providers available + ) + } + /> + ) : null; + return ( {modelRow} + {fallbackRow} {workspaceRow} ) : category === "general" ? ( <> {modelRow} + {fallbackRow} + resolveNewThreadModelSelection({ + defaultSelection, + fallbackSelection: settings.fallbackModelSelection, + providers: entries.map((entry) => entry.snapshot), + settings, + projectId: project?.id ?? null, + now: Date.now(), + }).selection; const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings .defaultModelSelection; for (const selection of [configured, settings.defaultModelSelection]) { @@ -275,7 +286,7 @@ export function scheduledTaskDefaultModel( entry.models.find((model) => model.slug === selection.model)?.isLegacy !== true, ) ) - return selection; + return resolve(selection); } const models = available.flatMap((entry) => entry.models @@ -283,5 +294,5 @@ export function scheduledTaskDefaultModel( .map((model) => ({ instanceId: entry.instanceId, model })), ); const fallback = models.find(({ model }) => model.isDefault) ?? models[0]; - return fallback ? { instanceId: fallback.instanceId, model: fallback.model.slug } : null; + return resolve(fallback ? { instanceId: fallback.instanceId, model: fallback.model.slug } : null); } diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index ac18db8d7c561..03a61235a2595 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -171,6 +171,7 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ hasExplicitComposerModelSelection: () => false, resolveNewThreadModelSelectionOverride: () => null, })); +vi.mock("../providerInstances", () => ({ resolveDefaultProviderModelSelection: () => null })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ readT3ProjectFile: () => testState.projectFileRead, })); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 90a45540b242f..b07ee3f9c5478 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -44,6 +44,8 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { getClientSettings } from "./useSettings"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; +import { resolveDefaultProviderModelSelection } from "../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -135,7 +137,21 @@ export function createNewThreadHandler(router: AnyRouter) { project?.id ?? null, project, ); - const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; + const projectDefaultModelSelection = resolveNewThreadModelSelection({ + defaultSelection: + projectSettings.settings.defaultModelSelection ?? + (carryModelSelection + ? null + : resolveDefaultProviderModelSelection( + environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + null, + )), + fallbackSelection: targetServerSettings.fallbackModelSelection ?? null, + providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + settings: targetServerSettings, + projectId: projectRef.projectId, + now: Date.now(), + }).selection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index f040f2218058d..80a2c72cf7419 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -110,6 +110,9 @@ runs in the project folder unless you pass `--worktree `. `send` never interrupts: a busy thread gets the message as its next turn. Pass `-` as the message to read it from stdin. +Set a fallback model in Settings > Threads to use it for new threads when the +default provider is out of usage. This environment setting also applies to project defaults. + `--wait` prints the agent's reply. It exits 1 when the run fails or stops, and 2 when the agent waits for an answer you give in the app. Add `--json` for a result that always includes the thread id. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 87f6c43439ff3..675e90a53d1e1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1147,6 +1147,9 @@ export const ServerSettings = Schema.Struct({ defaultModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + fallbackModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), defaultRuntimeMode: RuntimeMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), @@ -1499,6 +1502,7 @@ export const ServerSettingsPatch = Schema.Struct({ ), defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), + fallbackModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), /** Replaces each project's override set; null removes it. Omitted providerInstances retains its stored value. */ projectSettingsOverrides: Schema.optionalKey( Schema.Record( diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 76dc352e96b67..025d0ed9e060d 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -26,6 +27,41 @@ import { const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { + it("prefers the configured fallback instance with its driver's text generation model", () => { + const preferred = ProviderInstanceId.make("claude_work"); + expect( + fallbackTextGenerationModelSelection( + { [preferred]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} } }, + () => true, + { includeConfiguredInstances: true, preferredInstanceId: preferred }, + ), + ).toEqual({ + instanceId: preferred, + model: DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], + }); + }); + it("replaces and clears the fallback without merging the previous model's options", () => { + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + fallbackModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "first", + options: [{ id: "effort", value: "high" }], + }, + }); + const second = applyServerSettingsPatch(first, { + fallbackModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "second", + }, + }); + expect(second.fallbackModelSelection).toEqual({ + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "second", + }); + expect( + applyServerSettingsPatch(second, { fallbackModelSelection: null }).fallbackModelSelection, + ).toBeNull(); + }); it("ignores a configured non-default instance at machine scope, matching no fallback", () => { const configured = ProviderInstanceId.make("codex_work"); const providerInstances = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 97f02799c4e0d..bc518ff93f826 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -96,6 +96,7 @@ export function fallbackTextGenerationModelSelection( options?: { readonly includeConfiguredInstances?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly preferredInstanceId?: ProviderInstanceId; }, ): ModelSelection | null { const candidates = [ @@ -110,7 +111,10 @@ export function fallbackTextGenerationModelSelection( ) : []), ]; - const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); + const preferred = candidates.find(([instanceId]) => instanceId === options?.preferredInstanceId); + const match = [...(preferred ? [preferred] : []), ...candidates].find(([instanceId, driver]) => + isEnabled(instanceId, driver), + ); if (match === undefined) return null; const [instanceId, driver] = match; const threadDefault = options?.defaultModelSelection; @@ -567,6 +571,9 @@ export function applyServerSettingsPatch( ...(patch.defaultModelSelection !== undefined ? { defaultModelSelection: patch.defaultModelSelection } : {}), + ...(patch.fallbackModelSelection !== undefined + ? { fallbackModelSelection: patch.fallbackModelSelection } + : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 503521c087d72..b1d19e6c574c8 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -20,6 +20,7 @@ import { paceOf, paceRemainingPercent, usesChatGptSharing, + resolveNewThreadModelSelection, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -50,6 +51,93 @@ function provider(overrides: Partial): ServerProvider { }; } +describe("new thread model fallback", () => { + const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const fallback = { instanceId: ProviderInstanceId.make("claude"), model: "claude-fable-5-1" }; + const limited = provider({ + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...window, usedPercent: 100 }], + }, + }); + const available = provider({ + instanceId: fallback.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + }); + + it("uses an available fallback for an exhausted default", () => { + expect( + resolveNewThreadModelSelection({ + defaultSelection: selection, + fallbackSelection: fallback, + providers: [limited, available], + now, + }).selection, + ).toEqual(fallback); + }); + it("keeps the default without a usable fallback", () => { + for (const candidate of [null, fallback]) { + expect( + resolveNewThreadModelSelection({ + defaultSelection: selection, + fallbackSelection: candidate, + providers: [limited, { ...available, usageLimits: limited.usageLimits }], + now, + }).selection, + ).toEqual(selection); + } + }); + it("keeps explicit selections and ignores expired windows", () => { + expect( + resolveNewThreadModelSelection({ + defaultSelection: selection, + explicitSelection: selection, + fallbackSelection: fallback, + providers: [limited, available], + now, + }).selection, + ).toEqual(selection); + expect( + resolveNewThreadModelSelection({ + defaultSelection: selection, + fallbackSelection: fallback, + providers: [limited, available], + now: Date.parse("2026-09-04T00:00:00Z"), + }).selection, + ).toEqual(selection); + }); + it("treats an unknown reset as exhausted and rejects disabled or unavailable fallbacks", () => { + const unknown = { + ...limited, + usageLimits: { + ...limited.usageLimits!, + windows: [{ ...window, usedPercent: 100, resetsAt: undefined }], + }, + }; + expect( + resolveNewThreadModelSelection({ + defaultSelection: selection, + fallbackSelection: fallback, + providers: [unknown, available], + now, + }).selection, + ).toEqual(fallback); + for (const candidate of [ + { ...available, enabled: false }, + { ...available, installed: false }, + ]) { + expect( + resolveNewThreadModelSelection({ + defaultSelection: selection, + fallbackSelection: fallback, + providers: [unknown, candidate], + now, + }).selection, + ).toEqual(selection); + } + }); +}); + describe("pace", () => { it("places the clock three fifths through a five-hour window with two hours left", () => { expect(elapsedShare(window, now)).toBeCloseTo(0.6); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 01e2ad59a332d..26e97b0c773cf 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1,5 +1,9 @@ import { type EnvironmentId, + type ModelSelection, + type ServerSettings, + type ProjectId, + resolveProjectProviderInstanceEnabled, type UsageLimitsReport, type ProviderInstanceId, type ProviderConsumeResetCreditInput, @@ -17,6 +21,55 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export function exhaustedUsageWindow(provider: ServerProvider | undefined, now: number) { + return provider?.usageLimits?.windows.find( + (window) => + window.usedPercent !== undefined && + window.usedPercent >= 100 && + (!window.resetsAt || + !Number.isFinite(Date.parse(window.resetsAt)) || + Date.parse(window.resetsAt) > now), + ); +} + +export function resolveNewThreadModelSelection(input: { + readonly defaultSelection: ModelSelection | null; + readonly explicitSelection?: ModelSelection | null; + readonly fallbackSelection: ModelSelection | null; + readonly providers: readonly ServerProvider[]; + readonly now: number; + readonly settings?: ServerSettings; + readonly projectId?: ProjectId | null; +}) { + const selection = input.explicitSelection ?? input.defaultSelection; + const unchanged = { selection, fallback: null }; + if (input.explicitSelection || !selection || !input.fallbackSelection) return unchanged; + const provider = input.providers.find((entry) => entry.instanceId === selection.instanceId); + const window = exhaustedUsageWindow(provider, input.now); + const fallback = input.providers.find( + (entry) => entry.instanceId === input.fallbackSelection?.instanceId, + ); + if ( + !provider || + !window || + !fallback?.enabled || + !fallback.installed || + !isProviderAvailable(fallback) || + exhaustedUsageWindow(fallback, input.now) || + (input.settings && + !resolveProjectProviderInstanceEnabled( + input.settings, + input.projectId ?? null, + fallback.instanceId, + )) + ) + return unchanged; + return { + selection: input.fallbackSelection, + fallback: { provider, resetsAt: window.resetsAt ?? null }, + }; +} + export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; export const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"; /** A ChatGPT Business admin caps plan use in other apps; only the admin can raise it. */ From 2b814d70437bfbfb39a348afa099573f0befec51 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 22:49:50 +0000 Subject: [PATCH 02/23] fix(web,shared): keep sticky models and skip signed-out fallbacks --- .../src/components/chat/DraftHeroHeadline.tsx | 19 +++++++++++++------ .../settings/ProjectDefaultsSettings.tsx | 2 +- apps/web/src/hooks/useHandleNewThread.ts | 12 ++++++++---- packages/shared/src/usageLimits.test.ts | 1 + packages/shared/src/usageLimits.ts | 1 + 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index b811dc9eb7c54..fdce87f5d70df 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -203,17 +203,24 @@ export function DraftHeroHeadline({ ? resolveProjectSettings(environmentSettings, project.id, project).settings .defaultModelSelection : project.defaultModelSelection; - const defaultModelSelection = resolveNewThreadModelSelection({ - defaultSelection: resolveDefaultProviderModelSelection( - providersByEnvironment.get(project.environmentId) ?? [], - defaultSelection, - ), + const fallbackResolution = resolveNewThreadModelSelection({ + defaultSelection: + defaultSelection ?? + (useComposerDraftStore.getState().stickyActiveProvider !== null + ? null + : resolveDefaultProviderModelSelection( + providersByEnvironment.get(project.environmentId) ?? [], + null, + )), fallbackSelection: environmentSettings?.fallbackModelSelection ?? null, providers: providersByEnvironment.get(project.environmentId) ?? [], ...(environmentSettings ? { settings: environmentSettings } : {}), projectId: project.id, now: Date.now(), - }).selection; + }); + const defaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : defaultSelection; if (defaultModelSelection) { setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index f9c939dec6fc0..596d468e606a6 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -269,7 +269,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting title="Fallback model" description="Used for new threads when the default model's provider is out of usage." resetAction={ - settings.fallbackModelSelection !== null ? ( + mixedFallback || settings.fallbackModelSelection !== null ? ( updateSettings({ fallbackModelSelection: null })} diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index b07ee3f9c5478..e863b102c5efc 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -137,10 +137,11 @@ export function createNewThreadHandler(router: AnyRouter) { project?.id ?? null, project, ); - const projectDefaultModelSelection = resolveNewThreadModelSelection({ + const configuredDefaultModelSelection = projectSettings.settings.defaultModelSelection ?? null; + const fallbackResolution = resolveNewThreadModelSelection({ defaultSelection: - projectSettings.settings.defaultModelSelection ?? - (carryModelSelection + configuredDefaultModelSelection ?? + (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null ? null : resolveDefaultProviderModelSelection( environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], @@ -151,7 +152,10 @@ export function createNewThreadHandler(router: AnyRouter) { settings: targetServerSettings, projectId: projectRef.projectId, now: Date.now(), - }).selection; + }); + const projectDefaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : configuredDefaultModelSelection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b1d19e6c574c8..f083deaeea7c6 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -125,6 +125,7 @@ describe("new thread model fallback", () => { for (const candidate of [ { ...available, enabled: false }, { ...available, installed: false }, + { ...available, auth: { status: "unauthenticated" as const } }, ]) { expect( resolveNewThreadModelSelection({ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 26e97b0c773cf..66f1bb58f4a76 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -54,6 +54,7 @@ export function resolveNewThreadModelSelection(input: { !window || !fallback?.enabled || !fallback.installed || + fallback.auth.status === "unauthenticated" || !isProviderAvailable(fallback) || exhaustedUsageWindow(fallback, input.now) || (input.settings && From 4010689c9bbb162f8d38e7fac369edcdff156f43 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 22:57:17 +0000 Subject: [PATCH 03/23] fix(shared): only count the usage pools the default model draws from --- .../src/textGeneration/TextGeneration.ts | 1 + packages/shared/src/usageLimits.test.ts | 31 +++++++++++++++ packages/shared/src/usageLimits.ts | 38 +++++++++++++++++-- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 0780532649d50..17e03b86d465a 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -79,6 +79,7 @@ export const make = Effect.gen(function* () { const limited = exhaustedUsageWindow( snapshots.find((provider) => provider.instanceId === selection.instanceId), now, + selection.model, ); const call = (modelSelection: ModelSelection) => resolveInstance(registry, operation, modelSelection.instanceId).pipe( diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index f083deaeea7c6..6d5c4cb5e9a8f 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -137,6 +137,37 @@ describe("new thread model fallback", () => { ).toEqual(selection); } }); + it("only counts the usage pools the default model draws from", () => { + const pools = (driver: string, ids: readonly string[]) => + provider({ + driver: ProviderDriverKind.make(driver), + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: ids.map((id) => ({ ...window, id, usedPercent: 100 })), + }, + }); + const resolve = (limited: ServerProvider, model: string) => + resolveNewThreadModelSelection({ + defaultSelection: { instanceId: limited.instanceId, model }, + fallbackSelection: fallback, + providers: [limited, available], + now, + }).selection?.model; + expect(resolve(pools("cursor", ["autoPercentUsed"]), "claude-opus-5-5")).toBe( + "claude-opus-5-5", + ); + expect(resolve(pools("cursor", ["autoPercentUsed"]), "composer-2.5")).toBe("composer-2.5"); + expect(resolve(pools("cursor", ["autoPercentUsed", "apiPercentUsed"]), "composer-2.5")).toBe( + fallback.model, + ); + expect(resolve(pools("antigravity", ["gemini-weekly"]), "claude-opus-5-5")).toBe( + "claude-opus-5-5", + ); + expect(resolve(pools("antigravity", ["3p-weekly"]), "claude-opus-5-5")).toBe(fallback.model); + expect(resolve(pools("opencode", ["go_weekly"]), "opencode/big-pickle")).toBe( + "opencode/big-pickle", + ); + }); }); describe("pace", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 66f1bb58f4a76..63da0519b7328 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -21,8 +21,12 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; -export function exhaustedUsageWindow(provider: ServerProvider | undefined, now: number) { - return provider?.usageLimits?.windows.find( +export function exhaustedUsageWindow( + provider: ServerProvider | undefined, + now: number, + model?: string, +) { + const exhausted = (provider?.usageLimits?.windows ?? []).filter( (window) => window.usedPercent !== undefined && window.usedPercent >= 100 && @@ -30,6 +34,32 @@ export function exhaustedUsageWindow(provider: ServerProvider | undefined, now: !Number.isFinite(Date.parse(window.resetsAt)) || Date.parse(window.resetsAt) > now), ); + if (model === undefined) return exhausted[0]; + // Some drivers report separate allowances per model family; only the ones this model draws from count. + switch (provider?.driver) { + case "cursor": { + const api = exhausted.find((window) => window.id === "apiPercentUsed"); + const auto = exhausted.find((window) => window.id === "autoPercentUsed"); + return /^(auto|default|grok|composer)/.test(model) ? auto && api : api; + } + case "antigravity": { + const family = model.match(/^(gemini|claude|gpt)-/)?.[1]; + return exhausted.find((window) => { + const pool = window.id.match(/^(gemini|claude|gpt|3p)-/)?.[1]; + return ( + pool === undefined || + pool === family || + (pool === "3p" && (family === "claude" || family === "gpt")) + ); + }); + } + case "opencode": + return exhausted.find( + (window) => !window.id.startsWith("go_") || model.startsWith("opencode-go/"), + ); + default: + return exhausted[0]; + } } export function resolveNewThreadModelSelection(input: { @@ -45,7 +75,7 @@ export function resolveNewThreadModelSelection(input: { const unchanged = { selection, fallback: null }; if (input.explicitSelection || !selection || !input.fallbackSelection) return unchanged; const provider = input.providers.find((entry) => entry.instanceId === selection.instanceId); - const window = exhaustedUsageWindow(provider, input.now); + const window = exhaustedUsageWindow(provider, input.now, selection.model); const fallback = input.providers.find( (entry) => entry.instanceId === input.fallbackSelection?.instanceId, ); @@ -56,7 +86,7 @@ export function resolveNewThreadModelSelection(input: { !fallback.installed || fallback.auth.status === "unauthenticated" || !isProviderAvailable(fallback) || - exhaustedUsageWindow(fallback, input.now) || + exhaustedUsageWindow(fallback, input.now, input.fallbackSelection.model) || (input.settings && !resolveProjectProviderInstanceEnabled( input.settings, From c0a6ed9cdc9a29ea30bbb2bb1613068cefebefb4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 23:00:36 +0000 Subject: [PATCH 04/23] fix(server): skip signed-out providers in the text generation fallback --- apps/server/src/textGeneration/TextGeneration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 17e03b86d465a..7aa740e6cf96e 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -104,6 +104,7 @@ export const make = Effect.gen(function* () { instanceId !== selection.instanceId && provider?.enabled === true && provider.installed && + provider.auth.status !== "unauthenticated" && isProviderAvailable(provider) && isProviderTextGenerationCapable(provider) && !exhaustedUsageWindow(provider, now) From e89c4fa754d61b3490dc1eec4d1a7b72aee90746 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 23:02:40 +0000 Subject: [PATCH 05/23] fix(shared): count every Antigravity pool for models without a known family --- packages/shared/src/usageLimits.test.ts | 3 +++ packages/shared/src/usageLimits.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 6d5c4cb5e9a8f..26dfa7cfd4c43 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -164,6 +164,9 @@ describe("new thread model fallback", () => { "claude-opus-5-5", ); expect(resolve(pools("antigravity", ["3p-weekly"]), "claude-opus-5-5")).toBe(fallback.model); + expect(resolve(pools("antigravity", ["gemini-weekly"]), "antigravity-default")).toBe( + fallback.model, + ); expect(resolve(pools("opencode", ["go_weekly"]), "opencode/big-pickle")).toBe( "opencode/big-pickle", ); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 63da0519b7328..fffb2373a192b 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -48,6 +48,7 @@ export function exhaustedUsageWindow( const pool = window.id.match(/^(gemini|claude|gpt|3p)-/)?.[1]; return ( pool === undefined || + family === undefined || pool === family || (pool === "3p" && (family === "claude" || family === "gpt")) ); From bccbbb200a541b3dfd7af44acd6462710e177566 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 23:07:02 +0000 Subject: [PATCH 06/23] fix(server): check text generation fallbacks against the model they would use --- apps/server/src/textGeneration/TextGeneration.ts | 9 ++++++--- packages/shared/src/serverSettings.ts | 16 +++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 7aa740e6cf96e..dab51e5f0c10e 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -5,7 +5,10 @@ import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import { isProviderAvailable, isProviderTextGenerationCapable } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; -import { fallbackTextGenerationModelSelection } from "@t3tools/shared/serverSettings"; +import { + defaultTextGenerationModel, + fallbackTextGenerationModelSelection, +} from "@t3tools/shared/serverSettings"; import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -98,7 +101,7 @@ export const make = Effect.gen(function* () { ); const fallback = fallbackTextGenerationModelSelection( settings.providerInstances, - (instanceId) => { + (instanceId, driver) => { const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); return ( instanceId !== selection.instanceId && @@ -107,7 +110,7 @@ export const make = Effect.gen(function* () { provider.auth.status !== "unauthenticated" && isProviderAvailable(provider) && isProviderTextGenerationCapable(provider) && - !exhaustedUsageWindow(provider, now) + !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) ); }, { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index bc518ff93f826..d61e5f88d15dd 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -125,13 +125,15 @@ export function fallbackTextGenerationModelSelection( ) { return threadDefault; } - return { - instanceId, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_TEXT_GENERATION_MODEL, - }; + return { instanceId, model: defaultTextGenerationModel(driver) }; +} + +export function defaultTextGenerationModel(driver: ProviderDriverKind): string { + return ( + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL + ); } export function resolveSourceControlWriterModelSelection( From 822d9719ac340d14885bdcc91462d1862b0ffc1d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 23:12:55 +0000 Subject: [PATCH 07/23] fix(server): fall back to Codex or Claude for review guides --- apps/server/src/textGeneration/TextGeneration.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index dab51e5f0c10e..fe9276a97a45a 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -60,6 +60,9 @@ const resolveInstance = ( ), ); +// Review guides investigate the checkout read-only; the other drivers reject that request. +const INVESTIGATING_DRIVERS = new Set(["codex", "claudeAgent"]); + /** @public */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; @@ -110,6 +113,7 @@ export const make = Effect.gen(function* () { provider.auth.status !== "unauthenticated" && isProviderAvailable(provider) && isProviderTextGenerationCapable(provider) && + (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) ); }, From 45b3a821d64b9490e5fea0ac3c9402912f4de079 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 23:33:44 +0000 Subject: [PATCH 08/23] fix(server,web): respect provider enablement and OpenCode quota errors in fallbacks --- .../CodexTextGeneration.test.ts | 249 +++++++++--------- .../src/textGeneration/TextGeneration.ts | 12 +- apps/web/src/components/ChatView.tsx | 60 +++-- .../src/components/chat/DraftHeroHeadline.tsx | 9 +- apps/web/src/hooks/useHandleNewThread.ts | 7 +- apps/web/src/providerInstances.ts | 13 + 6 files changed, 199 insertions(+), 151 deletions(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index b3431bb071063..389954840ddd0 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -446,128 +446,137 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ); }); -it.effect.each(["usage-error", "preflight", "ordinary-error", "retry-fails"] as const)( - "text generation fallback: %s", - (scenario) => - Effect.gen(function* () { - const primary = ProviderInstanceId.make("codex"); - const preferred = ProviderInstanceId.make("claude_work"); - const calls: string[] = []; - const snapshots: ServerProvider[] = [ - { - instanceId: primary, - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated" }, - version: null, - checkedAt: "1970-01-01T00:00:00Z", - models: [], - slashCommands: [], - skills: [], - ...(scenario === "preflight" - ? { - usageLimits: { - checkedAt: "1970-01-01T00:00:00Z", - windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], - }, - } - : {}), - }, - { - instanceId: preferred, - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated" }, - version: null, - checkedAt: "1970-01-01T00:00:00Z", - models: [], - slashCommands: [], - skills: [], - }, - ]; - const instances: ProviderInstance[] = snapshots.map((snapshot) => ({ - instanceId: snapshot.instanceId, - driverKind: snapshot.driver, - continuationIdentity: { driverKind: snapshot.driver, continuationKey: snapshot.instanceId }, - displayName: undefined, +it.effect.each([ + "usage-error", + "typed-quota", + "preflight", + "ordinary-error", + "retry-fails", +] as const)("text generation fallback: %s", (scenario) => + Effect.gen(function* () { + const primary = ProviderInstanceId.make("codex"); + const preferred = ProviderInstanceId.make("claude_work"); + const calls: string[] = []; + const snapshots: ServerProvider[] = [ + { + instanceId: primary, + driver: ProviderDriverKind.make("codex"), enabled: true, - snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"], - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: TextGeneration.TextGeneration.of({ - generateCommitMessage: () => Effect.die("unused"), - generatePrContent: () => Effect.die("unused"), - generateBranchName: () => Effect.die("unused"), - generateReviewGuide: () => Effect.die("unused"), - generateThreadTitle: (input) => - Effect.gen(function* () { - calls.push(input.modelSelection.instanceId); - if (snapshot.instanceId === primary || scenario === "retry-fails") - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: - scenario === "ordinary-error" - ? "invalid credentials" + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: "1970-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(scenario === "preflight" + ? { + usageLimits: { + checkedAt: "1970-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + }, + { + instanceId: preferred, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: "1970-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + }, + ]; + const instances: ProviderInstance[] = snapshots.map((snapshot) => ({ + instanceId: snapshot.instanceId, + driverKind: snapshot.driver, + continuationIdentity: { driverKind: snapshot.driver, continuationKey: snapshot.instanceId }, + displayName: undefined, + enabled: true, + snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"], + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: TextGeneration.TextGeneration.of({ + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateBranchName: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + generateThreadTitle: (input) => + Effect.gen(function* () { + calls.push(input.modelSelection.instanceId); + if (snapshot.instanceId === primary || scenario === "retry-fails") + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: + scenario === "ordinary-error" + ? "invalid credentials" + : scenario === "typed-quota" + ? "Provider request failed" : "You've hit your usage limit", - }); - expect(input.modelSelection.model).toBe( - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], - ); - expect(input.modelSelection.options).toBeUndefined(); - return { title: "Fallback title" }; - }), - }), - })); - const service = yield* TextGeneration.make.pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ - listInstances: Effect.succeed(instances), - getInstance: (id) => - Effect.succeed(instances.find((instance) => instance.instanceId === id)), - }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed(snapshots), - }), - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: Effect.succeed({ - ...DEFAULT_SERVER_SETTINGS, - fallbackModelSelection: { instanceId: preferred, model: "chat-model" }, - providerInstances: { - [preferred]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, - }, - }), - }), - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("unused"), + ...(scenario === "typed-quota" && snapshot.instanceId === primary + ? { cause: { type: "provider.quota", message: "Budget exceeded", status: 429 } } + : {}), + }); + expect(input.modelSelection.model).toBe( + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], + ); + expect(input.modelSelection.options).toBeUndefined(); + return { title: "Fallback title" }; + }), + }), + })); + const service = yield* TextGeneration.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + listInstances: Effect.succeed(instances), + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(snapshots), + }), + Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + fallbackModelSelection: { instanceId: preferred, model: "chat-model" }, + providerInstances: { + [preferred]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, + }, }), - ), + }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => Effect.die("unused"), + }), ), - ); - const result = yield* service - .generateThreadTitle({ - cwd: "/tmp", - message: "hello", - linkedContext: "", - modelSelection: { - instanceId: primary, - model: "chat-model", - options: [{ id: "effort", value: "high" }], - }, - }) - .pipe(Effect.result); - expect(calls).toEqual( - scenario === "preflight" - ? [preferred] - : scenario === "ordinary-error" - ? [primary] - : [primary, preferred], - ); - expect(result._tag).toBe( - scenario === "ordinary-error" || scenario === "retry-fails" ? "Failure" : "Success", - ); - }), + ), + ); + const result = yield* service + .generateThreadTitle({ + cwd: "/tmp", + message: "hello", + linkedContext: "", + modelSelection: { + instanceId: primary, + model: "chat-model", + options: [{ id: "effort", value: "high" }], + }, + }) + .pipe(Effect.result); + expect(calls).toEqual( + scenario === "preflight" + ? [preferred] + : scenario === "ordinary-error" + ? [primary] + : [primary, preferred], + ); + expect(result._tag).toBe( + scenario === "ordinary-error" || scenario === "retry-fails" ? "Failure" : "Success", + ); + }), ); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index fe9276a97a45a..667626f09b184 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -2,7 +2,11 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; -import { isProviderAvailable, isProviderTextGenerationCapable } from "@t3tools/contracts"; +import { + isProviderAvailable, + isProviderTextGenerationCapable, + resolveProjectProviderInstanceEnabled, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; import { @@ -109,6 +113,7 @@ export const make = Effect.gen(function* () { return ( instanceId !== selection.instanceId && provider?.enabled === true && + resolveProjectProviderInstanceEnabled(settings, null, instanceId) && provider.installed && provider.auth.status !== "unauthenticated" && isProviderAvailable(provider) && @@ -139,6 +144,8 @@ export const make = Effect.gen(function* () { for (let depth = 0; depth < 8 && cause && typeof cause === "object"; depth++) { const failure = cause as { class?: unknown; + type?: unknown; + status?: unknown; code?: unknown; detail?: unknown; message?: unknown; @@ -146,6 +153,9 @@ export const make = Effect.gen(function* () { }; usageLimited ||= failure.class === "usage_limit" || + failure.type === "provider.rate-limit" || + // OpenCode also types a never-resetting empty balance as quota, with HTTP 402. + (failure.type === "provider.quota" && failure.status !== 402) || [failure.code, failure.detail, failure.message].some( (text) => typeof text === "string" && diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 666698513aab0..efd31241ae988 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -187,7 +187,7 @@ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, - resolveDefaultProviderModelSelection, + resolveProjectDefaultProviderModelSelection, shouldShowInstanceBadge, sortProviderInstanceEntries, } from "../providerInstances"; @@ -761,25 +761,35 @@ export default function ChatView(props: ChatViewProps) { } } const nowMinute = useNowMinute(); + const draftDefaultModelSelection = draftThread + ? resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection + : null; + const draftFallback = draftThread + ? resolveNewThreadModelSelection({ + defaultSelection: + draftDefaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + draftThread.projectId, + ), + fallbackSelection: settings.fallbackModelSelection, + providers: providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + projectId: draftThread.projectId, + now: Date.parse(`${nowMinute}:00Z`), + }) + : null; const localDraftThread = draftThread ? buildLocalDraftThread( threadId, draftThread, - resolveNewThreadModelSelection({ - defaultSelection: resolveDefaultProviderModelSelection( - providersByEnvironment.get(draftThread.environmentId) ?? [], - resolveProjectSettings( - settings, - fallbackDraftProject?.id ?? null, - fallbackDraftProject ?? undefined, - ).settings.defaultModelSelection, - ), - fallbackSelection: settings.fallbackModelSelection, - providers: providersByEnvironment.get(draftThread.environmentId) ?? [], - settings, - projectId: draftThread.projectId, - now: Date.parse(`${nowMinute}:00Z`), - }).selection ?? NO_PROVIDER_MODEL_SELECTION, + (draftFallback?.fallback ? draftFallback.selection : draftDefaultModelSelection) ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined; const isServerThread = serverThread !== null; @@ -1072,19 +1082,23 @@ export default function ChatView(props: ChatViewProps) { const { projectCloneBannerItem, projectCloneSendBlockReason } = useProjectCloneBanner(activeProjectRef); const defaultModelResolution = resolveNewThreadModelSelection({ - defaultSelection: resolveDefaultProviderModelSelection( - providersByEnvironment.get(activeThread?.environmentId ?? environmentId) ?? [], - activeProjectSettings.settings.defaultModelSelection, - ), + defaultSelection: + activeProjectSettings.settings.defaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(activeThread?.environmentId ?? environmentId) ?? [], + settings, + activeProject?.id ?? null, + ), fallbackSelection: settings.fallbackModelSelection, providers: providersByEnvironment.get(activeThread?.environmentId ?? environmentId) ?? [], settings, projectId: activeProject?.id ?? null, now: Date.parse(`${nowMinute}:00Z`), }); - const activeProjectDefaultModelSelection = isServerThread - ? activeProjectSettings.settings.defaultModelSelection - : defaultModelResolution.selection; + const activeProjectDefaultModelSelection = + !isServerThread && defaultModelResolution.fallback + ? defaultModelResolution.selection + : activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); }; diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index fdce87f5d70df..e1a74fc921605 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -47,7 +47,7 @@ import { InlineButton } from "../ui/button"; import { DraftProjectName } from "./DraftProjectName"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { resolveDefaultProviderModelSelection } from "../../providerInstances"; +import { resolveProjectDefaultProviderModelSelection } from "../../providerInstances"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; const ADD_PROJECT_VALUE = "add-project"; @@ -206,11 +206,12 @@ export function DraftHeroHeadline({ const fallbackResolution = resolveNewThreadModelSelection({ defaultSelection: defaultSelection ?? - (useComposerDraftStore.getState().stickyActiveProvider !== null + (useComposerDraftStore.getState().stickyActiveProvider !== null || !environmentSettings ? null - : resolveDefaultProviderModelSelection( + : resolveProjectDefaultProviderModelSelection( providersByEnvironment.get(project.environmentId) ?? [], - null, + environmentSettings, + project.id, )), fallbackSelection: environmentSettings?.fallbackModelSelection ?? null, providers: providersByEnvironment.get(project.environmentId) ?? [], diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e863b102c5efc..7e41226a0cf26 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -44,7 +44,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { getClientSettings } from "./useSettings"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; -import { resolveDefaultProviderModelSelection } from "../providerInstances"; +import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; interface NewThreadWorkspaceOptions { @@ -143,9 +143,10 @@ export function createNewThreadHandler(router: AnyRouter) { configuredDefaultModelSelection ?? (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null ? null - : resolveDefaultProviderModelSelection( + : resolveProjectDefaultProviderModelSelection( environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], - null, + targetServerSettings, + projectRef.projectId, )), fallbackSelection: targetServerSettings.fallbackModelSelection ?? null, providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 60c7de4eccac5..85ec46261d0b6 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -239,3 +239,16 @@ export function resolveDefaultProviderModelSelection( const model = getDefaultProviderInstanceModel(providers, instanceId); return model ? { instanceId, model } : null; } + +export function resolveProjectDefaultProviderModelSelection( + providers: ReadonlyArray, + settings: ServerSettings, + projectId: ProjectId | null, +): ModelSelection | null { + return resolveDefaultProviderModelSelection( + providers.filter((provider) => + resolveProjectProviderInstanceEnabled(settings, projectId, provider.instanceId), + ), + null, + ); +} From 1c7fca3ffa307c8fc75889f4cbe9f13925501bf6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 23:41:35 +0000 Subject: [PATCH 09/23] fix(web): pick the auto default like the composer and hide the fallback notice for multi-model sends --- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/providerInstances.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index efd31241ae988..d24fd53a46da8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2573,6 +2573,7 @@ export default function ChatView(props: ChatViewProps) { const composerBannerItems = (() => { const fallback = !isServerThread && + multipleModelSelections === null && !composerModelSelectionExplicit && defaultModelResolution.fallback && (!composerSelectedModelSelection || diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 85ec46261d0b6..4ed6eb616df46 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -245,10 +245,12 @@ export function resolveProjectDefaultProviderModelSelection( settings: ServerSettings, projectId: ProjectId | null, ): ModelSelection | null { - return resolveDefaultProviderModelSelection( - providers.filter((provider) => - resolveProjectProviderInstanceEnabled(settings, projectId, provider.instanceId), + const entry = resolveSelectableProviderInstanceEntry( + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, projectId), ), - null, + undefined, ); + const model = entry ? getDefaultProviderInstanceModel(providers, entry.instanceId) : undefined; + return entry && model ? { instanceId: entry.instanceId, model } : null; } From fb29b2617135bf6d05fa97df62e007bace071a78 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 23:44:43 +0000 Subject: [PATCH 10/23] fix(web): resolve the auto default within the composer's first driver group --- apps/web/src/providerInstances.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 4ed6eb616df46..c5402fcb3b01e 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -245,12 +245,15 @@ export function resolveProjectDefaultProviderModelSelection( settings: ServerSettings, projectId: ProjectId | null, ): ModelSelection | null { - const entry = resolveSelectableProviderInstanceEntry( - sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, projectId), - ), - undefined, + const entries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, projectId), ); + // Matches resolveComposerProviderSelection: the first driver group wins before any other. + const entry = + resolveSelectableProviderInstanceEntry( + entries.filter((candidate) => candidate.driverKind === entries[0]?.driverKind), + undefined, + ) ?? resolveSelectableProviderInstanceEntry(entries, undefined); const model = entry ? getDefaultProviderInstanceModel(providers, entry.instanceId) : undefined; return entry && model ? { instanceId: entry.instanceId, model } : null; } From 5ddc2fe9cd6a99798cf953e94b29651421d84f8a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 28 Oct 2026 01:00:22 +0000 Subject: [PATCH 11/23] fix(server): refresh provider usage when a turn hits its usage limit --- .../ProviderEventIngestor.test.ts | 168 +++++++++++++++++- .../orchestration-v2/ProviderEventIngestor.ts | 21 +++ .../ProviderSessionManager.test.ts | 5 + .../testkit/ProviderReplayHarness.ts | 5 + .../src/provider/claudeUsageLimits.test.ts | 38 ++++ apps/server/src/provider/claudeUsageLimits.ts | 41 +++-- packages/contracts/src/providerUsageLimits.ts | 2 + .../src/server/managedProvider.test.ts | 61 +++++++ .../src/server/managedProvider.ts | 16 ++ packages/provider-core/src/server/snapshot.ts | 9 +- .../src/server/usageLimits.test.ts | 24 +++ .../provider-core/src/server/usageLimits.ts | 19 +- packages/shared/src/usageLimits.test.ts | 25 +++ packages/shared/src/usageLimits.ts | 1 + 14 files changed, 417 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 3fecc1c7d2d61..0fa2dc258121c 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -19,6 +19,7 @@ import { ProviderInstanceId, ProviderThreadId, ProviderTurnId, + type ServerProvider, RunAttemptId, RunId, RuntimeRequestId, @@ -32,6 +33,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlError from "effect/sql/SqlError"; import * as Stream from "effect/Stream"; @@ -46,6 +48,11 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderEventRoutingState, @@ -68,8 +75,16 @@ const layerGit = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); -const makeLayerTest = (ingestorEventSink: Layer.Layer) => +const layerEmptyInstances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), +}); +const makeLayerTest = ( + ingestorEventSink: Layer.Layer, + instances = layerEmptyInstances, +) => Layer.mergeAll( + instances, layerTestDatabase, layerTestStores, layerTestEventSink, @@ -79,6 +94,7 @@ const makeLayerTest = (ingestorEventSink: Layer.Layer + Effect.scoped( + Effect.gen(function* () { + const probeSnapshot = yield* Ref.make({ + instanceId: ProviderInstanceId.make("cursor-work"), + driver: ProviderDriverKind.make("cursor"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-04-10T00:00:00.000Z", + windows: [{ id: "apiPercentUsed", kind: "monthly", label: "API", usedPercent: 20 }], + }, + }); + const snapshot = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Ref.get(probeSnapshot), + checkProvider: Ref.get(probeSnapshot), + refreshInterval: "1 hour", + refreshOnInterval: false, + }).pipe( + Effect.provide( + Layer.mock(ProviderHost.ProviderHost)({ + resolveAttachmentPath: () => null, + paths: { + cwd: "/test", + baseDir: "/test", + stateDir: "/test/state", + providerStatusCacheDir: "/test/cache", + attachmentsDir: "/test/attachments", + }, + settings: { + get: Effect.die("unused"), + withSnapshot: () => Effect.die("unused"), + changes: Stream.empty, + subscribe: Effect.succeed(Stream.empty), + }, + }), + ), + ); + yield* snapshot.streamChanges.pipe(Stream.take(1), Stream.runDrain); + assert.equal((yield* snapshot.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); + yield* Ref.update(probeSnapshot, (current): ServerProvider => ({ + ...current, + usageLimits: { + checkedAt: "2026-04-10T00:01:00.000Z", + windows: [{ id: "apiPercentUsed", kind: "monthly", label: "API", usedPercent: 100 }], + }, + })); + const refreshed = yield* snapshot.streamChanges.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe( + Effect.provide( + Layer.mock(ProviderAdapter.ProviderAdapterV2)({ + instanceId: ProviderInstanceId.make("cursor-work"), + driver: ProviderDriverKind.make("cursor"), + }), + ), + ); + const instance: ProviderInstance = { + instanceId: ProviderInstanceId.make("cursor-work"), + driverKind: ProviderDriverKind.make("cursor"), + continuationIdentity: { + driverKind: ProviderDriverKind.make("cursor"), + continuationKey: "work", + }, + enabled: true, + displayName: undefined, + orchestrationAdapter: adapter, + textGeneration: { + generateBranchName: () => Effect.die("unused"), + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateThreadTitle: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + }, + snapshot, + }; + const lookups: ProviderInstanceId[] = []; + const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.sync(() => { + lookups.push(id); + return id === instance.instanceId ? instance : undefined; + }), + }); + yield* Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + yield* eventSink.write({ events: [threadEvent] }); + const input = { + providerSessionId: yield* idAllocator.allocate.providerSession({ + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }), + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }; + for (const failureClass of ["validation_error", "usage_limit"] as const) { + const stored = yield* ingestor.ingestNormalized({ + ...input, + event: { + type: "turn.terminal", + driver: instance.driverKind, + providerThreadId: idAllocator.derive.providerThread({ + driver: instance.driverKind, + nativeThreadId: "limited", + }), + providerTurnId: idAllocator.derive.providerTurn({ + driver: instance.driverKind, + nativeTurnId: failureClass, + }), + runOrdinal: 1, + failureItemOrdinal: 1, + status: "failed", + failure: makeProviderFailure({ + class: failureClass, + message: "Provider rejected turn.", + }), + threadDisposition: "reusable", + }, + }); + assert.equal(stored.length, 1); + if (failureClass === "validation_error") assert.deepEqual(lookups, []); + } + assert.deepEqual(lookups, [instance.instanceId]); + const [published] = yield* Fiber.join(refreshed); + assert.equal(published?.instanceId, instance.instanceId); + assert.equal(published?.usageLimits?.windows[0]?.usedPercent, 100); + assert.equal((yield* snapshot.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); + }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }), + ), +); + it.effect("records accepted billed turn usage once without billing the context window", () => { const recorded: Array>> = []; const layerAnalytics = Layer.succeed(ProviderEventIngestor.ProviderTurnAnalytics, { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 6beb4d0e55471..06f444776cb3c 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -34,6 +34,7 @@ import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -326,6 +327,7 @@ export const layer: Layer.Layer< | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2 | ThreadCommandExecutor.ThreadCommandExecutor + | ProviderInstanceRegistry.ProviderInstanceRegistry > = Layer.effect( ProviderEventIngestorV2, Effect.gen(function* () { @@ -336,6 +338,8 @@ export const layer: Layer.Layer< const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; + const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const scope = yield* Effect.scope; const completedTurnAnalytics = new Set(); const makeDomainEvent = @@ -794,6 +798,23 @@ export const layer: Layer.Layer< }, (effect, input) => effect.pipe( + Effect.tap((storedEvents) => + Effect.gen(function* () { + if ( + storedEvents.length === 0 || + input.event.type !== "turn.terminal" || + input.event.status !== "failed" || + input.event.failure.class !== "usage_limit" + ) + return; + const instance = yield* instances.getInstance(input.providerInstanceId); + if (!instance?.snapshot.refreshUsage) return; + yield* instance.snapshot.refreshUsage.pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(scope, { startImmediately: true }), + ); + }), + ), Effect.flatMap((storedEvents) => Effect.gen(function* () { if (input.event.type !== "app_thread.created") return storedEvents; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 3c635be6753df..78d4fbb12ad07 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -47,6 +47,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -529,6 +530,10 @@ function layerTest(input: { const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerConfiguredEventSink, IdAllocator.layer, layerTestStores, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 4da359f74c920..afd11a9ab7ecb 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -40,6 +40,7 @@ import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderContinuationService from "../ProviderContinuationService.ts"; import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; import * as ProviderRuntimeRecoveryService from "../ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "../ProviderSessionManager.ts"; import * as CommandOutputRecorder from "./CommandOutputRecorder.ts"; @@ -320,6 +321,10 @@ export function layerWithRegistry( const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerStores, layerEventSinkProvided, IdAllocator.layer, diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index f20d8b0543636..2cf7002c27605 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -6,6 +6,26 @@ const checkedAt = "2026-07-18T10:00:00.000Z"; const noNames = { overageIncluded: undefined } as const; describe("claudeUsageResponseToLimits", () => { + it("reports whether enabled extra usage still has capacity", () => { + for (const [is_enabled, utilization, allowed] of [ + [true, 20, true], + [true, 100, false], + [false, 20, false], + ] as const) { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 100, resets_at: null }, + extra_usage: { is_enabled, utilization, monthly_limit: null, used_credits: null }, + }, + }, + }).limits.overageAllowed, + ).toBe(allowed); + } + }); it("maps the session, weekly, and model-scoped weekly windows", () => { expect( claudeUsageResponseToLimits({ @@ -35,6 +55,7 @@ describe("claudeUsageResponseToLimits", () => { names: { overageIncluded: "Fable" }, limits: { checkedAt, + overageAllowed: false, windows: [ { id: "five_hour", @@ -140,6 +161,7 @@ describe("claudeUsageResponseToLimits", () => { { checkedAt, windows: [monthlyWindow], + overageAllowed: true, }, ); const response = { @@ -224,6 +246,22 @@ describe("claudeUsageResponseToLimits", () => { }); describe("claudeRateLimitEventToUpdate", () => { + it("updates overage permission even without a recognized usage window", () => { + for (const info of [ + { status: "rejected", overageStatus: "allowed" }, + { status: "rejected", overageStatus: "allowed_warning" }, + { status: "rejected", isUsingOverage: true }, + { status: "rejected", overageInUse: true }, + ] as const) { + expect(claudeRateLimitEventToUpdate(info, noNames)).toEqual({ + windows: [], + overageAllowed: true, + }); + } + expect( + claudeRateLimitEventToUpdate({ status: "rejected", overageStatus: "rejected" }, noNames), + ).toEqual({ windows: [], overageAllowed: false }); + }); it("scales the 0โ€“1 utilization and epoch-second reset onto the probe's window id", () => { expect( claudeRateLimitEventToUpdate( diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index d0acf43bb4968..58e54715dba6b 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -156,19 +156,29 @@ export function claudeRateLimitEventToUpdate( info: SDKRateLimitInfo, names: ClaudeScopedLimitNames, ): ProviderUsageLimitsUpdate | undefined { + const overageAllowed = + info.overageStatus === "allowed" || + info.overageStatus === "allowed_warning" || + info.isUsingOverage === true || + info.overageInUse === true + ? true + : info.overageStatus === "rejected" || info.status === "rejected" + ? false + : undefined; const type: string | undefined = info.rateLimitType; - if (!type || typeof info.utilization !== "number") { - return undefined; - } - const usedPercent = info.utilization * 100; - const resetsAt = isoFromEpochSeconds(info.resetsAt); - if (type in WINDOWS) { - return { windows: [makeWindow(type, usedPercent, resetsAt)] }; - } - if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { - return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; + const windows: ServerProviderUsageWindow[] = []; + if (type && typeof info.utilization === "number") { + const usedPercent = info.utilization * 100; + const resetsAt = isoFromEpochSeconds(info.resetsAt); + if (type in WINDOWS) { + windows.push(makeWindow(type, usedPercent, resetsAt)); + } else if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { + windows.push(scopedWindow(names.overageIncluded, usedPercent, resetsAt)); + } } - return undefined; + return windows.length || overageAllowed !== undefined + ? { windows, ...(overageAllowed !== undefined ? { overageAllowed } : {}) } + : undefined; } /** get_usage percentages are already 0-100. */ @@ -211,7 +221,14 @@ export function claudeUsageResponseToLimits(input: { }); } return { - limits: makeUsageLimits({ checkedAt, windows }), + limits: { + ...makeUsageLimits({ checkedAt, windows }), + ...(spendPercent !== undefined + ? { overageAllowed: spendPercent < 100 } + : response.rate_limits.extra_usage?.is_enabled === false + ? { overageAllowed: false } + : {}), + }, names: { overageIncluded }, }; } diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5c4927d4a164e..806935b7900dc 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -32,6 +32,7 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, windows: ForwardCompatibleArray(ServerProviderUsageWindow), + overageAllowed: Schema.optional(Schema.Boolean), credentialFingerprint: Schema.optional(TrimmedNonEmptyString), resetCredits: Schema.optional(ServerProviderResetCredits), externalUsage: Schema.optional( @@ -52,6 +53,7 @@ export type ServerProviderUsageLimits = typeof ServerProviderUsageLimits.Type; /** Partial updates merge windows by id; omitted windows remain unchanged. */ export const ProviderUsageLimitsUpdate = Schema.Struct({ windows: Schema.Array(ServerProviderUsageWindow), + overageAllowed: Schema.optional(Schema.Boolean), }); export type ProviderUsageLimitsUpdate = typeof ProviderUsageLimitsUpdate.Type; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 668502f93e432..32321feeaafda 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -138,6 +138,67 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("refreshes usage on demand and skips probes already in flight or unsupported", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const limits = { + checkedAt: "2026-04-10T00:00:01.000Z", + windows: [{ id: "five_hour", kind: "session", label: "Session", usedPercent: 100 }], + } as const; + const probeSnapshot = yield* Ref.make({ + ...refreshedSnapshotSecond, + usageLimits: { ...limits, checkedAt: "2026-04-10T00:00:03.000Z" }, + }); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed({ ...initialSnapshot, usageLimits: limits }), + checkProvider: Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.succeed({ + ...refreshedSnapshot, + usageLimits: { + ...limits, + windows: [{ ...limits.windows[0], usedPercent: 20 }], + }, + }) + : Deferred.succeed(probeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseProbe)), + Effect.andThen(Ref.get(probeSnapshot)), + ), + ), + ), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); + const refresh = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(calls), 2); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(refresh); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); + yield* Ref.set(probeSnapshot, { + ...refreshedSnapshotSecond, + usageLimits: { ...limits, windows: [], unavailable: { reason: "unsupported" } }, + }); + yield* provider.refreshUsage!; + assert.strictEqual( + (yield* provider.getSnapshot).usageLimits?.unavailable?.reason, + "unsupported", + ); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(calls), 3); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index f40baee525c8d..6703675edde54 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -216,6 +216,21 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); + const refreshUsage = Effect.fn("ManagedServerProvider.refreshUsage")(function* () { + const { snapshot } = yield* Ref.get(snapshotStateRef); + if ( + !snapshot.enabled || + !snapshot.usageLimits || + snapshot.usageLimits.unavailable?.reason === "unsupported" + ) + return; + yield* refreshSemaphore.withPermitsIfAvailable(1)( + input.getSettings.pipe( + Effect.flatMap((settings) => applySnapshotBase(settings, { forceRefresh: true })), + ), + ); + }); + const shouldRefreshOnInterval = Effect.gen(function* () { const state = yield* Ref.get(snapshotStateRef); // A disabled instance has nothing to probe; a periodic check would only @@ -304,6 +319,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( .resolveMaintenance({ fresh: true }) .pipe(Effect.andThen(refreshSnapshot), Effect.tapError(Effect.logError), Effect.orDie), applyUsageLimits, + refreshUsage: refreshUsage(), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index c9c877d29fe20..8118923acb8fb 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -1,4 +1,8 @@ -import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; +import type { + ProviderUsageLimitsUpdate, + ServerProvider, + ServerSettingsError, +} from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "./maintenance.ts"; @@ -10,8 +14,9 @@ export interface ManagedServerProvider { }) => Effect.Effect; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; + readonly refreshUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; - /** Merge windows by ID; updates with no usable window leave the snapshot untouched. */ + /** Merge windows by ID and retain omitted overage permission. */ readonly applyUsageLimits: ( update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, ) => Effect.Effect; diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 4b9c1b39c862b..d84ded361b1dd 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -21,6 +21,27 @@ const weekly = { const published = { checkedAt, windows: [session, weekly] }; describe("applyUsageLimitsUpdate", () => { + it("merges overage permission without windows and preserves it on sparse updates", () => { + const allowed = applyUsageLimitsUpdate({ + previous: published, + checkedAt, + update: { windows: [], overageAllowed: true }, + }); + expect(allowed).toEqual({ ...published, overageAllowed: true }); + const updated = applyUsageLimitsUpdate({ + previous: allowed, + checkedAt, + update: { windows: [{ ...weekly, usedPercent: 100 }] }, + }); + expect(updated?.overageAllowed).toBe(true); + expect( + applyUsageLimitsUpdate({ + previous: updated, + checkedAt, + update: { windows: [], overageAllowed: false }, + })?.overageAllowed, + ).toBe(false); + }); it("returns the published object itself when no window moved", () => { const next = applyUsageLimitsUpdate({ previous: published, @@ -86,6 +107,7 @@ describe("resolveUsageLimitsAfterProbe", () => { it("uses read order for windows while taking metadata from the successful probe", () => { const live = { checkedAt: "2026-09-03T12:00:05.000Z", + overageAllowed: true, windows: [ { ...session, usedPercent: 95 }, { ...weekly, usedPercent: 70 }, @@ -95,6 +117,7 @@ describe("resolveUsageLimitsAfterProbe", () => { }; const probed = { ...published, + overageAllowed: false, windows: [{ ...session, usedPercent: 0 }], resetCredits: { availableCount: 1 }, externalUsage: { label: "Usage dashboard", url: "https://example.com/usage" }, @@ -104,6 +127,7 @@ describe("resolveUsageLimitsAfterProbe", () => { ...probed, checkedAt: live.checkedAt, windows: live.windows, + overageAllowed: true, }); const sameTime = { ...probed, checkedAt: live.checkedAt }; diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 1e4bcd0090854..13262f6d14410 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -53,12 +53,16 @@ export function applyUsageLimitsUpdate(input: { readonly checkedAt: string; }): ServerProviderUsageLimits | undefined { const { previous, update } = input; - if (update.windows.length === 0 || previous?.unavailable?.reason === "unsupported") { + if ( + (update.windows.length === 0 && update.overageAllowed === undefined) || + previous?.unavailable?.reason === "unsupported" + ) { return previous; } const merged = new Map(previous?.windows.map((window) => [window.id, window] as const)); // Codex repeats unchanged limits on token ticks; avoid allocating snapshots for no-ops. - let changed = false; + const overageAllowed = update.overageAllowed ?? previous?.overageAllowed; + let changed = overageAllowed !== previous?.overageAllowed; for (const window of update.windows) { const existing = merged.get(window.id); const next: ServerProviderUsageWindow = { @@ -83,6 +87,7 @@ export function applyUsageLimitsUpdate(input: { previous === undefined ? { reason: "probeFailed" as const } : previous.unavailable; return { ...makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }), + ...(overageAllowed !== undefined ? { overageAllowed } : {}), ...(previous?.resetCredits !== undefined ? { resetCredits: previous.resetCredits } : {}), ...(unavailable !== undefined ? { unavailable } : {}), }; @@ -114,7 +119,15 @@ export function resolveUsageLimitsAfterProbe(input: { !probed.unavailable && Date.parse(published.checkedAt) > Date.parse(probed.checkedAt) ) { - return { ...probed, checkedAt: published.checkedAt, windows: published.windows }; + const { overageAllowed: _probedOverage, ...rest } = probed; + return { + ...rest, + checkedAt: published.checkedAt, + windows: published.windows, + ...(published.overageAllowed !== undefined + ? { overageAllowed: published.overageAllowed } + : {}), + }; } return probed; } diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 26dfa7cfd4c43..2e7115a46c0f4 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -21,6 +21,7 @@ import { paceRemainingPercent, usesChatGptSharing, resolveNewThreadModelSelection, + exhaustedUsageWindow, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -52,6 +53,30 @@ function provider(overrides: Partial): ServerProvider { } describe("new thread model fallback", () => { + it("keeps Claude usable while overage is allowed", () => { + const claude = provider({ + driver: ProviderDriverKind.make("claudeAgent"), + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...window, usedPercent: 100 }], + overageAllowed: true, + }, + }); + expect(exhaustedUsageWindow(claude, now, "claude-fable-5-1")).toBeUndefined(); + expect(exhaustedUsageWindow(claude, now)).toBeUndefined(); + for (const overageAllowed of [false, undefined]) { + expect( + exhaustedUsageWindow( + { ...claude, usageLimits: { ...claude.usageLimits!, overageAllowed } }, + now, + "claude-fable-5-1", + ), + ).toBeDefined(); + } + expect( + exhaustedUsageWindow({ ...claude, driver: ProviderDriverKind.make("codex") }, now), + ).toBeDefined(); + }); const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; const fallback = { instanceId: ProviderInstanceId.make("claude"), model: "claude-fable-5-1" }; const limited = provider({ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index fffb2373a192b..e36164a7170ea 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -26,6 +26,7 @@ export function exhaustedUsageWindow( now: number, model?: string, ) { + if (provider?.driver === "claudeAgent" && provider.usageLimits?.overageAllowed === true) return; const exhausted = (provider?.usageLimits?.windows ?? []).filter( (window) => window.usedPercent !== undefined && From 38a513635f079bc32d9ee16333c8353776bb01f2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 28 Oct 2026 01:31:00 +0000 Subject: [PATCH 12/23] fix(server): re-probe Claude usage past its cache and classify usage limits --- .../ProviderEventIngestor.test.ts | 6 +- .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../src/provider/claudeUsageLimits.test.ts | 38 ---------- apps/server/src/provider/claudeUsageLimits.ts | 41 ++++------- packages/contracts/src/providerUsageLimits.ts | 2 - .../src/server/managedProvider.test.ts | 46 +++++++++++++ .../src/server/managedProvider.ts | 2 + packages/provider-core/src/server/snapshot.ts | 2 +- .../src/server/usageLimits.test.ts | 24 ------- .../provider-core/src/server/usageLimits.ts | 19 +---- .../src/server/CursorAgentSdk.ts | 9 +++ .../src/server/adapter.test.ts | 26 ++++++- .../provider-cursor/src/server/adapter.ts | 8 ++- .../src/server/adapter.test.ts | 69 +++++++++++++++++++ .../provider-opencode/src/server/adapter.ts | 26 ++++++- packages/shared/src/usageLimits.test.ts | 25 ------- packages/shared/src/usageLimits.ts | 1 - 17 files changed, 202 insertions(+), 143 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 0fa2dc258121c..26caf80bd0e61 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -365,7 +365,11 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit status: "failed", failure: makeProviderFailure({ class: failureClass, - message: "Provider rejected turn.", + ...(failureClass === "usage_limit" ? { code: "resource_exhausted" } : {}), + message: + failureClass === "usage_limit" + ? "Usage limit reached" + : "Provider rejected turn.", }), threadDisposition: "reusable", }, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 86832d53d3119..e1fbcd30080fd 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -299,6 +299,7 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index 2cf7002c27605..f20d8b0543636 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -6,26 +6,6 @@ const checkedAt = "2026-07-18T10:00:00.000Z"; const noNames = { overageIncluded: undefined } as const; describe("claudeUsageResponseToLimits", () => { - it("reports whether enabled extra usage still has capacity", () => { - for (const [is_enabled, utilization, allowed] of [ - [true, 20, true], - [true, 100, false], - [false, 20, false], - ] as const) { - expect( - claudeUsageResponseToLimits({ - checkedAt, - response: { - rate_limits_available: true, - rate_limits: { - five_hour: { utilization: 100, resets_at: null }, - extra_usage: { is_enabled, utilization, monthly_limit: null, used_credits: null }, - }, - }, - }).limits.overageAllowed, - ).toBe(allowed); - } - }); it("maps the session, weekly, and model-scoped weekly windows", () => { expect( claudeUsageResponseToLimits({ @@ -55,7 +35,6 @@ describe("claudeUsageResponseToLimits", () => { names: { overageIncluded: "Fable" }, limits: { checkedAt, - overageAllowed: false, windows: [ { id: "five_hour", @@ -161,7 +140,6 @@ describe("claudeUsageResponseToLimits", () => { { checkedAt, windows: [monthlyWindow], - overageAllowed: true, }, ); const response = { @@ -246,22 +224,6 @@ describe("claudeUsageResponseToLimits", () => { }); describe("claudeRateLimitEventToUpdate", () => { - it("updates overage permission even without a recognized usage window", () => { - for (const info of [ - { status: "rejected", overageStatus: "allowed" }, - { status: "rejected", overageStatus: "allowed_warning" }, - { status: "rejected", isUsingOverage: true }, - { status: "rejected", overageInUse: true }, - ] as const) { - expect(claudeRateLimitEventToUpdate(info, noNames)).toEqual({ - windows: [], - overageAllowed: true, - }); - } - expect( - claudeRateLimitEventToUpdate({ status: "rejected", overageStatus: "rejected" }, noNames), - ).toEqual({ windows: [], overageAllowed: false }); - }); it("scales the 0โ€“1 utilization and epoch-second reset onto the probe's window id", () => { expect( claudeRateLimitEventToUpdate( diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index 58e54715dba6b..d0acf43bb4968 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -156,29 +156,19 @@ export function claudeRateLimitEventToUpdate( info: SDKRateLimitInfo, names: ClaudeScopedLimitNames, ): ProviderUsageLimitsUpdate | undefined { - const overageAllowed = - info.overageStatus === "allowed" || - info.overageStatus === "allowed_warning" || - info.isUsingOverage === true || - info.overageInUse === true - ? true - : info.overageStatus === "rejected" || info.status === "rejected" - ? false - : undefined; const type: string | undefined = info.rateLimitType; - const windows: ServerProviderUsageWindow[] = []; - if (type && typeof info.utilization === "number") { - const usedPercent = info.utilization * 100; - const resetsAt = isoFromEpochSeconds(info.resetsAt); - if (type in WINDOWS) { - windows.push(makeWindow(type, usedPercent, resetsAt)); - } else if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { - windows.push(scopedWindow(names.overageIncluded, usedPercent, resetsAt)); - } + if (!type || typeof info.utilization !== "number") { + return undefined; } - return windows.length || overageAllowed !== undefined - ? { windows, ...(overageAllowed !== undefined ? { overageAllowed } : {}) } - : undefined; + const usedPercent = info.utilization * 100; + const resetsAt = isoFromEpochSeconds(info.resetsAt); + if (type in WINDOWS) { + return { windows: [makeWindow(type, usedPercent, resetsAt)] }; + } + if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { + return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; + } + return undefined; } /** get_usage percentages are already 0-100. */ @@ -221,14 +211,7 @@ export function claudeUsageResponseToLimits(input: { }); } return { - limits: { - ...makeUsageLimits({ checkedAt, windows }), - ...(spendPercent !== undefined - ? { overageAllowed: spendPercent < 100 } - : response.rate_limits.extra_usage?.is_enabled === false - ? { overageAllowed: false } - : {}), - }, + limits: makeUsageLimits({ checkedAt, windows }), names: { overageIncluded }, }; } diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 806935b7900dc..5c4927d4a164e 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -32,7 +32,6 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, windows: ForwardCompatibleArray(ServerProviderUsageWindow), - overageAllowed: Schema.optional(Schema.Boolean), credentialFingerprint: Schema.optional(TrimmedNonEmptyString), resetCredits: Schema.optional(ServerProviderResetCredits), externalUsage: Schema.optional( @@ -53,7 +52,6 @@ export type ServerProviderUsageLimits = typeof ServerProviderUsageLimits.Type; /** Partial updates merge windows by id; omitted windows remain unchanged. */ export const ProviderUsageLimitsUpdate = Schema.Struct({ windows: Schema.Array(ServerProviderUsageWindow), - overageAllowed: Schema.optional(Schema.Boolean), }); export type ProviderUsageLimitsUpdate = typeof ProviderUsageLimitsUpdate.Type; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 32321feeaafda..3082221543da9 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -6,6 +6,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Cache from "effect/Cache"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -138,6 +139,51 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("refreshes usage past a warmed capabilities cache without waiting for its TTL", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const cache = yield* Cache.makeWith( + () => + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.map((count): ServerProvider => ({ + ...refreshedSnapshot, + driver: ProviderDriverKind.make("claudeAgent"), + usageLimits: { + checkedAt: refreshedSnapshot.checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: count === 1 ? 20 : 100, + }, + ], + }, + })), + ), + { capacity: 1, timeToLive: () => "5 minutes" }, + ); + const warmed = yield* Cache.get(cache, "claude"); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(warmed), + checkProvider: Cache.get(cache, "claude"), + invalidateUsageCache: Cache.invalidateAll(cache), + refreshOnInterval: false, + }); + yield* provider.refresh; + assert.strictEqual(yield* Ref.get(calls), 1); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(calls), 2); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); it.effect("refreshes usage on demand and skips probes already in flight or unsupported", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 6703675edde54..04e08273a3f0a 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -44,6 +44,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + readonly invalidateUsageCache?: Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -226,6 +227,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return; yield* refreshSemaphore.withPermitsIfAvailable(1)( input.getSettings.pipe( + Effect.tap(() => input.invalidateUsageCache ?? Effect.void), Effect.flatMap((settings) => applySnapshotBase(settings, { forceRefresh: true })), ), ); diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index 8118923acb8fb..88f2c1f113848 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -16,7 +16,7 @@ export interface ManagedServerProvider { readonly refresh: Effect.Effect; readonly refreshUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; - /** Merge windows by ID and retain omitted overage permission. */ + /** Merge windows by ID; updates with no usable window leave the snapshot untouched. */ readonly applyUsageLimits: ( update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, ) => Effect.Effect; diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index d84ded361b1dd..4b9c1b39c862b 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -21,27 +21,6 @@ const weekly = { const published = { checkedAt, windows: [session, weekly] }; describe("applyUsageLimitsUpdate", () => { - it("merges overage permission without windows and preserves it on sparse updates", () => { - const allowed = applyUsageLimitsUpdate({ - previous: published, - checkedAt, - update: { windows: [], overageAllowed: true }, - }); - expect(allowed).toEqual({ ...published, overageAllowed: true }); - const updated = applyUsageLimitsUpdate({ - previous: allowed, - checkedAt, - update: { windows: [{ ...weekly, usedPercent: 100 }] }, - }); - expect(updated?.overageAllowed).toBe(true); - expect( - applyUsageLimitsUpdate({ - previous: updated, - checkedAt, - update: { windows: [], overageAllowed: false }, - })?.overageAllowed, - ).toBe(false); - }); it("returns the published object itself when no window moved", () => { const next = applyUsageLimitsUpdate({ previous: published, @@ -107,7 +86,6 @@ describe("resolveUsageLimitsAfterProbe", () => { it("uses read order for windows while taking metadata from the successful probe", () => { const live = { checkedAt: "2026-09-03T12:00:05.000Z", - overageAllowed: true, windows: [ { ...session, usedPercent: 95 }, { ...weekly, usedPercent: 70 }, @@ -117,7 +95,6 @@ describe("resolveUsageLimitsAfterProbe", () => { }; const probed = { ...published, - overageAllowed: false, windows: [{ ...session, usedPercent: 0 }], resetCredits: { availableCount: 1 }, externalUsage: { label: "Usage dashboard", url: "https://example.com/usage" }, @@ -127,7 +104,6 @@ describe("resolveUsageLimitsAfterProbe", () => { ...probed, checkedAt: live.checkedAt, windows: live.windows, - overageAllowed: true, }); const sameTime = { ...probed, checkedAt: live.checkedAt }; diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 13262f6d14410..1e4bcd0090854 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -53,16 +53,12 @@ export function applyUsageLimitsUpdate(input: { readonly checkedAt: string; }): ServerProviderUsageLimits | undefined { const { previous, update } = input; - if ( - (update.windows.length === 0 && update.overageAllowed === undefined) || - previous?.unavailable?.reason === "unsupported" - ) { + if (update.windows.length === 0 || previous?.unavailable?.reason === "unsupported") { return previous; } const merged = new Map(previous?.windows.map((window) => [window.id, window] as const)); // Codex repeats unchanged limits on token ticks; avoid allocating snapshots for no-ops. - const overageAllowed = update.overageAllowed ?? previous?.overageAllowed; - let changed = overageAllowed !== previous?.overageAllowed; + let changed = false; for (const window of update.windows) { const existing = merged.get(window.id); const next: ServerProviderUsageWindow = { @@ -87,7 +83,6 @@ export function applyUsageLimitsUpdate(input: { previous === undefined ? { reason: "probeFailed" as const } : previous.unavailable; return { ...makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }), - ...(overageAllowed !== undefined ? { overageAllowed } : {}), ...(previous?.resetCredits !== undefined ? { resetCredits: previous.resetCredits } : {}), ...(unavailable !== undefined ? { unavailable } : {}), }; @@ -119,15 +114,7 @@ export function resolveUsageLimitsAfterProbe(input: { !probed.unavailable && Date.parse(published.checkedAt) > Date.parse(probed.checkedAt) ) { - const { overageAllowed: _probedOverage, ...rest } = probed; - return { - ...rest, - checkedAt: published.checkedAt, - windows: published.windows, - ...(published.overageAllowed !== undefined - ? { overageAllowed: published.overageAllowed } - : {}), - }; + return { ...probed, checkedAt: published.checkedAt, windows: published.windows }; } return probed; } diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 7f0decb49af3f..a41783bbfb22a 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -43,6 +43,15 @@ export function cursorSdkFailureMessage(error: CursorAgentSdkRunnerError): strin : `Cursor Agent SDK ${error.method} failed: ${reason}`; } +export function isCursorUsageLimitError(cause: unknown): boolean { + const error = isCursorAgentSdkRunnerError(cause) ? cause.cause : cause; + return ( + typeof error === "object" && + error !== null && + (Reflect.get(error, "code") === "resource_exhausted" || Reflect.get(error, "status") === 429) + ); +} + const GENERIC_SDK_ERROR_CODES = new Set(["", "error", "unknown"]); function sdkErrorReason(cause: unknown): string | undefined { diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 8283c5c3d6b0d..989af53febcaa 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -219,6 +219,28 @@ describe("CursorAdapterV2", () => { ); it.effect.each([ + { + reason: "the serialized SDK resource exhaustion code", + wait: Effect.succeed({ + id: "native-cursor-run", + status: "error" as const, + error: { message: "Usage limit reached", code: "resource_exhausted" }, + }), + failure: { class: "usage_limit", message: "Usage limit reached" }, + }, + { + reason: "an unrelated SDK transport error", + wait: Effect.fail( + new CursorAgentSdk.CursorAgentSdkRunnerError({ + method: "run.wait", + cause: Object.assign(new Error("Service unavailable"), { status: 503 }), + }), + ), + failure: { + class: "transport_error", + message: "Cursor Agent SDK run.wait failed: Service unavailable (HTTP 503)", + }, + }, { reason: "the error Cursor's run ended with", wait: Effect.succeed({ @@ -237,7 +259,7 @@ describe("CursorAdapterV2", () => { }, }, { - reason: "the SDK error that broke the run", + reason: "the SDK rate limit that broke the run", wait: Effect.fail( new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "run.wait", @@ -248,7 +270,7 @@ describe("CursorAdapterV2", () => { }), ), failure: { - class: "transport_error", + class: "usage_limit", message: "Cursor Agent SDK run.wait failed: Too many requests (resource_exhausted, HTTP 429)", }, diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 186334fe5c913..1fcef2b62d257 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2402,7 +2402,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( cause: result.error, // makeProviderFailure never reads a cause's own text. message: result.error?.message, - class: "provider_error", + class: CursorAgentSdk.isCursorUsageLimitError(result.error) + ? "usage_limit" + : "provider_error", }) : makeProviderFailure({ cause: transportFailure, @@ -2428,7 +2430,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( message: CursorAgentSdk.isCursorAgentSdkRunnerError(cause) ? CursorAgentSdk.cursorSdkFailureMessage(cause) : undefined, - class: "transport_error", + class: CursorAgentSdk.isCursorUsageLimitError(cause) + ? "usage_limit" + : "transport_error", }), }), }); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index cec5c7847c885..646aeb9e145d0 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -239,6 +239,75 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect.each([ + { + statusCode: 429, + headers: { "retry-after": "60" }, + failureClass: "usage_limit", + resetAt: "1970-01-01T00:01:00.000Z", + }, + { + statusCode: 429, + headers: { "retry-after": "Fri, 10 Apr 2026 12:00:00 GMT" }, + failureClass: "usage_limit", + resetAt: "2026-04-10T12:00:00.000Z", + }, + { + statusCode: 429, + headers: { "retry-after": "invalid" }, + failureClass: "usage_limit", + resetAt: null, + }, + { statusCode: 402, headers: {}, failureClass: "provider_error", resetAt: null }, + { statusCode: 500, headers: {}, failureClass: "provider_error", resetAt: null }, + ])( + "classifies APIError $statusCode with $headers", + ({ statusCode, headers, failureClass, resetAt }) => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness("usage-limit", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.error", + properties: { + sessionID: "root", + error: { + name: "APIError", + data: { + message: "Provider rejected request", + statusCode, + responseHeaders: headers, + isRetryable: true, + }, + }, + }, + }), + ); + const terminal = (yield* Fiber.join(received)).find( + (event) => event.type === "turn.terminal", + ); + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.failure?.class, failureClass); + assert.equal(terminal?.failure?.resetAt ?? null, resetAt); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); it.effect.each(["completed", "failed", "unavailable"] as const)( "normalizes OpenCode step usage for %s turns", (ending) => diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index efe90e2e77191..9df7261ff8732 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -841,6 +841,19 @@ function openCodeErrorMessage(event: Extract, turn: ActiveOpenCodeTurn, @@ -2632,6 +2645,14 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function (state): state is OpenCodeThreadState => state !== undefined, ); const message = openCodeErrorMessage(event); + const error = event.properties.error; + const usageLimit = error?.name === "APIError" && error.data.statusCode === 429; + const resetAt = usageLimit + ? openCodeUsageResetAt( + error.data.responseHeaders?.["retry-after"], + yield* DateTime.now, + ) + : undefined; if ( !isMessageAbortedError(event) && (event.properties.sessionID === undefined || @@ -2648,8 +2669,9 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function { failure: makeProviderFailure({ message, - code: event.properties.error?.name ?? null, - class: "provider_error", + code: error?.name ?? null, + class: usageLimit ? "usage_limit" : "provider_error", + resetAt: resetAt ?? null, }), threadDisposition: event.properties.sessionID === undefined ? "broken" : "reusable", diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 2e7115a46c0f4..26dfa7cfd4c43 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -21,7 +21,6 @@ import { paceRemainingPercent, usesChatGptSharing, resolveNewThreadModelSelection, - exhaustedUsageWindow, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -53,30 +52,6 @@ function provider(overrides: Partial): ServerProvider { } describe("new thread model fallback", () => { - it("keeps Claude usable while overage is allowed", () => { - const claude = provider({ - driver: ProviderDriverKind.make("claudeAgent"), - usageLimits: { - checkedAt: "2026-09-03T11:00:00.000Z", - windows: [{ ...window, usedPercent: 100 }], - overageAllowed: true, - }, - }); - expect(exhaustedUsageWindow(claude, now, "claude-fable-5-1")).toBeUndefined(); - expect(exhaustedUsageWindow(claude, now)).toBeUndefined(); - for (const overageAllowed of [false, undefined]) { - expect( - exhaustedUsageWindow( - { ...claude, usageLimits: { ...claude.usageLimits!, overageAllowed } }, - now, - "claude-fable-5-1", - ), - ).toBeDefined(); - } - expect( - exhaustedUsageWindow({ ...claude, driver: ProviderDriverKind.make("codex") }, now), - ).toBeDefined(); - }); const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; const fallback = { instanceId: ProviderInstanceId.make("claude"), model: "claude-fable-5-1" }; const limited = provider({ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index e36164a7170ea..fffb2373a192b 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -26,7 +26,6 @@ export function exhaustedUsageWindow( now: number, model?: string, ) { - if (provider?.driver === "claudeAgent" && provider.usageLimits?.overageAllowed === true) return; const exhausted = (provider?.usageLimits?.windows ?? []).filter( (window) => window.usedPercent !== undefined && From c57da93d683732e859b5e9d7697fb624d9683090 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 28 Oct 2026 01:48:59 +0000 Subject: [PATCH 13/23] fix(cursor): treat the SDK's detailed rate and usage limit codes as usage limits --- .../provider-cursor/src/server/CursorAgentSdk.ts | 12 +++++++++--- packages/provider-cursor/src/server/adapter.test.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index a41783bbfb22a..5986d09506d09 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -43,12 +43,18 @@ export function cursorSdkFailureMessage(error: CursorAgentSdkRunnerError): strin : `Cursor Agent SDK ${error.method} failed: ${reason}`; } +// The SDK maps backend details to codes like PRO_USER_USAGE_LIMIT or RATE_LIMITED, without a status. +const CURSOR_USAGE_LIMIT_CODE = + /^(?:resource_exhausted|rate_limited(?:_changeable)?|\w*_usage_limit|\w*_rate_limit(?:_exceeded)?)$/i; + export function isCursorUsageLimitError(cause: unknown): boolean { const error = isCursorAgentSdkRunnerError(cause) ? cause.cause : cause; + if (typeof error !== "object" || error === null) return false; + const code = Reflect.get(error, "code"); return ( - typeof error === "object" && - error !== null && - (Reflect.get(error, "code") === "resource_exhausted" || Reflect.get(error, "status") === 429) + Reflect.get(error, "name") === "RateLimitError" || + Reflect.get(error, "status") === 429 || + (typeof code === "string" && CURSOR_USAGE_LIMIT_CODE.test(code)) ); } diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 989af53febcaa..93102367e0cfa 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -228,6 +228,15 @@ describe("CursorAdapterV2", () => { }), failure: { class: "usage_limit", message: "Usage limit reached" }, }, + { + reason: "the SDK's detailed plan usage limit code", + wait: Effect.succeed({ + id: "native-cursor-run", + status: "error" as const, + error: { message: "You've hit your usage limit", code: "PRO_USER_USAGE_LIMIT" }, + }), + failure: { class: "usage_limit", message: "You've hit your usage limit" }, + }, { reason: "an unrelated SDK transport error", wait: Effect.fail( From 43f7015d60c81434a06da145201529db220e5ee4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 28 Oct 2026 03:03:42 +0000 Subject: [PATCH 14/23] refactor(server): trim usage refresh tests and OpenCode reset parsing --- .../ProviderEventIngestor.test.ts | 85 ++------------ .../src/server/managedProvider.test.ts | 81 +++---------- .../src/server/adapter.test.ts | 22 ---- .../src/server/adapter.test.ts | 107 +++++++----------- .../provider-opencode/src/server/adapter.ts | 20 ---- 5 files changed, 67 insertions(+), 248 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 26caf80bd0e61..7f1006f1c5ec5 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -19,7 +19,6 @@ import { ProviderInstanceId, ProviderThreadId, ProviderTurnId, - type ServerProvider, RunAttemptId, RunId, RuntimeRequestId, @@ -33,7 +32,6 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlError from "effect/sql/SqlError"; import * as Stream from "effect/Stream"; @@ -51,8 +49,6 @@ import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; -import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; -import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderEventRoutingState, @@ -236,66 +232,7 @@ const layer = it.layer(layerTest); it.effect("refreshes the failing instance snapshot after a committed usage_limit terminal", () => Effect.scoped( Effect.gen(function* () { - const probeSnapshot = yield* Ref.make({ - instanceId: ProviderInstanceId.make("cursor-work"), - driver: ProviderDriverKind.make("cursor"), - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-04-10T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - usageLimits: { - checkedAt: "2026-04-10T00:00:00.000Z", - windows: [{ id: "apiPercentUsed", kind: "monthly", label: "API", usedPercent: 20 }], - }, - }); - const snapshot = yield* makeManagedServerProvider({ - resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), - getSettings: Effect.succeed({ enabled: true }), - streamSettings: Stream.empty, - haveSettingsChanged: () => false, - initialSnapshot: () => Ref.get(probeSnapshot), - checkProvider: Ref.get(probeSnapshot), - refreshInterval: "1 hour", - refreshOnInterval: false, - }).pipe( - Effect.provide( - Layer.mock(ProviderHost.ProviderHost)({ - resolveAttachmentPath: () => null, - paths: { - cwd: "/test", - baseDir: "/test", - stateDir: "/test/state", - providerStatusCacheDir: "/test/cache", - attachmentsDir: "/test/attachments", - }, - settings: { - get: Effect.die("unused"), - withSnapshot: () => Effect.die("unused"), - changes: Stream.empty, - subscribe: Effect.succeed(Stream.empty), - }, - }), - ), - ); - yield* snapshot.streamChanges.pipe(Stream.take(1), Stream.runDrain); - assert.equal((yield* snapshot.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); - yield* Ref.update(probeSnapshot, (current): ServerProvider => ({ - ...current, - usageLimits: { - checkedAt: "2026-04-10T00:01:00.000Z", - windows: [{ id: "apiPercentUsed", kind: "monthly", label: "API", usedPercent: 100 }], - }, - })); - const refreshed = yield* snapshot.streamChanges.pipe( - Stream.take(1), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); + const refreshed = yield* Deferred.make(); const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe( Effect.provide( Layer.mock(ProviderAdapter.ProviderAdapterV2)({ @@ -321,7 +258,14 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit generateThreadTitle: () => Effect.die("unused"), generateReviewGuide: () => Effect.die("unused"), }, - snapshot, + snapshot: { + refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid), + getSnapshot: Effect.die("unused"), + refresh: Effect.die("unused"), + resolveMaintenance: () => Effect.die("unused"), + applyUsageLimits: () => Effect.die("unused"), + streamChanges: Stream.empty, + }, }; const lookups: ProviderInstanceId[] = []; const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ @@ -365,11 +309,7 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit status: "failed", failure: makeProviderFailure({ class: failureClass, - ...(failureClass === "usage_limit" ? { code: "resource_exhausted" } : {}), - message: - failureClass === "usage_limit" - ? "Usage limit reached" - : "Provider rejected turn.", + message: "Provider rejected turn.", }), threadDisposition: "reusable", }, @@ -378,10 +318,7 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit if (failureClass === "validation_error") assert.deepEqual(lookups, []); } assert.deepEqual(lookups, [instance.instanceId]); - const [published] = yield* Fiber.join(refreshed); - assert.equal(published?.instanceId, instance.instanceId); - assert.equal(published?.usageLimits?.windows[0]?.usedPercent, 100); - assert.equal((yield* snapshot.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); + yield* Deferred.await(refreshed); }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); }), ), diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 3082221543da9..5ce743121d854 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -139,16 +139,22 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { - it.effect("refreshes usage past a warmed capabilities cache without waiting for its TTL", () => + it.effect("invalidates cached usage before probing and skips an in-flight refresh", () => Effect.scoped( Effect.gen(function* () { const calls = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); const cache = yield* Cache.makeWith( () => - Ref.updateAndGet(calls, (count) => count + 1).pipe( - Effect.map((count): ServerProvider => ({ + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count > 1) { + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + } + return { ...refreshedSnapshot, - driver: ProviderDriverKind.make("claudeAgent"), usageLimits: { checkedAt: refreshedSnapshot.checkedAt, windows: [ @@ -160,13 +166,13 @@ describe("makeManagedServerProvider", () => { }, ], }, - })), - ), + } satisfies ServerProvider; + }), { capacity: 1, timeToLive: () => "5 minutes" }, ); const warmed = yield* Cache.get(cache, "claude"); const provider = yield* makeManagedServerProvider({ - resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), getSettings: Effect.succeed({ enabled: true }), streamSettings: Stream.empty, haveSettingsChanged: () => false, @@ -175,73 +181,16 @@ describe("makeManagedServerProvider", () => { invalidateUsageCache: Cache.invalidateAll(cache), refreshOnInterval: false, }); - yield* provider.refresh; - assert.strictEqual(yield* Ref.get(calls), 1); - assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); - yield* provider.refreshUsage!; - assert.strictEqual(yield* Ref.get(calls), 2); - assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); - }), - ).pipe(Effect.provide(layerAlwaysRunTest)), - ); - it.effect("refreshes usage on demand and skips probes already in flight or unsupported", () => - Effect.scoped( - Effect.gen(function* () { - const calls = yield* Ref.make(0); - const probeStarted = yield* Deferred.make(); - const releaseProbe = yield* Deferred.make(); - const limits = { - checkedAt: "2026-04-10T00:00:01.000Z", - windows: [{ id: "five_hour", kind: "session", label: "Session", usedPercent: 100 }], - } as const; - const probeSnapshot = yield* Ref.make({ - ...refreshedSnapshotSecond, - usageLimits: { ...limits, checkedAt: "2026-04-10T00:00:03.000Z" }, - }); - const provider = yield* makeManagedServerProvider({ - resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), - getSettings: Effect.succeed({ enabled: true }), - streamSettings: Stream.empty, - haveSettingsChanged: () => false, - initialSnapshot: () => Effect.succeed({ ...initialSnapshot, usageLimits: limits }), - checkProvider: Ref.updateAndGet(calls, (count) => count + 1).pipe( - Effect.flatMap((count) => - count === 1 - ? Effect.succeed({ - ...refreshedSnapshot, - usageLimits: { - ...limits, - windows: [{ ...limits.windows[0], usedPercent: 20 }], - }, - }) - : Deferred.succeed(probeStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseProbe)), - Effect.andThen(Ref.get(probeSnapshot)), - ), - ), - ), - refreshOnInterval: false, - }); yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); - assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); + assert.strictEqual(yield* Ref.get(calls), 1); const refresh = yield* provider.refreshUsage!.pipe(Effect.forkChild); yield* Deferred.await(probeStarted); yield* provider.refreshUsage!; assert.strictEqual(yield* Ref.get(calls), 2); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); yield* Deferred.succeed(releaseProbe, undefined); yield* Fiber.join(refresh); assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); - yield* Ref.set(probeSnapshot, { - ...refreshedSnapshotSecond, - usageLimits: { ...limits, windows: [], unavailable: { reason: "unsupported" } }, - }); - yield* provider.refreshUsage!; - assert.strictEqual( - (yield* provider.getSnapshot).usageLimits?.unavailable?.reason, - "unsupported", - ); - yield* provider.refreshUsage!; - assert.strictEqual(yield* Ref.get(calls), 3); }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 93102367e0cfa..4c514bb16d665 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -219,15 +219,6 @@ describe("CursorAdapterV2", () => { ); it.effect.each([ - { - reason: "the serialized SDK resource exhaustion code", - wait: Effect.succeed({ - id: "native-cursor-run", - status: "error" as const, - error: { message: "Usage limit reached", code: "resource_exhausted" }, - }), - failure: { class: "usage_limit", message: "Usage limit reached" }, - }, { reason: "the SDK's detailed plan usage limit code", wait: Effect.succeed({ @@ -237,19 +228,6 @@ describe("CursorAdapterV2", () => { }), failure: { class: "usage_limit", message: "You've hit your usage limit" }, }, - { - reason: "an unrelated SDK transport error", - wait: Effect.fail( - new CursorAgentSdk.CursorAgentSdkRunnerError({ - method: "run.wait", - cause: Object.assign(new Error("Service unavailable"), { status: 503 }), - }), - ), - failure: { - class: "transport_error", - message: "Cursor Agent SDK run.wait failed: Service unavailable (HTTP 503)", - }, - }, { reason: "the error Cursor's run ended with", wait: Effect.succeed({ diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 646aeb9e145d0..9b957d307f061 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -239,74 +239,49 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { - it.effect.each([ - { - statusCode: 429, - headers: { "retry-after": "60" }, - failureClass: "usage_limit", - resetAt: "1970-01-01T00:01:00.000Z", - }, - { - statusCode: 429, - headers: { "retry-after": "Fri, 10 Apr 2026 12:00:00 GMT" }, - failureClass: "usage_limit", - resetAt: "2026-04-10T12:00:00.000Z", - }, - { - statusCode: 429, - headers: { "retry-after": "invalid" }, - failureClass: "usage_limit", - resetAt: null, - }, - { statusCode: 402, headers: {}, failureClass: "provider_error", resetAt: null }, - { statusCode: 500, headers: {}, failureClass: "provider_error", resetAt: null }, - ])( - "classifies APIError $statusCode with $headers", - ({ statusCode, headers, failureClass, resetAt }) => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const harness = yield* makeOpenCodeRuntimeHarness("usage-limit", "root", { - event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, - session: { - create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), - promptAsync: async () => ({ data: true }), - abort: async () => ({ data: true }), - children: async () => ({ data: [] }), - }, - }); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - Effect.forkScoped, - ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "session.error", - properties: { - sessionID: "root", - error: { - name: "APIError", - data: { - message: "Provider rejected request", - statusCode, - responseHeaders: headers, - isRetryable: true, - }, + it.effect("classifies APIError 429 as a usage limit", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness("usage-limit", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.error", + properties: { + sessionID: "root", + error: { + name: "APIError", + data: { + message: "Provider rejected request", + statusCode: 429, + isRetryable: true, }, }, - }), - ); - const terminal = (yield* Fiber.join(received)).find( - (event) => event.type === "turn.terminal", - ); - assert.equal(terminal?.status, "failed"); - assert.equal(terminal?.failure?.class, failureClass); - assert.equal(terminal?.failure?.resetAt ?? null, resetAt); - }).pipe( - Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), - Effect.scoped, - ), + }, + }), + ); + const terminal = (yield* Fiber.join(received)).find( + (event) => event.type === "turn.terminal", + ); + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.failure?.class, "usage_limit"); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), ); it.effect.each(["completed", "failed", "unavailable"] as const)( "normalizes OpenCode step usage for %s turns", diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 9df7261ff8732..25790d7101afe 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -841,19 +841,6 @@ function openCodeErrorMessage(event: Extract, turn: ActiveOpenCodeTurn, @@ -2647,12 +2634,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const message = openCodeErrorMessage(event); const error = event.properties.error; const usageLimit = error?.name === "APIError" && error.data.statusCode === 429; - const resetAt = usageLimit - ? openCodeUsageResetAt( - error.data.responseHeaders?.["retry-after"], - yield* DateTime.now, - ) - : undefined; if ( !isMessageAbortedError(event) && (event.properties.sessionID === undefined || @@ -2671,7 +2652,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function message, code: error?.name ?? null, class: usageLimit ? "usage_limit" : "provider_error", - resetAt: resetAt ?? null, }), threadDisposition: event.properties.sessionID === undefined ? "broken" : "reusable", From ab32a3d6e9209c36b25da82ba04abb33ad61e407 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 28 Oct 2026 03:08:24 +0000 Subject: [PATCH 15/23] refactor(server,web): trim model fallback to its essentials --- .../features/settings/scheduledTaskDraft.ts | 12 +- apps/server/src/mcp/OrchestratorMcpService.ts | 25 +-- .../src/mcp/toolkits/project/handlers.test.ts | 99 +++++----- .../src/serverRuntimeStartup.autoPull.test.ts | 2 - apps/server/src/serverRuntimeStartup.test.ts | 2 - apps/server/src/serverRuntimeStartup.ts | 16 +- .../CodexTextGeneration.test.ts | 152 ++++++---------- .../src/textGeneration/TextGeneration.ts | 125 +++++-------- apps/web/src/components/ChatView.tsx | 37 +--- .../settings/scheduledTasksSettings.logic.ts | 15 +- docs/user/outside-agents.md | 3 - packages/shared/src/serverSettings.test.ts | 46 ++--- packages/shared/src/serverSettings.ts | 22 +-- packages/shared/src/usageLimits.test.ts | 170 ++++++++---------- packages/shared/src/usageLimits.ts | 4 +- 15 files changed, 257 insertions(+), 473 deletions(-) diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index dcf153dc001b3..3ef1a2f3faf6e 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -27,8 +27,6 @@ import { resolveNewTaskModelSelection, } from "../../lib/modelOptions"; -import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; - export function scheduledTaskDefaultModel( config: ServerConfig | null, project: (LegacyProjectSettingsFields & { readonly id: ProjectId }) | null, @@ -39,7 +37,7 @@ export function scheduledTaskDefaultModel( const projectDefaultSelection = resolveDefaultableModelSelection(config, configured, project?.id ?? null) ?? resolveDefaultableModelSelection(config, settings.defaultModelSelection, project?.id ?? null); - const defaultSelection = resolveNewTaskModelSelection({ + return resolveNewTaskModelSelection({ draftSelection: null, projectDefaultSelection, stickySelection: null, @@ -50,14 +48,6 @@ export function scheduledTaskDefaultModel( project?.id ?? null, ), }); - return resolveNewThreadModelSelection({ - defaultSelection, - fallbackSelection: settings.fallbackModelSelection, - providers: config?.providers ?? [], - settings, - projectId: project?.id ?? null, - now: Date.now(), - }).selection; } export type ScheduleDraft = { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e95b723bee961..ea9e8a055641b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,5 +1,3 @@ -import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; -import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, type RunId, @@ -981,30 +979,17 @@ const make = Effect.gen(function* () { ), ); - const projectDefaultModelSelection = Effect.fn( - "OrchestratorMcpService.projectDefaultModelSelection", - )(function* (project: Effect.Success>) { - const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), - ); - const selection = resolveNewThreadModelSelection({ - defaultSelection: resolveProjectSettings(settings, project.id, project).settings - .defaultModelSelection, - fallbackSelection: settings.fallbackModelSelection, - providers: yield* providerRegistry.getProviders, - settings, - projectId: project.id, - now: DateTime.toEpochMillis(yield* DateTime.now), - }).selection; - return yield* selection === null + const projectDefaultModelSelection = ( + project: Effect.Success>, + ) => + project.defaultModelSelection === null ? Effect.fail( failure( "invalid_request", `Project ${project.id} has no default model, so this caller cannot pick one for it.`, ), ) - : Effect.succeed(selection); - }); + : Effect.succeed(project.defaultModelSelection); const secretRequests = yield* SecretRequests.SecretRequests; const requireCapability = (scope: McpInvocationScope) => diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 5e8d615888e0c..fa4d7922a5152 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -473,56 +473,51 @@ it.effect("a retried worktree launch replays after its base ref is gone", () => }), ); -it.effect.each([false, true])( - "a client only replaces a limited default, preserving an explicit model (%s)", - (explicit) => - Effect.gen(function* () { - const launched: ThreadLaunch.ThreadLaunchInput[] = []; - const fallbackModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; - const providers: ServerProvider[] = ["claudeAgent", "codex"].map((driver) => ({ - instanceId: ProviderInstanceId.make(driver), - driver: ProviderDriverKind.make(driver), - enabled: true, - installed: true, - status: "ready", - version: null, - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00Z", - models: [], - slashCommands: [], - skills: [], - ...(driver === "claudeAgent" - ? { - usageLimits: { - checkedAt: "2026-01-01T00:00:00Z", - windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], - }, - } - : {}), - })); - const { projectId, modelSelection, dependencies } = clientLaunchHarness({ - runtimeModeCeiling: "full-access", - launched, - fallbackModel, - providers, - }); - const toolkit = yield* ProjectToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( - Layer.provide(dependencies), - ), - ), - ); - const result = yield* toolkit - .handle("t3_thread_launch", { - projectId, - title: "Fallback", - ...(explicit ? { modelSelection } : {}), - }) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); - expect(result.at(-1)?.result).toMatchObject({ - modelSelection: explicit ? modelSelection : fallbackModel, - }); - expect(launched[0]?.modelSelection).toEqual(explicit ? modelSelection : fallbackModel); - }), +it.effect("a client launches with the fallback for an exhausted default", () => + Effect.gen(function* () { + const launched: ThreadLaunch.ThreadLaunchInput[] = []; + const fallbackModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const providers: ServerProvider[] = ["claudeAgent", "codex"].map((driver) => ({ + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(driver === "claudeAgent" + ? { + usageLimits: { + checkedAt: "2026-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + })); + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched, + fallbackModel, + providers, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", { + projectId, + title: "Fallback", + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + expect(result.at(-1)?.result).toMatchObject({ + modelSelection: fallbackModel, + }); + expect(launched[0]?.modelSelection).toEqual(fallbackModel); + }), ); diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index cdef8084ba4a6..59f6b67f04509 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -30,7 +30,6 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { ServerActivation } from "./serverActivation.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; -import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -73,7 +72,6 @@ it.effect("parks automatic pull until activation without delaying command readin const dependencies: Layer.Layer< Layer.Services> > = Layer.mergeAll( - Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ServerConfig.ServerConfig)({ ...(yield* ServerConfig.deriveServerPaths(cwd, undefined).pipe( Effect.provide(Path.layer), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b66a8c9d11128..274b149c66efc 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,4 +1,3 @@ -import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { @@ -70,7 +69,6 @@ it.effect.each([ }; const imports = { importedThreadCount: 0, importedMessageCount: 0 }; const dependencies = Layer.mergeAll( - Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), NodeCrypto.layer, Path.layer, Layer.mock(ServerConfig.ServerConfig)({ diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index ac0662c13c050..d8d62dc029c24 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -10,8 +10,6 @@ import { ThreadId, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; -import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -295,18 +293,8 @@ const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { projectId: project.id, title: "New thread", modelSelection: - resolveNewThreadModelSelection({ - defaultSelection: - resolveProjectSettings(settings, project.id, project).settings - .defaultModelSelection ?? threadModelSelection, - fallbackSelection: settings.fallbackModelSelection, - providers: yield* ProviderRegistry.ProviderRegistry.pipe( - Effect.flatMap((registry) => registry.getProviders), - ), - settings, - projectId: project.id, - now: DateTime.toEpochMillis(yield* DateTime.now), - }).selection ?? threadModelSelection, + resolveProjectSettings(settings, project.id, project).settings.defaultModelSelection ?? + threadModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: resolveProjectSettings(settings, project.id, project).settings .defaultRuntimeMode, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 389954840ddd0..04ae83952b3c4 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,13 +1,11 @@ import { - DEFAULT_SERVER_SETTINGS, DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, ProviderDriverKind, - TextGenerationError, + type ModelSelection, type ServerProvider, } from "@t3tools/contracts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -446,21 +444,21 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ); }); -it.effect.each([ - "usage-error", - "typed-quota", - "preflight", - "ordinary-error", - "retry-fails", -] as const)("text generation fallback: %s", (scenario) => +it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhausted) => Effect.gen(function* () { const primary = ProviderInstanceId.make("codex"); - const preferred = ProviderInstanceId.make("claude_work"); - const calls: string[] = []; - const snapshots: ServerProvider[] = [ - { - instanceId: primary, - driver: ProviderDriverKind.make("codex"), + const fallback = ProviderInstanceId.make("claude_work"); + const selection = { + instanceId: primary, + model: "chat-model", + options: [{ id: "effort", value: "high" }], + }; + const calls: ModelSelection[] = []; + const instances: ProviderInstance[] = [primary, fallback].map((instanceId) => { + const driver = ProviderDriverKind.make(instanceId === primary ? "codex" : "claudeAgent"); + const snapshot: ServerProvider = { + instanceId, + driver, enabled: true, installed: true, status: "ready", @@ -470,7 +468,7 @@ it.effect.each([ models: [], slashCommands: [], skills: [], - ...(scenario === "preflight" + ...(exhausted && instanceId === primary ? { usageLimits: { checkedAt: "1970-01-01T00:00:00Z", @@ -478,58 +476,28 @@ it.effect.each([ }, } : {}), - }, - { - instanceId: preferred, - driver: ProviderDriverKind.make("claudeAgent"), + }; + return { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated" }, - version: null, - checkedAt: "1970-01-01T00:00:00Z", - models: [], - slashCommands: [], - skills: [], - }, - ]; - const instances: ProviderInstance[] = snapshots.map((snapshot) => ({ - instanceId: snapshot.instanceId, - driverKind: snapshot.driver, - continuationIdentity: { driverKind: snapshot.driver, continuationKey: snapshot.instanceId }, - displayName: undefined, - enabled: true, - snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"], - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: TextGeneration.TextGeneration.of({ - generateCommitMessage: () => Effect.die("unused"), - generatePrContent: () => Effect.die("unused"), - generateBranchName: () => Effect.die("unused"), - generateReviewGuide: () => Effect.die("unused"), - generateThreadTitle: (input) => - Effect.gen(function* () { - calls.push(input.modelSelection.instanceId); - if (snapshot.instanceId === primary || scenario === "retry-fails") - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: - scenario === "ordinary-error" - ? "invalid credentials" - : scenario === "typed-quota" - ? "Provider request failed" - : "You've hit your usage limit", - ...(scenario === "typed-quota" && snapshot.instanceId === primary - ? { cause: { type: "provider.quota", message: "Budget exceeded", status: 429 } } - : {}), - }); - expect(input.modelSelection.model).toBe( - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], - ); - expect(input.modelSelection.options).toBeUndefined(); - return { title: "Fallback title" }; - }), - }), - })); + snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"], + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: TextGeneration.TextGeneration.of({ + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateBranchName: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + generateThreadTitle: (input) => + Effect.sync(() => { + calls.push(input.modelSelection); + return { title: "Generated title" }; + }), + }), + }; + }); const service = yield* TextGeneration.make.pipe( Effect.provide( Layer.mergeAll( @@ -538,17 +506,10 @@ it.effect.each([ getInstance: (id) => Effect.succeed(instances.find((instance) => instance.instanceId === id)), }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed(snapshots), - }), - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: Effect.succeed({ - ...DEFAULT_SERVER_SETTINGS, - fallbackModelSelection: { instanceId: preferred, model: "chat-model" }, - providerInstances: { - [preferred]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, - }, - }), + ServerSettings.layerTest({ + providerInstances: { + [fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, + }, }), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused"), @@ -556,27 +517,22 @@ it.effect.each([ ), ), ); - const result = yield* service - .generateThreadTitle({ + expect( + yield* service.generateThreadTitle({ cwd: "/tmp", message: "hello", linkedContext: "", - modelSelection: { - instanceId: primary, - model: "chat-model", - options: [{ id: "effort", value: "high" }], - }, - }) - .pipe(Effect.result); - expect(calls).toEqual( - scenario === "preflight" - ? [preferred] - : scenario === "ordinary-error" - ? [primary] - : [primary, preferred], - ); - expect(result._tag).toBe( - scenario === "ordinary-error" || scenario === "retry-fails" ? "Failure" : "Success", - ); + modelSelection: selection, + }), + ).toEqual({ title: "Generated title" }); + expect(calls).toEqual([ + exhausted + ? { + instanceId: fallback, + model: + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], + } + : selection, + ]); }), ); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 667626f09b184..1ea0af4e304ba 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -9,10 +9,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; -import { - defaultTextGenerationModel, - fallbackTextGenerationModelSelection, -} from "@t3tools/shared/serverSettings"; +import { fallbackTextGenerationModelSelection } from "@t3tools/shared/serverSettings"; import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -91,11 +88,7 @@ export const make = Effect.gen(function* () { now, selection.model, ); - const call = (modelSelection: ModelSelection) => - resolveInstance(registry, operation, modelSelection.instanceId).pipe( - Effect.flatMap((service) => invoke(service, modelSelection)), - ); - const retry = Effect.fn("TextGeneration.retryLimitedProvider")(function* () { + if (limited) { const settings = yield* serverSettings.getSettings.pipe( Effect.mapError( (cause) => @@ -106,70 +99,28 @@ export const make = Effect.gen(function* () { }), ), ); - const fallback = fallbackTextGenerationModelSelection( - settings.providerInstances, - (instanceId, driver) => { - const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); - return ( - instanceId !== selection.instanceId && - provider?.enabled === true && - resolveProjectProviderInstanceEnabled(settings, null, instanceId) && - provider.installed && - provider.auth.status !== "unauthenticated" && - isProviderAvailable(provider) && - isProviderTextGenerationCapable(provider) && - (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && - !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) - ); - }, - { - includeConfiguredInstances: true, - ...(settings.fallbackModelSelection - ? { preferredInstanceId: settings.fallbackModelSelection.instanceId } - : {}), - }, - ); - return fallback; - }); - if (limited) { - const fallback = yield* retry(); - if (fallback) return yield* call(fallback); - return yield* call(selection); + selection = + fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId, driver) => { + const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); + return ( + instanceId !== selection.instanceId && + provider?.enabled === true && + resolveProjectProviderInstanceEnabled(settings, null, instanceId) && + provider.installed && + provider.auth.status !== "unauthenticated" && + isProviderAvailable(provider) && + isProviderTextGenerationCapable(provider) && + (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && + !exhaustedUsageWindow(provider, now) + ); + }, + { includeConfiguredInstances: true }, + ) ?? selection; } - return yield* call(selection).pipe( - Effect.catchTags({ - TextGenerationError: Effect.fn("TextGeneration.handleFailure")(function* (error) { - let cause: unknown = error; - let usageLimited = false; - for (let depth = 0; depth < 8 && cause && typeof cause === "object"; depth++) { - const failure = cause as { - class?: unknown; - type?: unknown; - status?: unknown; - code?: unknown; - detail?: unknown; - message?: unknown; - cause?: unknown; - }; - usageLimited ||= - failure.class === "usage_limit" || - failure.type === "provider.rate-limit" || - // OpenCode also types a never-resetting empty balance as quota, with HTTP 402. - (failure.type === "provider.quota" && failure.status !== 402) || - [failure.code, failure.detail, failure.message].some( - (text) => - typeof text === "string" && - /usage[_ -]?limit|rate[_ -]?limit|quota.{0,30}(?:exceed|exhaust)|(?:exceed|exhaust).{0,30}quota|out of (?:usage|credits)|(?:you've hit|reached) your limit|too many requests/i.test( - text, - ), - ); - cause = failure.cause; - } - if (!usageLimited) return yield* error; - const fallback = yield* retry(); - return fallback ? yield* call(fallback) : yield* error; - }), - }), + return yield* resolveInstance(registry, operation, selection.instanceId).pipe( + Effect.flatMap((service) => invoke(service, selection)), ); }); return TextGeneration.of({ @@ -193,20 +144,26 @@ export const make = Effect.gen(function* () { service.generateReviewGuide({ ...input, modelSelection }), ); }), - generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function* (input) { - const linkedContext = - input.linkedContext ?? - (yield* ThreadTitleLinks.resolveThreadTitleLinks(input).pipe( - Effect.provideService( - SourceControlProviderRegistry.SourceControlProviderRegistry, - sourceControl, - ), - )); - return yield* generate( + generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function (input) { + return generate( "generateThreadTitle", input.modelSelection, - (service, modelSelection) => - service.generateThreadTitle({ ...input, linkedContext, modelSelection }), + (textGeneration, modelSelection) => + Effect.gen(function* () { + const linkedContext = + input.linkedContext ?? + (yield* ThreadTitleLinks.resolveThreadTitleLinks(input).pipe( + Effect.provideService( + SourceControlProviderRegistry.SourceControlProviderRegistry, + sourceControl, + ), + )); + return yield* textGeneration.generateThreadTitle({ + ...input, + linkedContext, + modelSelection, + }); + }), ); }), }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d24fd53a46da8..093dda43872ff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -760,36 +760,15 @@ export default function ChatView(props: ChatViewProps) { }); } } - const nowMinute = useNowMinute(); - const draftDefaultModelSelection = draftThread - ? resolveProjectSettings( - settings, - fallbackDraftProject?.id ?? null, - fallbackDraftProject ?? undefined, - ).settings.defaultModelSelection - : null; - const draftFallback = draftThread - ? resolveNewThreadModelSelection({ - defaultSelection: - draftDefaultModelSelection ?? - resolveProjectDefaultProviderModelSelection( - providersByEnvironment.get(draftThread.environmentId) ?? [], - settings, - draftThread.projectId, - ), - fallbackSelection: settings.fallbackModelSelection, - providers: providersByEnvironment.get(draftThread.environmentId) ?? [], - settings, - projectId: draftThread.projectId, - now: Date.parse(`${nowMinute}:00Z`), - }) - : null; const localDraftThread = draftThread ? buildLocalDraftThread( threadId, draftThread, - (draftFallback?.fallback ? draftFallback.selection : draftDefaultModelSelection) ?? - NO_PROVIDER_MODEL_SELECTION, + resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined; const isServerThread = serverThread !== null; @@ -1081,6 +1060,7 @@ export default function ChatView(props: ChatViewProps) { const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; const { projectCloneBannerItem, projectCloneSendBlockReason } = useProjectCloneBanner(activeProjectRef); + const nowMinute = useNowMinute(); const defaultModelResolution = resolveNewThreadModelSelection({ defaultSelection: activeProjectSettings.settings.defaultModelSelection ?? @@ -1095,10 +1075,7 @@ export default function ChatView(props: ChatViewProps) { projectId: activeProject?.id ?? null, now: Date.parse(`${nowMinute}:00Z`), }); - const activeProjectDefaultModelSelection = - !isServerThread && defaultModelResolution.fallback - ? defaultModelResolution.selection - : activeProjectSettings.settings.defaultModelSelection; + const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); }; diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 39fa2eedca1f8..218e2a6e683b0 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -252,8 +252,6 @@ export function taskToDraft(task: ScheduledTask): DraftState { }; } -import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; - export function scheduledTaskDefaultModel( settings: ServerSettings, project: (LegacyProjectSettingsFields & { readonly id: ProjectId }) | null, @@ -266,15 +264,6 @@ export function scheduledTaskDefaultModel( entry.isAvailable && entry.snapshot.auth.status !== "unauthenticated", ); - const resolve = (defaultSelection: ModelSelection | null) => - resolveNewThreadModelSelection({ - defaultSelection, - fallbackSelection: settings.fallbackModelSelection, - providers: entries.map((entry) => entry.snapshot), - settings, - projectId: project?.id ?? null, - now: Date.now(), - }).selection; const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings .defaultModelSelection; for (const selection of [configured, settings.defaultModelSelection]) { @@ -286,7 +275,7 @@ export function scheduledTaskDefaultModel( entry.models.find((model) => model.slug === selection.model)?.isLegacy !== true, ) ) - return resolve(selection); + return selection; } const models = available.flatMap((entry) => entry.models @@ -294,5 +283,5 @@ export function scheduledTaskDefaultModel( .map((model) => ({ instanceId: entry.instanceId, model })), ); const fallback = models.find(({ model }) => model.isDefault) ?? models[0]; - return resolve(fallback ? { instanceId: fallback.instanceId, model: fallback.model.slug } : null); + return fallback ? { instanceId: fallback.instanceId, model: fallback.model.slug } : null; } diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index 80a2c72cf7419..f040f2218058d 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -110,9 +110,6 @@ runs in the project folder unless you pass `--worktree `. `send` never interrupts: a busy thread gets the message as its next turn. Pass `-` as the message to read it from stdin. -Set a fallback model in Settings > Threads to use it for new threads when the -default provider is out of usage. This environment setting also applies to project defaults. - `--wait` prints the agent's reply. It exits 1 when the run fails or stops, and 2 when the agent waits for an answer you give in the app. Add `--json` for a result that always includes the thread id. diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 025d0ed9e060d..c29b21250753b 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,6 +1,5 @@ import { DEFAULT_SERVER_SETTINGS, - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -27,39 +26,22 @@ import { const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { - it("prefers the configured fallback instance with its driver's text generation model", () => { - const preferred = ProviderInstanceId.make("claude_work"); - expect( - fallbackTextGenerationModelSelection( - { [preferred]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} } }, - () => true, - { includeConfiguredInstances: true, preferredInstanceId: preferred }, - ), - ).toEqual({ - instanceId: preferred, - model: DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], - }); - }); - it("replaces and clears the fallback without merging the previous model's options", () => { - const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - fallbackModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "first", - options: [{ id: "effort", value: "high" }], - }, - }); - const second = applyServerSettingsPatch(first, { - fallbackModelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "second", + it("replaces and clears the fallback without carrying model options", () => { + const fallbackModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "new" }; + const settings = applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + fallbackModelSelection: { + ...fallbackModelSelection, + model: "old", + options: [{ id: "effort", value: "high" }], + }, }, - }); - expect(second.fallbackModelSelection).toEqual({ - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "second", - }); + { fallbackModelSelection }, + ); + expect(settings.fallbackModelSelection).toEqual(fallbackModelSelection); expect( - applyServerSettingsPatch(second, { fallbackModelSelection: null }).fallbackModelSelection, + applyServerSettingsPatch(settings, { fallbackModelSelection: null }).fallbackModelSelection, ).toBeNull(); }); it("ignores a configured non-default instance at machine scope, matching no fallback", () => { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index d61e5f88d15dd..893e9ceb3da4b 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -96,7 +96,6 @@ export function fallbackTextGenerationModelSelection( options?: { readonly includeConfiguredInstances?: boolean; readonly defaultModelSelection?: ModelSelection | null; - readonly preferredInstanceId?: ProviderInstanceId; }, ): ModelSelection | null { const candidates = [ @@ -111,10 +110,7 @@ export function fallbackTextGenerationModelSelection( ) : []), ]; - const preferred = candidates.find(([instanceId]) => instanceId === options?.preferredInstanceId); - const match = [...(preferred ? [preferred] : []), ...candidates].find(([instanceId, driver]) => - isEnabled(instanceId, driver), - ); + const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); if (match === undefined) return null; const [instanceId, driver] = match; const threadDefault = options?.defaultModelSelection; @@ -125,15 +121,13 @@ export function fallbackTextGenerationModelSelection( ) { return threadDefault; } - return { instanceId, model: defaultTextGenerationModel(driver) }; -} - -export function defaultTextGenerationModel(driver: ProviderDriverKind): string { - return ( - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_TEXT_GENERATION_MODEL - ); + return { + instanceId, + model: + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL, + }; } export function resolveSourceControlWriterModelSelection( diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 26dfa7cfd4c43..5da7bc2cc02db 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, ProviderDriverKind, ProviderInstanceId, @@ -17,6 +18,7 @@ import { collectLimitNotices, collectLimitPools, elapsedShare, + exhaustedUsageWindow, paceOf, paceRemainingPercent, usesChatGptSharing, @@ -65,111 +67,89 @@ describe("new thread model fallback", () => { driver: ProviderDriverKind.make("claudeAgent"), }); - it("uses an available fallback for an exhausted default", () => { - expect( - resolveNewThreadModelSelection({ - defaultSelection: selection, - fallbackSelection: fallback, - providers: [limited, available], - now, - }).selection, - ).toEqual(fallback); - }); - it("keeps the default without a usable fallback", () => { - for (const candidate of [null, fallback]) { - expect( - resolveNewThreadModelSelection({ - defaultSelection: selection, - fallbackSelection: candidate, - providers: [limited, { ...available, usageLimits: limited.usageLimits }], - now, - }).selection, - ).toEqual(selection); - } + const defaults = { + defaultSelection: selection, + fallbackSelection: fallback, + providers: [limited, available], + now, + }; + it.each([ + ["exhausted default", {}, fallback], + ["explicit selection", { explicitSelection: selection }, selection], + ["missing default", { defaultSelection: null }, null], + ["no fallback", { fallbackSelection: null }, selection], + ["expired window", { now: Date.parse("2026-09-04T00:00:00Z") }, selection], + [ + "environment disabled", + { + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [fallback.instanceId]: { driver: available.driver, enabled: false, config: {} }, + }, + }, + }, + selection, + ], + ] as const)("resolves %s", (_name, overrides, expected) => { + expect(resolveNewThreadModelSelection({ ...defaults, ...overrides }).selection).toEqual( + expected, + ); }); - it("keeps explicit selections and ignores expired windows", () => { - expect( - resolveNewThreadModelSelection({ - defaultSelection: selection, - explicitSelection: selection, - fallbackSelection: fallback, - providers: [limited, available], - now, - }).selection, - ).toEqual(selection); + it.each([ + { enabled: false }, + { installed: false }, + { auth: { status: "unauthenticated" as const } }, + { availability: "unavailable" as const }, + { usageLimits: limited.usageLimits }, + ])("rejects an unusable fallback: %j", (overrides) => { expect( resolveNewThreadModelSelection({ - defaultSelection: selection, - fallbackSelection: fallback, - providers: [limited, available], - now: Date.parse("2026-09-04T00:00:00Z"), + ...defaults, + providers: [limited, { ...available, ...overrides }], }).selection, ).toEqual(selection); }); - it("treats an unknown reset as exhausted and rejects disabled or unavailable fallbacks", () => { - const unknown = { - ...limited, - usageLimits: { - ...limited.usageLimits!, - windows: [{ ...window, usedPercent: 100, resetsAt: undefined }], - }, - }; + it.each([undefined, "invalid"])("treats an unknown reset as exhausted: %s", (resetsAt) => { expect( - resolveNewThreadModelSelection({ - defaultSelection: selection, - fallbackSelection: fallback, - providers: [unknown, available], + exhaustedUsageWindow( + provider({ + usageLimits: { + checkedAt: limited.checkedAt, + windows: [{ ...window, usedPercent: 100, resetsAt }], + }, + }), now, - }).selection, - ).toEqual(fallback); - for (const candidate of [ - { ...available, enabled: false }, - { ...available, installed: false }, - { ...available, auth: { status: "unauthenticated" as const } }, - ]) { - expect( - resolveNewThreadModelSelection({ - defaultSelection: selection, - fallbackSelection: fallback, - providers: [unknown, candidate], - now, - }).selection, - ).toEqual(selection); - } + ), + ).toBeDefined(); }); - it("only counts the usage pools the default model draws from", () => { - const pools = (driver: string, ids: readonly string[]) => - provider({ - driver: ProviderDriverKind.make(driver), - usageLimits: { - checkedAt: "2026-09-03T11:00:00.000Z", - windows: ids.map((id) => ({ ...window, id, usedPercent: 100 })), - }, - }); - const resolve = (limited: ServerProvider, model: string) => + it.each([ + ["cursor", ["autoPercentUsed"], "claude-opus-5-5", false], + ["cursor", ["autoPercentUsed"], "composer-2.5", false], + ["cursor", ["autoPercentUsed", "apiPercentUsed"], "composer-2.5", true], + ["antigravity", ["gemini-weekly"], "claude-opus-5-5", false], + ["antigravity", ["3p-weekly"], "claude-opus-5-5", true], + ["antigravity", ["gemini-weekly"], "antigravity-default", true], + ["opencode", ["go_weekly"], "opencode/big-pickle", false], + ["opencode", ["go_weekly"], "opencode-go/big-pickle", true], + ] as const)("matches %s pools %s to %s", (driver, ids, model, usesFallback) => { + const defaultSelection = { ...selection, model }; + expect( resolveNewThreadModelSelection({ - defaultSelection: { instanceId: limited.instanceId, model }, - fallbackSelection: fallback, - providers: [limited, available], - now, - }).selection?.model; - expect(resolve(pools("cursor", ["autoPercentUsed"]), "claude-opus-5-5")).toBe( - "claude-opus-5-5", - ); - expect(resolve(pools("cursor", ["autoPercentUsed"]), "composer-2.5")).toBe("composer-2.5"); - expect(resolve(pools("cursor", ["autoPercentUsed", "apiPercentUsed"]), "composer-2.5")).toBe( - fallback.model, - ); - expect(resolve(pools("antigravity", ["gemini-weekly"]), "claude-opus-5-5")).toBe( - "claude-opus-5-5", - ); - expect(resolve(pools("antigravity", ["3p-weekly"]), "claude-opus-5-5")).toBe(fallback.model); - expect(resolve(pools("antigravity", ["gemini-weekly"]), "antigravity-default")).toBe( - fallback.model, - ); - expect(resolve(pools("opencode", ["go_weekly"]), "opencode/big-pickle")).toBe( - "opencode/big-pickle", - ); + ...defaults, + defaultSelection, + providers: [ + provider({ + driver: ProviderDriverKind.make(driver), + usageLimits: { + checkedAt: limited.checkedAt, + windows: ids.map((id) => ({ ...window, id, usedPercent: 100 })), + }, + }), + available, + ], + }).selection, + ).toEqual(usesFallback ? fallback : defaultSelection); }); }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index fffb2373a192b..3dca207a2efb7 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -28,14 +28,12 @@ export function exhaustedUsageWindow( ) { const exhausted = (provider?.usageLimits?.windows ?? []).filter( (window) => - window.usedPercent !== undefined && - window.usedPercent >= 100 && + (window.usedPercent ?? 0) >= 100 && (!window.resetsAt || !Number.isFinite(Date.parse(window.resetsAt)) || Date.parse(window.resetsAt) > now), ); if (model === undefined) return exhausted[0]; - // Some drivers report separate allowances per model family; only the ones this model draws from count. switch (provider?.driver) { case "cursor": { const api = exhausted.find((window) => window.id === "apiPercentUsed"); From 56469c8bac5ed84ef55fb90fda1ef206a4ed690f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 28 Oct 2026 03:22:54 +0000 Subject: [PATCH 16/23] fix(server,web): keep the draft on the fallback and check text generation candidates per model --- .../src/textGeneration/TextGeneration.ts | 7 +- apps/web/src/components/ChatView.tsx | 66 +++++++++++-------- packages/shared/src/serverSettings.ts | 16 +++-- 3 files changed, 52 insertions(+), 37 deletions(-) diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 1ea0af4e304ba..fc8d0fb68da31 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -9,7 +9,10 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; -import { fallbackTextGenerationModelSelection } from "@t3tools/shared/serverSettings"; +import { + defaultTextGenerationModel, + fallbackTextGenerationModelSelection, +} from "@t3tools/shared/serverSettings"; import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -113,7 +116,7 @@ export const make = Effect.gen(function* () { isProviderAvailable(provider) && isProviderTextGenerationCapable(provider) && (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && - !exhaustedUsageWindow(provider, now) + !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) ); }, { includeConfiguredInstances: true }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 093dda43872ff..17419a34939e9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -760,15 +760,37 @@ export default function ChatView(props: ChatViewProps) { }); } } + const nowMinute = useNowMinute(); + const draftDefaultModelSelection = draftThread + ? resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection + : null; + const draftModelResolution = draftThread + ? resolveNewThreadModelSelection({ + defaultSelection: + draftDefaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + draftThread.projectId, + ), + fallbackSelection: settings.fallbackModelSelection, + providers: providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + projectId: draftThread.projectId, + now: Date.parse(`${nowMinute}:00Z`), + }) + : null; + const draftFallback = draftModelResolution?.fallback ? draftModelResolution : null; const localDraftThread = draftThread ? buildLocalDraftThread( threadId, draftThread, - resolveProjectSettings( - settings, - fallbackDraftProject?.id ?? null, - fallbackDraftProject ?? undefined, - ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + (draftFallback ? draftFallback.selection : draftDefaultModelSelection) ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined; const isServerThread = serverThread !== null; @@ -1060,22 +1082,10 @@ export default function ChatView(props: ChatViewProps) { const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; const { projectCloneBannerItem, projectCloneSendBlockReason } = useProjectCloneBanner(activeProjectRef); - const nowMinute = useNowMinute(); - const defaultModelResolution = resolveNewThreadModelSelection({ - defaultSelection: - activeProjectSettings.settings.defaultModelSelection ?? - resolveProjectDefaultProviderModelSelection( - providersByEnvironment.get(activeThread?.environmentId ?? environmentId) ?? [], - settings, - activeProject?.id ?? null, - ), - fallbackSelection: settings.fallbackModelSelection, - providers: providersByEnvironment.get(activeThread?.environmentId ?? environmentId) ?? [], - settings, - projectId: activeProject?.id ?? null, - now: Date.parse(`${nowMinute}:00Z`), - }); - const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; + const activeProjectDefaultModelSelection = + !isServerThread && draftFallback + ? draftFallback.selection + : activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); }; @@ -2548,25 +2558,25 @@ export default function ChatView(props: ChatViewProps) { }) : null; const composerBannerItems = (() => { + const fallbackSelection = draftFallback?.selection; const fallback = !isServerThread && multipleModelSelections === null && !composerModelSelectionExplicit && - defaultModelResolution.fallback && + draftFallback?.fallback && (!composerSelectedModelSelection || - (composerSelectedModelSelection.instanceId === - defaultModelResolution.selection?.instanceId && - composerSelectedModelSelection.model === defaultModelResolution.selection?.model)) - ? defaultModelResolution.fallback + (composerSelectedModelSelection.instanceId === fallbackSelection?.instanceId && + composerSelectedModelSelection.model === fallbackSelection?.model)) + ? draftFallback.fallback : null; const fallbackItems: ComposerBannerStackItem[] = - fallback && defaultModelResolution.selection + fallback && fallbackSelection ? [ { id: "fallback-model", variant: "info", icon: null, - title: `${fallback.provider.displayName ?? fallback.provider.driver} is out of usage${fallback.resetsAt ? ` until ${new Date(fallback.resetsAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}` : ""}. Using ${providerStatuses.find((provider) => provider.instanceId === defaultModelResolution.selection?.instanceId)?.models.find((model) => model.slug === defaultModelResolution.selection?.model)?.name ?? defaultModelResolution.selection.model}.`, + title: `${fallback.provider.displayName ?? fallback.provider.driver} is out of usage${fallback.resetsAt ? ` until ${new Date(fallback.resetsAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}` : ""}. Using ${providerStatuses.find((provider) => provider.instanceId === fallbackSelection.instanceId)?.models.find((model) => model.slug === fallbackSelection.model)?.name ?? fallbackSelection.model}.`, }, ] : []; diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 893e9ceb3da4b..9debb24be1dbc 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -121,13 +121,15 @@ export function fallbackTextGenerationModelSelection( ) { return threadDefault; } - return { - instanceId, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_TEXT_GENERATION_MODEL, - }; + return { instanceId, model: defaultTextGenerationModel(driver) }; +} + +export function defaultTextGenerationModel(driver: ProviderDriverKind): string { + return ( + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL + ); } export function resolveSourceControlWriterModelSelection( From fe8bc024fe26abdd2a7cd216cc8c10757bfe5264 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 28 Oct 2026 05:01:06 +0000 Subject: [PATCH 17/23] fix(web): give the fallback model notice the usage notice layout and a way back --- apps/web/src/components/ChatView.tsx | 31 ++++++++++++++----- .../components/chat/ComposerUsageLimits.tsx | 31 +++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bcfc36293c31e..4557f0c15e983 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -310,6 +310,7 @@ import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; import { @@ -2550,6 +2551,13 @@ export default function ChatView(props: ChatViewProps) { : null; const composerBannerItems = (() => { const fallbackSelection = draftFallback?.selection; + const defaultSelection = + draftDefaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providerStatuses, + settings, + draftThread?.projectId ?? null, + ); const fallback = !isServerThread && multipleModelSelections === null && @@ -2561,14 +2569,23 @@ export default function ChatView(props: ChatViewProps) { ? draftFallback.fallback : null; const fallbackItems: ComposerBannerStackItem[] = - fallback && fallbackSelection + fallback && fallbackSelection && defaultSelection ? [ - { - id: "fallback-model", - variant: "info", - icon: null, - title: `${fallback.provider.displayName ?? fallback.provider.driver} is out of usage${fallback.resetsAt ? ` until ${new Date(fallback.resetsAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}` : ""}. Using ${providerStatuses.find((provider) => provider.instanceId === fallbackSelection.instanceId)?.models.find((model) => model.slug === fallbackSelection.model)?.name ?? fallbackSelection.model}.`, - }, + modelFallbackBannerItem({ + fallbackModelName: + providerStatuses + .find((provider) => provider.instanceId === fallbackSelection.instanceId) + ?.models.find((model) => model.slug === fallbackSelection.model)?.name ?? + fallbackSelection.model, + defaultProviderName: fallback.provider.displayName ?? fallback.provider.driver, + defaultModelName: + fallback.provider.models.find((model) => model.slug === defaultSelection.model) + ?.name ?? defaultSelection.model, + resetsAt: fallback.resetsAt, + timestampFormat, + onUseDefault: () => + onProviderModelSelectStable(defaultSelection.instanceId, defaultSelection.model), + }), ] : []; const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 294816c140a29..908a03024790f 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -1,8 +1,10 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; import { limitsNotice } from "@t3tools/shared/usageLimits"; import { GaugeIcon } from "lucide-react"; import { ensureLocalApi } from "../../localApi"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; @@ -67,6 +69,35 @@ export function usageLimitsBannerItem( }; } +export function modelFallbackBannerItem({ + fallbackModelName, + defaultProviderName, + defaultModelName, + resetsAt, + timestampFormat, + onUseDefault, +}: { + readonly fallbackModelName: string; + readonly defaultProviderName: string; + readonly defaultModelName: string; + readonly resetsAt: string | null; + readonly timestampFormat: TimestampFormat; + readonly onUseDefault: () => void; +}): ComposerBannerStackItem { + return { + id: "fallback-model", + variant: "info", + icon: , + title: `Using ${fallbackModelName}`, + description: `${defaultProviderName} is out of usage${resetsAt ? ` ยท resets ${formatUpcomingTimestamp(resetsAt, timestampFormat)}` : ""}`, + actions: ( + + ), + }; +} + function UsageLimitsBannerBody({ report, environmentId, From ed3f6ded5466dbe486652c2d9963495101afc356 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 01:20:58 +0000 Subject: [PATCH 18/23] fix(web): let the fallback model notice be dismissed until its usage window resets Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 14 +++++++++++++- .../src/components/chat/ComposerUsageLimits.tsx | 4 ++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4557f0c15e983..c8424ce265095 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1661,6 +1661,11 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed, ]); + const [dismissedFallbackWindow, setDismissedFallbackWindow] = useLocalStorage( + `t3code:fallback-notice-dismissed:${environmentId}`, + null, + Schema.NullOr(Schema.String), + ); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -2568,8 +2573,14 @@ export default function ChatView(props: ChatViewProps) { composerSelectedModelSelection.model === fallbackSelection?.model)) ? draftFallback.fallback : null; + const fallbackWindow = fallback + ? `${fallback.provider.instanceId}:${fallback.resetsAt ?? ""}` + : null; const fallbackItems: ComposerBannerStackItem[] = - fallback && fallbackSelection && defaultSelection + fallback && + fallbackSelection && + defaultSelection && + fallbackWindow !== dismissedFallbackWindow ? [ modelFallbackBannerItem({ fallbackModelName: @@ -2585,6 +2596,7 @@ export default function ChatView(props: ChatViewProps) { timestampFormat, onUseDefault: () => onProviderModelSelectStable(defaultSelection.instanceId, defaultSelection.model), + onDismiss: () => setDismissedFallbackWindow(fallbackWindow), }), ] : []; diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 908a03024790f..12c11d213cdf7 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -76,6 +76,7 @@ export function modelFallbackBannerItem({ resetsAt, timestampFormat, onUseDefault, + onDismiss, }: { readonly fallbackModelName: string; readonly defaultProviderName: string; @@ -83,6 +84,7 @@ export function modelFallbackBannerItem({ readonly resetsAt: string | null; readonly timestampFormat: TimestampFormat; readonly onUseDefault: () => void; + readonly onDismiss: () => void; }): ComposerBannerStackItem { return { id: "fallback-model", @@ -95,6 +97,8 @@ export function modelFallbackBannerItem({ Use {defaultModelName} ), + dismissLabel: "Dismiss fallback model notice", + onDismiss, }; } From d0363d21431caa840a352ec4e6e0b37362d6fcd2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 01:23:53 +0000 Subject: [PATCH 19/23] fix(web): hoist the fallback notice dismissal schema Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c8424ce265095..85a5e1125d4b1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -406,6 +406,8 @@ type ChatViewProps = draftId: DraftId; }; +const DismissedFallbackWindowSchema = Schema.NullOr(Schema.String); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1664,7 +1666,7 @@ export default function ChatView(props: ChatViewProps) { const [dismissedFallbackWindow, setDismissedFallbackWindow] = useLocalStorage( `t3code:fallback-notice-dismissed:${environmentId}`, null, - Schema.NullOr(Schema.String), + DismissedFallbackWindowSchema, ); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< From 86b3a5735ecb0d1f0febd3f4a599b486696a17da Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 01:51:53 +0000 Subject: [PATCH 20/23] fix(server): queue a usage refresh behind an in-flight provider refresh Co-Authored-By: Claude Opus 5.5 --- .../src/server/managedProvider.test.ts | 9 +++++++-- .../src/server/managedProvider.ts | 18 ++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 5ce743121d854..5f5946a80cce9 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -139,7 +139,7 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { - it.effect("invalidates cached usage before probing and skips an in-flight refresh", () => + it.effect("invalidates cached usage and queues one refresh behind an in-flight refresh", () => Effect.scoped( Effect.gen(function* () { const calls = yield* Ref.make(0); @@ -149,7 +149,7 @@ describe("makeManagedServerProvider", () => { () => Effect.gen(function* () { const count = yield* Ref.updateAndGet(calls, (count) => count + 1); - if (count > 1) { + if (count === 2) { yield* Deferred.succeed(probeStarted, undefined); yield* Deferred.await(releaseProbe); } @@ -185,11 +185,16 @@ describe("makeManagedServerProvider", () => { assert.strictEqual(yield* Ref.get(calls), 1); const refresh = yield* provider.refreshUsage!.pipe(Effect.forkChild); yield* Deferred.await(probeStarted); + const queued = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); yield* provider.refreshUsage!; assert.strictEqual(yield* Ref.get(calls), 2); assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); yield* Deferred.succeed(releaseProbe, undefined); yield* Fiber.join(refresh); + yield* Fiber.join(queued); + assert.strictEqual(yield* Ref.get(calls), 3); assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); }), ).pipe(Effect.provide(layerAlwaysRunTest)), diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 04e08273a3f0a..c270296b018ce 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -61,6 +61,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( > { const host = yield* ProviderHost.ProviderHost; const refreshSemaphore = yield* Semaphore.make(1); + const usageRefreshQueued = yield* Ref.make(false); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -225,12 +226,17 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( snapshot.usageLimits.unavailable?.reason === "unsupported" ) return; - yield* refreshSemaphore.withPermitsIfAvailable(1)( - input.getSettings.pipe( - Effect.tap(() => input.invalidateUsageCache ?? Effect.void), - Effect.flatMap((settings) => applySnapshotBase(settings, { forceRefresh: true })), - ), - ); + // An in-flight refresh may have read cached usage, so one refresh waits behind it. + if (yield* Ref.getAndSet(usageRefreshQueued, true)) return; + yield* refreshSemaphore + .withPermits(1)( + Ref.set(usageRefreshQueued, false).pipe( + Effect.andThen(input.invalidateUsageCache ?? Effect.void), + Effect.andThen(input.getSettings), + Effect.flatMap((settings) => applySnapshotBase(settings, { forceRefresh: true })), + ), + ) + .pipe(Effect.onInterrupt(() => Ref.set(usageRefreshQueued, false))); }); const shouldRefreshOnInterval = Effect.gen(function* () { From aaa9ab3c4c1b89dc63a98b4399ac9bdca4238a37 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 02:03:42 +0000 Subject: [PATCH 21/23] fix(server): only the queued usage refresh clears its own claim Co-Authored-By: Claude Opus 5.5 --- .../src/server/managedProvider.test.ts | 50 +++++++++++++++++++ .../src/server/managedProvider.ts | 11 ++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 5f5946a80cce9..a24233d63f853 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -199,6 +199,56 @@ describe("makeManagedServerProvider", () => { }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); + it.effect("keeps the waiting usage refresh's claim when the running one is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const usageProbeStarted = yield* Deferred.make(); + const statusProbeStarted = yield* Deferred.make(); + const releaseStatusProbe = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(usageProbeStarted, undefined); + yield* Effect.never; + } + if (count === 3) { + yield* Deferred.succeed(statusProbeStarted, undefined); + yield* Deferred.await(releaseStatusProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { checkedAt: refreshedSnapshot.checkedAt, windows: [] }, + } satisfies ServerProvider; + }), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + const running = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(usageProbeStarted); + const status = yield* provider.refresh.pipe(Effect.forkChild({ startImmediately: true })); + const waiting = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Fiber.interrupt(running); + yield* Deferred.await(statusProbeStarted); + const extra = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseStatusProbe, undefined); + yield* Fiber.join(status); + yield* Fiber.join(waiting); + yield* Fiber.join(extra); + assert.strictEqual(yield* Ref.get(calls), 4); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index c270296b018ce..544aec00ce622 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -61,7 +61,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( > { const host = yield* ProviderHost.ProviderHost; const refreshSemaphore = yield* Semaphore.make(1); - const usageRefreshQueued = yield* Ref.make(false); + const usageRefreshQueued = yield* Ref.make(null); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -227,16 +227,19 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ) return; // An in-flight refresh may have read cached usage, so one refresh waits behind it. - if (yield* Ref.getAndSet(usageRefreshQueued, true)) return; + const claim = Symbol(); + const release = Ref.update(usageRefreshQueued, (queued) => (queued === claim ? null : queued)); + if (yield* Ref.modify(usageRefreshQueued, (queued) => [queued !== null, queued ?? claim])) + return; yield* refreshSemaphore .withPermits(1)( - Ref.set(usageRefreshQueued, false).pipe( + release.pipe( Effect.andThen(input.invalidateUsageCache ?? Effect.void), Effect.andThen(input.getSettings), Effect.flatMap((settings) => applySnapshotBase(settings, { forceRefresh: true })), ), ) - .pipe(Effect.onInterrupt(() => Ref.set(usageRefreshQueued, false))); + .pipe(Effect.onInterrupt(() => release)); }); const shouldRefreshOnInterval = Effect.gen(function* () { From ec0ef3619453f252d231f48c63d37ea9197626fa Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 02:05:23 +0000 Subject: [PATCH 22/23] test(server): return the never-ending usage probe Co-Authored-By: Claude Opus 5.5 --- packages/provider-core/src/server/managedProvider.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index a24233d63f853..7794107824c9b 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -216,7 +216,7 @@ describe("makeManagedServerProvider", () => { const count = yield* Ref.updateAndGet(calls, (count) => count + 1); if (count === 2) { yield* Deferred.succeed(usageProbeStarted, undefined); - yield* Effect.never; + return yield* Effect.never; } if (count === 3) { yield* Deferred.succeed(statusProbeStarted, undefined); From f78151e858a7d9f20333bee4d4cebf0821ea8698 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 02:16:29 +0000 Subject: [PATCH 23/23] fix(server): claim the queued usage refresh without an interrupt gap Co-Authored-By: Claude Opus 5.5 --- .../src/server/managedProvider.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 544aec00ce622..41ff65b0f294b 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -229,17 +229,25 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( // An in-flight refresh may have read cached usage, so one refresh waits behind it. const claim = Symbol(); const release = Ref.update(usageRefreshQueued, (queued) => (queued === claim ? null : queued)); - if (yield* Ref.modify(usageRefreshQueued, (queued) => [queued !== null, queued ?? claim])) - return; - yield* refreshSemaphore - .withPermits(1)( - release.pipe( - Effect.andThen(input.invalidateUsageCache ?? Effect.void), - Effect.andThen(input.getSettings), - Effect.flatMap((settings) => applySnapshotBase(settings, { forceRefresh: true })), + yield* Effect.uninterruptibleMask((restore) => + Ref.modify(usageRefreshQueued, (queued) => [queued !== null, queued ?? claim]).pipe( + Effect.flatMap((held) => + held + ? Effect.void + : restore( + refreshSemaphore.withPermits(1)( + release.pipe( + Effect.andThen(input.invalidateUsageCache ?? Effect.void), + Effect.andThen(input.getSettings), + Effect.flatMap((settings) => + applySnapshotBase(settings, { forceRefresh: true }), + ), + ), + ), + ).pipe(Effect.onInterrupt(() => release)), ), - ) - .pipe(Effect.onInterrupt(() => release)); + ), + ); }); const shouldRefreshOnInterval = Effect.gen(function* () {