@@ -106,11 +106,18 @@ interface TenantAccountOutput {
106106 readonly created_at : string
107107 readonly email ?: string
108108 readonly validation ?: "ok" | "failed"
109+ readonly health : AccountHealthOutput
109110}
110111
111112type UpstreamProvider = "claude" | "codex"
112113type WaitUntil = ( promise : Promise < unknown > ) => void
113114
115+ interface AccountHealthOutput {
116+ readonly ok : boolean
117+ readonly lastQuotaErrorAt ?: string
118+ readonly message ?: string
119+ }
120+
114121const anthropicBootstrapModelQuotas : AccountModelQuotas = {
115122 opus : { remainingPercent : 100 } ,
116123 sonnet : { remainingPercent : 100 } ,
@@ -136,6 +143,9 @@ interface StoredAccount extends Account {
136143 readonly createdAt : number
137144 readonly updatedAt : number
138145 readonly source ?: string
146+ readonly lastQuotaErrorAt ?: number
147+ readonly lastQuotaErrorCode ?: string
148+ readonly consecutiveQuotaErrors ?: number
139149}
140150
141151interface RouteOutput {
@@ -185,6 +195,14 @@ interface AccountRow {
185195 readonly totp_algorithm : string | null
186196 readonly created_at : number
187197 readonly updated_at : number
198+ readonly last_quota_error_at : number | null
199+ readonly last_quota_error_code : string | null
200+ readonly consecutive_quota_errors : number | null
201+ }
202+
203+ interface TableInfoRow {
204+ readonly [ key : string ] : SqlStorageValue
205+ readonly name : string
188206}
189207
190208interface OAuthRefreshStatus {
@@ -593,6 +611,16 @@ const safeTenantAccount = (
593611 created_at : new Date ( account . createdAt ) . toISOString ( ) ,
594612 ...( safe . email ? { email : safe . email } : { } ) ,
595613 ...( validation ? { validation } : { } ) ,
614+ health : accountHealth ( account ) ,
615+ }
616+ }
617+
618+ const accountHealth = ( account : StoredAccount ) : AccountHealthOutput => {
619+ if ( ! account . lastQuotaErrorAt ) return { ok : true }
620+ return {
621+ ok : false ,
622+ lastQuotaErrorAt : new Date ( account . lastQuotaErrorAt ) . toISOString ( ) ,
623+ message : "Upstream reported quota exhaustion for this shared tenant account." ,
596624 }
597625}
598626
@@ -808,6 +836,15 @@ const rowToAccount = (row: AccountRow): StoredAccount => ({
808836 createdAt : row . created_at ,
809837 updatedAt : row . updated_at ,
810838 source : "durable-object" ,
839+ ...( row . last_quota_error_at !== null
840+ ? { lastQuotaErrorAt : row . last_quota_error_at }
841+ : { } ) ,
842+ ...( row . last_quota_error_code !== null
843+ ? { lastQuotaErrorCode : row . last_quota_error_code }
844+ : { } ) ,
845+ ...( row . consecutive_quota_errors !== null
846+ ? { consecutiveQuotaErrors : row . consecutive_quota_errors }
847+ : { } ) ,
811848} )
812849
813850const quotaRowsToRecord = (
@@ -1444,6 +1481,7 @@ export class SubrouterDurableObject extends DurableObject<Env> {
14441481 INSERT OR IGNORE INTO subrouter_status(id) VALUES (1);
14451482 INSERT OR IGNORE INTO lifecycle(id, started_at) VALUES (1, ${ Date . now ( ) } );
14461483 ` )
1484+ this . ensureAccountHealthColumns ( )
14471485
14481486 this . ctx . blockConcurrencyWhile ( async ( ) => {
14491487 await this . scheduleNextRefreshAlarm ( )
@@ -1678,9 +1716,13 @@ export class SubrouterDurableObject extends DurableObject<Env> {
16781716 }
16791717 }
16801718
1681- async ready ( ) : Promise < { ok : boolean ; draining : boolean } > {
1719+ async ready ( ) : Promise < { ok : boolean ; draining : boolean ; accountsDegraded : number } > {
16821720 const draining = this . isDraining ( )
1683- return { ok : ! draining , draining }
1721+ return {
1722+ ok : ! draining ,
1723+ draining,
1724+ accountsDegraded : this . accountsDegradedCount ( this . ctx . storage . sql ) ,
1725+ }
16841726 }
16851727
16861728 async drain ( ) : Promise < { ok : true ; draining : true } > {
@@ -1917,6 +1959,52 @@ export class SubrouterDurableObject extends DurableObject<Env> {
19171959 }
19181960 }
19191961
1962+ async recordAccountQuotaError ( input : {
1963+ readonly orgId ?: string
1964+ readonly accountId : string
1965+ readonly code : string
1966+ } ) : Promise < { ok : true } > {
1967+ const orgId = this . resolveOrgId ( input . orgId )
1968+ const accountId = this . requireNonEmpty ( input . accountId , "accountId" )
1969+ const code = this . requireNonEmpty ( input . code , "code" )
1970+ const now = Date . now ( )
1971+ this . ctx . storage . sql . exec (
1972+ `UPDATE accounts
1973+ SET last_quota_error_at = ?,
1974+ last_quota_error_code = ?,
1975+ consecutive_quota_errors = COALESCE(consecutive_quota_errors, 0) + 1,
1976+ updated_at = ?
1977+ WHERE id = ? AND org_id = ?` ,
1978+ now ,
1979+ code ,
1980+ now ,
1981+ accountId ,
1982+ orgId
1983+ )
1984+ return { ok : true }
1985+ }
1986+
1987+ async clearAccountQuotaError ( input : {
1988+ readonly orgId ?: string
1989+ readonly accountId : string
1990+ } ) : Promise < { ok : true } > {
1991+ const orgId = this . resolveOrgId ( input . orgId )
1992+ const accountId = this . requireNonEmpty ( input . accountId , "accountId" )
1993+ const now = Date . now ( )
1994+ this . ctx . storage . sql . exec (
1995+ `UPDATE accounts
1996+ SET last_quota_error_at = NULL,
1997+ last_quota_error_code = NULL,
1998+ consecutive_quota_errors = 0,
1999+ updated_at = ?
2000+ WHERE id = ? AND org_id = ? AND last_quota_error_at IS NOT NULL` ,
2001+ now ,
2002+ accountId ,
2003+ orgId
2004+ )
2005+ return { ok : true }
2006+ }
2007+
19202008 async recordTranscriptMeta ( input : TranscriptEventInput ) : Promise < { ok : true } > {
19212009 const agentType = normalizeAgentType ( input . agentType ) || "codex"
19222010 const sessionId = this . requireNonEmpty ( input . sessionId , "sessionId" )
@@ -2058,6 +2146,34 @@ export class SubrouterDurableObject extends DurableObject<Env> {
20582146 return sql . exec < StatusRow > ( "SELECT * FROM subrouter_status WHERE id = 1" ) . one ( )
20592147 }
20602148
2149+ private ensureAccountHealthColumns ( ) : void {
2150+ const existing = new Set (
2151+ this . ctx . storage . sql
2152+ . exec < TableInfoRow > ( "PRAGMA table_info(accounts)" )
2153+ . toArray ( )
2154+ . map ( ( row ) => row . name )
2155+ )
2156+ if ( ! existing . has ( "last_quota_error_at" ) ) {
2157+ this . ctx . storage . sql . exec ( "ALTER TABLE accounts ADD COLUMN last_quota_error_at INTEGER" )
2158+ }
2159+ if ( ! existing . has ( "last_quota_error_code" ) ) {
2160+ this . ctx . storage . sql . exec ( "ALTER TABLE accounts ADD COLUMN last_quota_error_code TEXT" )
2161+ }
2162+ if ( ! existing . has ( "consecutive_quota_errors" ) ) {
2163+ this . ctx . storage . sql . exec (
2164+ "ALTER TABLE accounts ADD COLUMN consecutive_quota_errors INTEGER DEFAULT 0"
2165+ )
2166+ }
2167+ }
2168+
2169+ private accountsDegradedCount ( sql : SqlStorage ) : number {
2170+ return sql
2171+ . exec < CountRow > (
2172+ "SELECT COUNT(*) AS count FROM accounts WHERE last_quota_error_at IS NOT NULL"
2173+ )
2174+ . one ( ) . count
2175+ }
2176+
20612177 private listEligibleAccounts (
20622178 sql : SqlStorage ,
20632179 orgId : string ,
@@ -2699,6 +2815,143 @@ const recordTelemetryAccount = (
26992815 telemetry . upstreamProvider = providerForAccount ( account . kind )
27002816}
27012817
2818+ interface QuotaObservation {
2819+ readonly code : string
2820+ readonly response ?: Response
2821+ }
2822+
2823+ const quotaResponseForNon2xx = async (
2824+ response : Response ,
2825+ account : StoredAccountContract
2826+ ) : Promise < QuotaObservation | null > => {
2827+ if ( response . status < 400 ) return null
2828+ const bodyText = await response . clone ( ) . text ( )
2829+ const parsed = parseJsonOrText ( bodyText )
2830+ const message = providerErrorMessage ( parsed )
2831+ const creditExhausted = isAnthropicCreditExhaustion ( parsed , bodyText )
2832+ // Only credit-class exhaustion counts: a plain 429 is transient rate
2833+ // limiting on an account that still has quota, and telling users to
2834+ // "top up" for it would be wrong advice.
2835+ if ( ! creditExhausted ) return null
2836+
2837+ const code = providerErrorCode ( parsed ) ?? "anthropic_credit_exhausted"
2838+ const reframedBody = reframeQuotaJsonBody ( parsed , account . label , message )
2839+ if ( ! reframedBody ) return { code }
2840+
2841+ const headers = new Headers ( response . headers )
2842+ headers . delete ( "content-length" )
2843+ if ( ! headers . has ( "content-type" ) ) {
2844+ headers . set ( "content-type" , "application/json; charset=utf-8" )
2845+ }
2846+ return {
2847+ code,
2848+ response : new Response ( JSON . stringify ( reframedBody ) , {
2849+ status : response . status ,
2850+ statusText : response . statusText ,
2851+ headers,
2852+ } ) ,
2853+ }
2854+ }
2855+
2856+ const providerErrorMessage = ( body : unknown ) : string | undefined => {
2857+ if ( ! body || typeof body !== "object" ) {
2858+ return typeof body === "string" && body . trim ( ) ? body : undefined
2859+ }
2860+ const record = body as Record < string , unknown >
2861+ const error = record [ "error" ]
2862+ if ( error && typeof error === "object" && ! Array . isArray ( error ) ) {
2863+ const nestedMessage = ( error as Record < string , unknown > ) [ "message" ]
2864+ if ( typeof nestedMessage === "string" && nestedMessage . trim ( ) ) {
2865+ return nestedMessage
2866+ }
2867+ }
2868+ if ( typeof error === "string" && error . trim ( ) ) return error
2869+ const message = record [ "message" ]
2870+ return typeof message === "string" && message . trim ( ) ? message : undefined
2871+ }
2872+
2873+ const providerErrorCode = ( body : unknown ) : string | undefined => {
2874+ if ( ! body || typeof body !== "object" ) return undefined
2875+ const record = body as Record < string , unknown >
2876+ const error = record [ "error" ]
2877+ if ( error && typeof error === "object" && ! Array . isArray ( error ) ) {
2878+ const nested = error as Record < string , unknown >
2879+ for ( const key of [ "code" , "type" ] ) {
2880+ const value = nested [ key ]
2881+ if ( typeof value === "string" && value . trim ( ) ) return value
2882+ }
2883+ }
2884+ for ( const key of [ "code" , "type" ] ) {
2885+ const value = record [ key ]
2886+ if ( typeof value === "string" && value . trim ( ) ) return value
2887+ }
2888+ return undefined
2889+ }
2890+
2891+ const isAnthropicCreditExhaustion = ( parsed : unknown , rawBody : string ) : boolean => {
2892+ const haystack = `${ providerErrorCode ( parsed ) ?? "" } \n${ providerErrorMessage ( parsed ) ?? "" } \n${ rawBody } ` . toLowerCase ( )
2893+ return (
2894+ haystack . includes ( "out of extra usage" ) ||
2895+ haystack . includes ( "claude.ai/settings/usage" ) ||
2896+ haystack . includes ( "credit_balance_too_low" ) ||
2897+ haystack . includes ( "out of credits" )
2898+ )
2899+ }
2900+
2901+ const reframeQuotaJsonBody = (
2902+ body : unknown ,
2903+ accountLabel : string ,
2904+ providerMessage : string | undefined
2905+ ) : unknown | null => {
2906+ if ( ! providerMessage ) return null
2907+ const message = quotaReframeMessage ( accountLabel , providerMessage )
2908+ if ( ! body || typeof body !== "object" || Array . isArray ( body ) ) return null
2909+ const record = body as Record < string , unknown >
2910+ const error = record [ "error" ]
2911+ if ( error && typeof error === "object" && ! Array . isArray ( error ) ) {
2912+ return {
2913+ ...record ,
2914+ error : {
2915+ ...( error as Record < string , unknown > ) ,
2916+ message,
2917+ } ,
2918+ }
2919+ }
2920+ if ( typeof error === "string" ) {
2921+ return { ...record , error : message }
2922+ }
2923+ if ( typeof record [ "message" ] === "string" ) {
2924+ return { ...record , message }
2925+ }
2926+ return null
2927+ }
2928+
2929+ const quotaReframeMessage = (
2930+ accountLabel : string ,
2931+ providerMessage : string
2932+ ) : string =>
2933+ `[cmux subrouter] shared tenant account '${ sanitizeQuotaMessage ( accountLabel ) } ' is out of quota - ask the tenant owner to top up or add another account (run: cmux-subrouter status). Provider says: ${ sanitizeQuotaMessage ( providerMessage ) } `
2934+
2935+ const sanitizeQuotaMessage = ( value : string ) : string =>
2936+ value
2937+ . replace ( / B e a r e r \s + [ A - Z a - z 0 - 9 . _ ~ + / = - ] + / gi, "Bearer [redacted]" )
2938+ . replace ( / s k - [ A - Z a - z 0 - 9 _ - ] + / g, "[redacted]" )
2939+ . replace ( / s r t _ [ 0 - 9 a - f ] + / gi, "[redacted]" )
2940+ . replace ( / [ \r \n \t ] + / g, " " )
2941+ . slice ( 0 , 500 )
2942+
2943+ const scheduleAccountHealthWrite = (
2944+ waitUntil : WaitUntil | undefined ,
2945+ promise : Promise < unknown >
2946+ ) : void => {
2947+ const guarded = promise . catch ( ( ) => { } )
2948+ if ( waitUntil ) {
2949+ waitUntil ( guarded )
2950+ return
2951+ }
2952+ void guarded
2953+ }
2954+
27022955const proxyUpstream = async (
27032956 request : Request ,
27042957 env : Env ,
@@ -2752,8 +3005,33 @@ const proxyUpstream = async (
27523005 const upstreamRequest = new Request ( upstreamURL , init )
27533006 const response = await fetch ( upstreamRequest )
27543007 if ( telemetry ) telemetry . upstreamStatus = response . status
2755- const [ clientBody , transcriptBody ] = response . body
2756- ? response . body . tee ( )
3008+ let clientResponse = response
3009+ if ( response . status >= 200 && response . status < 300 ) {
3010+ if ( ( account as StoredAccountContract & { readonly lastQuotaErrorAt ?: number } ) . lastQuotaErrorAt ) {
3011+ scheduleAccountHealthWrite (
3012+ waitUntil ,
3013+ actor . clearAccountQuotaError ( {
3014+ orgId : routeInput . orgId ,
3015+ accountId : account . id ,
3016+ } )
3017+ )
3018+ }
3019+ } else {
3020+ const quota = await quotaResponseForNon2xx ( response , account )
3021+ if ( quota ) {
3022+ scheduleAccountHealthWrite (
3023+ waitUntil ,
3024+ actor . recordAccountQuotaError ( {
3025+ orgId : routeInput . orgId ,
3026+ accountId : account . id ,
3027+ code : quota . code ,
3028+ } )
3029+ )
3030+ clientResponse = quota . response ?? response
3031+ }
3032+ }
3033+ const [ clientBody , transcriptBody ] = clientResponse . body
3034+ ? clientResponse . body . tee ( )
27573035 : [ null , null ]
27583036 const transcriptBodyBuffer = transcriptBody
27593037 ? new Response ( transcriptBody ) . arrayBuffer ( )
@@ -2778,7 +3056,7 @@ const proxyUpstream = async (
27783056 eventType : "http_body" ,
27793057 direction : "upstream_to_client" ,
27803058 body : await transcriptBodyBuffer ,
2781- payload : { status : response . status } ,
3059+ payload : { status : clientResponse . status } ,
27823060 } )
27833061 }
27843062 if ( waitUntil ) {
@@ -2787,9 +3065,9 @@ const proxyUpstream = async (
27873065 await recordTranscripts ( )
27883066 }
27893067 return new Response ( clientBody , {
2790- status : response . status ,
2791- statusText : response . statusText ,
2792- headers : response . headers ,
3068+ status : clientResponse . status ,
3069+ statusText : clientResponse . statusText ,
3070+ headers : clientResponse . headers ,
27933071 } )
27943072}
27953073
0 commit comments