55
66import { createHash , randomUUID } from 'crypto' ;
77import { WindsurfClient , isCascadeTransportError } from '../client.js' ;
8- import { getApiKey , acquireAccountByKey , releaseAccount , getAccountAvailability , reportError , reportSuccess , markRateLimited , reportInternalError , updateCapability , getAccountList , isAllRateLimited } from '../auth.js' ;
8+ import { getApiKey , acquireAccountByKey , releaseAccount , getAccountAvailability , reportError , reportSuccess , markRateLimited , reportInternalError , updateCapability , getAccountList , isAllRateLimited , isAllTemporarilyUnavailable , refundReservation } from '../auth.js' ;
99import { resolveModel , getModelInfo } from '../models.js' ;
1010import { getLsFor , ensureLs } from '../langserver.js' ;
1111import { config , log } from '../config.js' ;
@@ -186,9 +186,17 @@ function strictReuseMessage(model, retryMs, reason = 'temporarily unavailable')
186186 return `${ model } 上下文复用绑定账号暂不可用(${ reason } )。为避免切换账号导致上下文丢失,请 ${ Math . ceil ( retryMs / 1000 ) } 秒后重试` ;
187187}
188188
189- function rateLimitCooldownMs ( message = '' ) {
189+ export function rateLimitCooldownMs ( message = '' ) {
190+ const m = String ( message || '' ) . match ( / (?: r e t r y (?: a f t e r | i n ) | a f t e r ) \s + ( \d + ) \s * ( s e c o n d s ? | s e c s ? | s | m i n u t e s ? | m i n s ? | m | h o u r s ? | h r s ? | h ) / i) ;
191+ if ( m ) {
192+ const n = Number ( m [ 1 ] ) ;
193+ const unit = m [ 2 ] . toLowerCase ( ) ;
194+ if ( unit . startsWith ( 'h' ) ) return n * 60 * 60 * 1000 ;
195+ if ( unit . startsWith ( 'm' ) ) return n * 60 * 1000 ;
196+ return n * 1000 ;
197+ }
190198 if ( / a b o u t a n h o u r | i n a n h o u r | t r y a g a i n i n .* h o u r / i. test ( message ) ) return 60 * 60 * 1000 ;
191- return 5 * 60 * 1000 ;
199+ return 60 * 1000 ;
192200}
193201
194202function genId ( ) {
@@ -436,6 +444,8 @@ export async function handleChatCompletions(body, context = {}) {
436444 } = body ;
437445 let messages = body . messages ;
438446 const callerKey = context . callerKey || body . __callerKey || '' ;
447+ const checkMessageRateLimitFn = context . checkMessageRateLimit || checkMessageRateLimit ;
448+ const waitForAccountFn = context . waitForAccount || waitForAccount ;
439449
440450 // Probe diagnostics: dump compact request shape for every call, plus a
441451 // tail of the last user turn. Keeps us able to see how third-party
@@ -664,7 +674,10 @@ export async function handleChatCompletions(body, context = {}) {
664674 const ckey = cacheKey ( body ) ;
665675
666676 if ( stream ) {
667- return streamResponse ( chatId , created , displayModel , modelKey , messages , cascadeMessages , modelEnum , modelUid , useCascade , ckey , emulateTools , toolPreamble , reqId , wantJson , callerKey ) ;
677+ return streamResponse ( chatId , created , displayModel , modelKey , messages , cascadeMessages , modelEnum , modelUid , useCascade , ckey , emulateTools , toolPreamble , reqId , wantJson , callerKey , {
678+ checkMessageRateLimit : checkMessageRateLimitFn ,
679+ waitForAccount : waitForAccountFn ,
680+ } ) ;
668681 }
669682
670683 // ── Local response cache (exact body match) ─────────────
@@ -750,7 +763,7 @@ export async function handleChatCompletions(body, context = {}) {
750763 }
751764 }
752765 if ( ! acct ) {
753- acct = await waitForAccount ( tried , null , QUEUE_MAX_WAIT_MS , modelKey ) ;
766+ acct = await waitForAccountFn ( tried , null , QUEUE_MAX_WAIT_MS , modelKey ) ;
754767 if ( ! acct ) break ;
755768 }
756769 tried . push ( acct . apiKey ) ;
@@ -761,10 +774,13 @@ export async function handleChatCompletions(body, context = {}) {
761774 if ( isExperimentalEnabled ( 'preflightRateLimit' ) ) {
762775 try {
763776 const px = getEffectiveProxy ( acct . id ) || null ;
764- const rl = await checkMessageRateLimit ( acct . apiKey , px ) ;
777+ const rl = await checkMessageRateLimitFn ( acct . apiKey , px ) ;
765778 if ( ! rl . hasCapacity ) {
766779 log . warn ( `Preflight: ${ acct . email } has no capacity (remaining=${ rl . messagesRemaining } ), skipping` ) ;
767- markRateLimited ( acct . apiKey , 5 * 60 * 1000 , modelKey ) ;
780+ refundReservation ( acct . apiKey , acct . reservationTimestamp ) ;
781+ if ( Number . isFinite ( rl . retryAfterMs ) && rl . retryAfterMs > 0 ) {
782+ markRateLimited ( acct . apiKey , rl . retryAfterMs , modelKey ) ;
783+ }
768784 if ( strictReuse && checkedOutReuseEntry && fpBefore && checkedOutReuseEntry . apiKey === acct . apiKey ) {
769785 const availability = getAccountAvailability ( acct . apiKey , modelKey ) ;
770786 const retryAfterMs = strictReuseRetryMs ( availability ) ;
@@ -872,6 +888,25 @@ export async function handleChatCompletions(body, context = {}) {
872888 } ;
873889 }
874890 // If all accounts exhausted, check if it's because they're all rate-limited
891+ const temporaryUnavailable = isAllTemporarilyUnavailable ( modelKey ) ;
892+ if ( temporaryUnavailable . allUnavailable ) {
893+ if ( checkedOutReuseEntry && fpBefore ) {
894+ poolCheckin ( fpBefore , checkedOutReuseEntry , callerKey ) ;
895+ log . info ( `Chat[${ reqId } ]: restored checked-out cascade after temporary unavailability` ) ;
896+ }
897+ const retryAfterSec = Math . ceil ( temporaryUnavailable . retryAfterMs / 1000 ) ;
898+ return {
899+ status : 429 ,
900+ headers : { 'Retry-After' : String ( retryAfterSec ) } ,
901+ body : {
902+ error : {
903+ message : `${ displayModel } 所有账号暂时不可用,请 ${ retryAfterSec } 秒后重试` ,
904+ type : 'rate_limit_exceeded' ,
905+ retry_after_ms : temporaryUnavailable . retryAfterMs ,
906+ } ,
907+ } ,
908+ } ;
909+ }
875910 if ( ! lastErr || lastErr . status === 429 ) {
876911 const rl = isAllRateLimited ( modelKey ) ;
877912 if ( rl . allLimited ) {
@@ -1060,7 +1095,9 @@ async function nonStreamResponse(client, id, created, model, modelKey, messages,
10601095 }
10611096}
10621097
1063- function streamResponse ( id , created , model , modelKey , messages , cascadeMessages , modelEnum , modelUid , useCascade , ckey , emulateTools , toolPreamble , reqId , wantJson = false , callerKey = '' ) {
1098+ function streamResponse ( id , created , model , modelKey , messages , cascadeMessages , modelEnum , modelUid , useCascade , ckey , emulateTools , toolPreamble , reqId , wantJson = false , callerKey = '' , deps = { } ) {
1099+ const checkMessageRateLimitFn = deps . checkMessageRateLimit || checkMessageRateLimit ;
1100+ const waitForAccountFn = deps . waitForAccount || waitForAccount ;
10641101 return {
10651102 status : 200 ,
10661103 stream : true ,
@@ -1120,8 +1157,9 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
11201157 choices : [ { index : 0 , delta : { content : cached . text } , finish_reason : null } ] } ) ;
11211158 }
11221159 send ( { id, object : 'chat.completion.chunk' , created, model,
1123- choices : [ { index : 0 , delta : { } , finish_reason : 'stop' } ] ,
1124- usage : cachedUsage ( messages , cached . text ) } ) ;
1160+ choices : [ { index : 0 , delta : { } , finish_reason : 'stop' } ] } ) ;
1161+ send ( { id, object : 'chat.completion.chunk' , created, model,
1162+ choices : [ ] , usage : cachedUsage ( messages , cached . text ) } ) ;
11251163 if ( ! res . writableEnded ) { res . write ( 'data: [DONE]\n\n' ) ; res . end ( ) ; }
11261164 } finally {
11271165 unregisterSse ( ) ;
@@ -1228,18 +1266,32 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
12281266 // → client
12291267 let safeText = chunk . text ;
12301268 if ( toolParser ) {
1231- const { text : safe , toolCalls : done } = toolParser . feed ( chunk . text ) ;
1232- safeText = safe ;
1233- // Only emit tool_call deltas when emulating — otherwise the
1234- // parsed calls came from Cascade's built-in tools and are
1235- // silently discarded. Sanitize server-internal paths out of
1236- // the emulated call's input too (issue #38) — otherwise Claude
1237- // Code tries to Read the sandbox path and fails.
1238- for ( const rawTc of done ) {
1239- const tc = sanitizeToolCall ( rawTc ) ;
1240- const idx = collectedToolCalls . length ;
1241- collectedToolCalls . push ( tc ) ;
1242- emitToolCallDelta ( tc , idx ) ;
1269+ const parsed = toolParser . feed ( chunk . text ) ;
1270+ safeText = parsed . text ;
1271+ if ( Array . isArray ( parsed . items ) && parsed . items . length ) {
1272+ for ( const item of parsed . items ) {
1273+ if ( item . type === 'text' ) {
1274+ emitContent ( pathStreamText . feed ( item . text ) ) ;
1275+ continue ;
1276+ }
1277+ const tc = sanitizeToolCall ( item . toolCall ) ;
1278+ const idx = collectedToolCalls . length ;
1279+ collectedToolCalls . push ( tc ) ;
1280+ emitToolCallDelta ( tc , idx ) ;
1281+ }
1282+ safeText = '' ;
1283+ } else {
1284+ // Only emit tool_call deltas when emulating — otherwise the
1285+ // parsed calls came from Cascade's built-in tools and are
1286+ // silently discarded. Sanitize server-internal paths out of
1287+ // the emulated call's input too (issue #38) — otherwise Claude
1288+ // Code tries to Read the sandbox path and fails.
1289+ for ( const rawTc of parsed . toolCalls ) {
1290+ const tc = sanitizeToolCall ( rawTc ) ;
1291+ const idx = collectedToolCalls . length ;
1292+ collectedToolCalls . push ( tc ) ;
1293+ emitToolCallDelta ( tc , idx ) ;
1294+ }
12431295 }
12441296 }
12451297 if ( safeText ) emitContent ( pathStreamText . feed ( safeText ) ) ;
@@ -1283,7 +1335,7 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
12831335 }
12841336 }
12851337 if ( ! acct ) {
1286- acct = await waitForAccount ( tried , abortController . signal , QUEUE_MAX_WAIT_MS , modelKey ) ;
1338+ acct = await waitForAccountFn ( tried , abortController . signal , QUEUE_MAX_WAIT_MS , modelKey ) ;
12871339 if ( ! acct ) break ;
12881340 }
12891341 tried . push ( acct . apiKey ) ;
@@ -1294,10 +1346,13 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
12941346 if ( isExperimentalEnabled ( 'preflightRateLimit' ) ) {
12951347 try {
12961348 const px = getEffectiveProxy ( acct . id ) || null ;
1297- const rl = await checkMessageRateLimit ( acct . apiKey , px ) ;
1349+ const rl = await checkMessageRateLimitFn ( acct . apiKey , px ) ;
12981350 if ( ! rl . hasCapacity ) {
12991351 log . warn ( `Preflight: ${ acct . email } has no capacity (remaining=${ rl . messagesRemaining } ), skipping` ) ;
1300- markRateLimited ( acct . apiKey , 5 * 60 * 1000 , modelKey ) ;
1352+ refundReservation ( acct . apiKey , acct . reservationTimestamp ) ;
1353+ if ( Number . isFinite ( rl . retryAfterMs ) && rl . retryAfterMs > 0 ) {
1354+ markRateLimited ( acct . apiKey , rl . retryAfterMs , modelKey ) ;
1355+ }
13011356 if ( strictReuse && checkedOutReuseEntry && fpBefore && checkedOutReuseEntry . apiKey === acct . apiKey ) {
13021357 const availability = getAccountAvailability ( acct . apiKey , modelKey ) ;
13031358 const retryAfterMs = strictReuseRetryMs ( availability ) ;
@@ -1449,12 +1504,15 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
14491504 log . error ( 'Stream error after retries:' , lastErr ?. message ) ;
14501505 recordRequest ( model , false , Date . now ( ) - startTime , currentApiKey ) ;
14511506 try {
1507+ const temporaryUnavailable = isAllTemporarilyUnavailable ( modelKey ) ;
14521508 const rl = isAllRateLimited ( modelKey ) ;
14531509 const allInternal = streamInternalCount > 0 && tried . length > 0 && streamInternalCount >= tried . length ;
14541510 // 优先暴露 upstream_transient,避免把 Cascade transport 抖动误报成账号限流。
14551511 const lastIsTransport = isCascadeTransportError ( lastErr ) ;
14561512 const errMsg = allInternal
14571513 ? upstreamTransientErrorMessage ( model , tried . length , lastIsTransport ? 'cascade_transport' : 'internal_error' )
1514+ : temporaryUnavailable . allUnavailable
1515+ ? `${ model } 所有账号暂时不可用,请 ${ Math . ceil ( temporaryUnavailable . retryAfterMs / 1000 ) } 秒后重试`
14581516 : rl . allLimited
14591517 ? `${ model } 所有账号均已达速率限制,请 ${ Math . ceil ( rl . retryAfterMs / 1000 ) } 秒后重试`
14601518 : sanitizeText ( lastErr ?. message || 'no accounts' ) ;
@@ -1473,10 +1531,10 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages,
14731531 // output). Close cleanly with a plain stop — the caller saw
14741532 // whatever partial content we produced. Error details only
14751533 // go to the server log.
1476- send ( chatStreamError ( errMsg , allInternal ? 'upstream_transient_error' : 'upstream_error' ) ) ;
1534+ send ( chatStreamError ( errMsg , allInternal ? 'upstream_transient_error' : temporaryUnavailable . allUnavailable ? 'rate_limit_exceeded' : 'upstream_error' ) ) ;
14771535 log . warn ( `Stream: partial response delivered then failed (${ errMsg } )` ) ;
14781536 } else {
1479- send ( chatStreamError ( errMsg , allInternal ? 'upstream_transient_error' : 'upstream_error' ) ) ;
1537+ send ( chatStreamError ( errMsg , allInternal ? 'upstream_transient_error' : temporaryUnavailable . allUnavailable ? 'rate_limit_exceeded' : 'upstream_error' ) ) ;
14801538 }
14811539 res . write ( 'data: [DONE]\n\n' ) ;
14821540 } catch { }
0 commit comments