11import {
2+ type ClaudePlanLimitCause ,
23 ClaudePlanLimitStatus ,
34 ClaudePlanReading ,
45 ClaudePlanUsage ,
56 type ClaudePlanWindow ,
67 ClaudePlanWindowId ,
78 claudePlanBlockedUntil ,
9+ claudePlanExtraUsage ,
810 claudePlanWindowFamily ,
911 mergeClaudePlanUsage ,
1012} from "@dx/domain" ;
@@ -14,8 +16,9 @@ import { Option, Schema } from "effect";
1416 * A Claude Pro/Max plan's limits as Anthropic reports them. The OAuth token
1517 * from `claude setup-token` carries only the `user:inference` scope, so dx
1618 * cannot ask Anthropic's usage endpoint. Instead, every response on the plan
17- * carries `anthropic-ratelimit-unified-*` headers: the title call reads them
18- * directly, and the Claude Code CLI reports them as `rate_limit_event` lines
19+ * carries `anthropic-ratelimit-unified-*` headers: the title call and the
20+ * connection's access check read them directly, and the Claude Code CLI
21+ * reports them as `rate_limit_event` lines
1922 * on its stream-json output.
2023 */
2124
@@ -38,6 +41,11 @@ const seconds = (value: unknown) => {
3841 : undefined ;
3942} ;
4043
44+ const reason = ( value : unknown ) =>
45+ typeof value === "string" && / ^ [ a - z _ ] { 1 , 64 } $ / . test ( value )
46+ ? { disabledReason : value }
47+ : { } ;
48+
4149const fraction = ( value : unknown ) => {
4250 const number = typeof value === "string" ? Number ( value ) : value ;
4351 return typeof number === "number" && Number . isFinite ( number )
@@ -89,6 +97,7 @@ export const claudePlanReadingFromEvent = (
8997 ? { }
9098 : { resetsAt : seconds ( value . overageResetsAt ) } ) ,
9199 inUse : value . isUsingOverage === true || value . overageInUse === true ,
100+ ...reason ( value . overageDisabledReason ) ,
92101 } ,
93102 } ) ;
94103} ;
@@ -120,6 +129,7 @@ export const claudePlanReadingFromHeaders = (
120129 ...( isStatus ( overageStatus ) ? { status : overageStatus } : { } ) ,
121130 ...( overageReset === undefined ? { } : { resetsAt : overageReset } ) ,
122131 inUse : header ( "overage-in-use" ) === "true" ,
132+ ...reason ( header ( "overage-disabled-reason" ) ) ,
123133 } ,
124134 } ) ;
125135} ;
@@ -172,7 +182,8 @@ const limitWindowFor = (
172182
173183/**
174184 * The plan state for one launch on `model` (a Claude model id): the extra
175- * usage choice and, when the plan is used up for that model, until when.
185+ * usage choice, whether the account has extra usage off, which no choice
186+ * changes, and, when the plan is used up for that model, until when.
176187 */
177188export const claudePlanAccess = ( state : ClaudePlanState , model : string ) => {
178189 const blockedUntil = claudePlanBlockedUntil (
@@ -184,11 +195,31 @@ export const claudePlanAccess = (state: ClaudePlanState, model: string) => {
184195 const limitWindow = limitWindowFor ( state . usage , model ) ;
185196 return {
186197 allowExtraUsage : state . allowExtraUsage ,
198+ ...( claudePlanExtraUsage ( state . usage ) === "off"
199+ ? { extraUsageOff : true }
200+ : { } ) ,
187201 ...( blockedUntil === undefined ? { } : { blockedUntil } ) ,
188202 ...( limitWindow === undefined ? { } : { limitWindow } ) ,
189203 } ;
190204} ;
191205
206+ /**
207+ * Why dx does not start Claude Code on a blocked plan: its owner keeps it
208+ * off extra usage, or the account has none to keep it off.
209+ */
210+ export const claudePlanBlockedCause = (
211+ plan : ClaudePlanAccess ,
212+ ) : ClaudePlanLimitCause => ( plan . extraUsageOff ? "exhausted" : "not-started" ) ;
213+
214+ /**
215+ * Whether a one-token Messages call may read the plan's limits now. While
216+ * the plan is used up, a call could draw on paid extra usage unless the
217+ * account has none, so dx waits for the limit to reset.
218+ */
219+ export const claudePlanReadable = ( state : ClaudePlanState ) =>
220+ claudePlanExtraUsage ( state . usage ) === "off" ||
221+ claudePlanBlockedUntil ( state . usage , false , nowSeconds ( ) ) === undefined ;
222+
192223export type ClaudePlanAccess = ReturnType < typeof claudePlanAccess > ;
193224
194225/**
0 commit comments