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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ function AutoSettleSettingsRows() {
<SettingsSwitchRow
icon="clock"
label="Stop at subscription limit"
subtitle="Stop all Claude threads before paid extra usage, overriding thread options. Rechecks running sessions when enabled. Provider reports can lag; this is not a billing cap."
subtitle="Stop all Claude, Codex, and Cursor threads before paid usage, overriding thread options. Rechecks running sessions when enabled. Provider reports can lag; this is not a billing cap."
value={uniformMobileSetting(displayTargets, "stopAtSubscriptionLimit")}
disabled={disabled}
onValueChange={(value) => writeToAll({ stopAtSubscriptionLimit: value })}
Expand Down
45 changes: 10 additions & 35 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ import {
makeProviderFailure,
makeProviderRetryTurnItem,
} from "@t3tools/provider-core/server/failure";
import * as SubscriptionLimitPolicy from "@t3tools/provider-core/server/SubscriptionLimitPolicy";
import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
Expand Down Expand Up @@ -1727,6 +1728,9 @@ const getNativeThreadId = Effect.fnUntraced(function* (
return nativeThreadId;
});

// Extra usage starts once the session or weekly allowance runs out.
const CLAUDE_SUBSCRIPTION_WINDOW_IDS = ["five_hour", "seven_day"];

const isSyntheticClaudeTurnId = (nativeTurnId: string): boolean => nativeTurnId.startsWith("turn:");

const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean =>
Expand Down Expand Up @@ -4193,41 +4197,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
});
const subscriptionLimitFailure = Effect.fn("ClaudeAdapterV2.checkSubscriptionLimit")(
function* () {
const limits = yield* adapterOptions.checkUsageLimits ?? Effect.succeed(undefined);
const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
const subscriptionWindows =
limits?.windows.filter(
(window) => window.id === "five_hour" || window.id === "seven_day",
) ?? [];
const exhausted = subscriptionWindows.filter(
(window) =>
window.usedPercent >= 100 &&
(window.resetsAt === undefined || Date.parse(window.resetsAt) > nowMs),
);
if (
limits !== undefined &&
limits.unavailable === undefined &&
subscriptionWindows.length > 0 &&
exhausted.length === 0
)
return null;
const resetTimes = exhausted.map((window) => window.resetsAt);
const resetAt =
resetTimes.length > 0 && resetTimes.every((time) => time !== undefined)
? resetTimes.reduce((latest, time) => (time! > latest ? time! : latest), "")
: null;
return {
...makeProviderFailure({
class: exhausted.length > 0 ? "usage_limit" : "provider_error",
retryable: false,
code: "subscription_limit_guard",
message:
exhausted.length > 0
? "Claude subscription limit reached. This thread stopped because Stop at subscription limit is enabled."
: "Claude subscription usage could not be checked. Retry the turn, or disable Stop at subscription limit in application and thread settings to continue without this check.",
}),
resetAt,
};
return SubscriptionLimitPolicy.subscriptionLimitFailure({
limits: yield* adapterOptions.checkUsageLimits ?? Effect.succeed(undefined),
nowMs: DateTime.toEpochMillis(yield* DateTime.now),
provider: "Claude",
windowIds: CLAUDE_SUBSCRIPTION_WINDOW_IDS,
});
},
);

Expand Down
149 changes: 149 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import * as ServerConfig from "../../config.ts";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as SubscriptionLimitPolicy from "@t3tools/provider-core/server/SubscriptionLimitPolicy";
import * as EffectWorker from "../EffectWorker.ts";
import * as Orchestrator from "../Orchestrator.ts";
import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts";
Expand Down Expand Up @@ -2020,6 +2021,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
Effect.void,
onRequest: (method: string, params: unknown) => Effect.Effect<void> = () => Effect.void,
readChildMetadata?: Parameters<typeof withCodexReplayChildMetadata>[2],
subscriptionLimitPolicy?: SubscriptionLimitPolicy.SubscriptionLimitPolicy,
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -2073,6 +2075,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
continuationRequests.push(request);
}),
},
...(subscriptionLimitPolicy === undefined ? {} : { subscriptionLimitPolicy }),
});
const threadId = ThreadId.make(`thread-${transcript.scenario}`);
const runtime = yield* adapter.openSession({
Expand Down Expand Up @@ -7336,6 +7339,152 @@ describe("CodexAdapterV2 post-settle continuation", () => {
),
);

const guardedPolicy: SubscriptionLimitPolicy.SubscriptionLimitPolicy = {
enabled: () => Effect.succeed(true),
enabledChanges: () => Stream.never,
};
const rateLimitsRead = (id: number, usedPercent: number) =>
[
{
type: "expect_outbound",
label: "account/rateLimits/read",
frame: { id, method: "account/rateLimits/read", params: null },
},
{
type: "emit_inbound",
label: "account/rateLimits/read",
frame: {
id,
result: {
rateLimits: {
limitId: "codex",
primary: { usedPercent, resetsAt: 2000100000, windowDurationMins: 300 },
},
},
},
},
] satisfies Array<CodexReplay.CodexAppServerReplayEntry>;

it.effect("refuses a guarded turn before Codex starts it once the allowance is used up", () =>
Effect.gen(function* () {
const preamble = codexReplayPreamble({
nativeThreadId: "native-guard-refused",
nativeTurnId: "turn-guard-refused",
prompt: "Continue.",
});
const transcript = makeCodexReplayTranscript({
scenario: "codex-subscription-guard-refused",
entries: [...preamble.slice(0, 5), ...rateLimitsRead(3, 100)],
});
const harness = yield* makeCodexReplayHarness(
transcript,
undefined,
undefined,
undefined,
guardedPolicy,
);
const error = yield* harness.runtime
.startTurn(
makeCodexTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
text: "Continue.",
attemptId: RunAttemptId.make("attempt-guard-refused"),
}),
)
.pipe(Effect.flip);
const failure = SubscriptionLimitPolicy.findSubscriptionLimitStop(error);
assert.equal(failure?.class, "usage_limit");
assert.equal(failure?.code, "subscription_limit_guard");
assert.equal(failure?.resetAt, "2033-05-19T07:20:00.000Z");
assert.lengthOf(harness.terminalEvents(), 0);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("stops a running guarded turn when Codex reports the allowance used up", () =>
Effect.gen(function* () {
const nativeThreadId = "native-guard-live";
const nativeTurnId = "turn-guard-live";
const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." });
const transcript = makeCodexReplayTranscript({
scenario: "codex-subscription-guard-live",
entries: [
...preamble.slice(0, 5),
...rateLimitsRead(3, 40),
...preamble.slice(5).map((entry) => withReplayRequestId(entry, 4)),
{
type: "emit_inbound",
label: "account/rateLimits/updated",
frame: {
method: "account/rateLimits/updated",
params: {
rateLimits: {
limitId: "codex",
primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 },
},
},
},
},
{
type: "expect_outbound",
label: "turn/interrupt",
frame: {
id: 5,
method: "turn/interrupt",
params: { threadId: nativeThreadId, turnId: nativeTurnId },
},
},
{ type: "emit_inbound", label: "turn/interrupt", frame: { id: 5, result: {} } },
{
type: "emit_inbound",
label: "turn/completed",
frame: {
method: "turn/completed",
params: {
threadId: nativeThreadId,
turn: makeCodexReplayTurn({ id: nativeTurnId, status: "interrupted" }),
},
},
},
],
});
const harness = yield* makeCodexReplayHarness(
transcript,
undefined,
undefined,
undefined,
guardedPolicy,
);
yield* harness.runtime.startTurn(
makeCodexTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
text: "Continue.",
attemptId: RunAttemptId.make("attempt-guard-live"),
}),
);
yield* harness.firstTerminal;
const terminal = harness.terminalEvents()[0];
assert.equal(terminal?.status, "failed");
if (terminal?.status !== "failed") return;
assert.equal(terminal.failure.class, "usage_limit");
assert.equal(terminal.failure.code, "subscription_limit_guard");
assert.equal(terminal.failure.resetAt, "2033-05-19T07:20:00.000Z");
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect.each([
{
notificationStatus: 500,
Expand Down
Loading