@@ -93,6 +93,70 @@ function extractBillingHeaders(headers) {
9393 return hasBilling ? billing : null ;
9494}
9595
96+ /**
97+ * Sanitize OpenAI-compatible request history where tool_calls[].type is null.
98+ *
99+ * Normalizes null type to "function" when a function payload is present.
100+ * Otherwise, drops the malformed tool_call entry.
101+ *
102+ * @param {Buffer } body
103+ * @returns {{ body: Buffer, normalizedCount: number, droppedCount: number }|null }
104+ */
105+ function sanitizeNullToolCallTypes ( body ) {
106+ let parsed ;
107+ try {
108+ parsed = JSON . parse ( body . toString ( 'utf8' ) ) ;
109+ } catch {
110+ return null ;
111+ }
112+
113+ if ( ! parsed || typeof parsed !== 'object' || ! Array . isArray ( parsed . messages ) ) {
114+ return null ;
115+ }
116+
117+ let changed = false ;
118+ let normalizedCount = 0 ;
119+ let droppedCount = 0 ;
120+
121+ for ( const message of parsed . messages ) {
122+ if ( ! message || typeof message !== 'object' || ! Array . isArray ( message . tool_calls ) ) {
123+ continue ;
124+ }
125+
126+ const nextToolCalls = [ ] ;
127+ for ( const toolCall of message . tool_calls ) {
128+ if (
129+ toolCall &&
130+ typeof toolCall === 'object' &&
131+ Object . hasOwn ( toolCall , 'type' ) &&
132+ toolCall . type === null
133+ ) {
134+ if ( toolCall . function && typeof toolCall . function === 'object' ) {
135+ nextToolCalls . push ( { ...toolCall , type : 'function' } ) ;
136+ normalizedCount += 1 ;
137+ } else {
138+ droppedCount += 1 ;
139+ }
140+ changed = true ;
141+ continue ;
142+ }
143+ nextToolCalls . push ( toolCall ) ;
144+ }
145+
146+ message . tool_calls = nextToolCalls ;
147+ }
148+
149+ if ( ! changed ) {
150+ return null ;
151+ }
152+
153+ return {
154+ body : Buffer . from ( JSON . stringify ( parsed ) ) ,
155+ normalizedCount,
156+ droppedCount,
157+ } ;
158+ }
159+
96160/**
97161 * Shared RateLimiter instance.
98162 * Exported so that management endpoints (healthResponse) can read getAllStatus().
@@ -120,6 +184,16 @@ const effectiveTokenConfigCache = {
120184 rawMultipliers : undefined ,
121185 parsed : { max : null , multipliers : { } } ,
122186} ;
187+ let timeoutSteeringState = {
188+ configKey : null ,
189+ startTimeMs : 0 ,
190+ emittedThresholds : new Set ( ) ,
191+ uninjectedThresholds : new Set ( ) ,
192+ } ;
193+ const timeoutSteeringConfigCache = {
194+ rawMinutes : undefined ,
195+ parsedMinutes : null ,
196+ } ;
123197
124198// ── Max-runs guard ────────────────────────────────────────────────────────────
125199let maxRunsGuardState = {
@@ -138,6 +212,54 @@ function parseMaxRuns(raw) {
138212 return parsed ;
139213}
140214
215+ function createTimeoutSteeringState ( configKey = null , startTimeMs = Date . now ( ) ) {
216+ return {
217+ configKey,
218+ startTimeMs,
219+ emittedThresholds : new Set ( ) ,
220+ uninjectedThresholds : new Set ( ) ,
221+ } ;
222+ }
223+
224+ function parseAgentTimeoutMinutes ( raw ) {
225+ if ( raw === undefined || raw === null || String ( raw ) . trim ( ) === '' ) return null ;
226+ const parsed = Number ( raw ) ;
227+ if ( ! Number . isInteger ( parsed ) || parsed <= 0 ) return null ;
228+ return parsed ;
229+ }
230+
231+ function getTimeoutSteeringConfig ( ) {
232+ const rawMinutes = process . env . AWF_AGENT_TIMEOUT_MINUTES ;
233+ if ( timeoutSteeringConfigCache . rawMinutes === rawMinutes ) {
234+ return timeoutSteeringConfigCache . parsedMinutes ;
235+ }
236+ timeoutSteeringConfigCache . rawMinutes = rawMinutes ;
237+ timeoutSteeringConfigCache . parsedMinutes = parseAgentTimeoutMinutes ( rawMinutes ) ;
238+ return timeoutSteeringConfigCache . parsedMinutes ;
239+ }
240+
241+ function getTimeoutSteeringState ( timeoutMinutes ) {
242+ if ( ! timeoutMinutes ) return null ;
243+ const configKey = String ( timeoutMinutes ) ;
244+ if ( timeoutSteeringState . configKey !== configKey ) {
245+ timeoutSteeringState = createTimeoutSteeringState ( configKey ) ;
246+ }
247+ return timeoutSteeringState ;
248+ }
249+
250+ function updateTimeoutSteeringThresholds ( state , timeoutMinutes ) {
251+ if ( ! state || ! timeoutMinutes ) return ;
252+ const elapsedMs = Math . max ( 0 , Date . now ( ) - state . startTimeMs ) ;
253+ const timeoutMs = timeoutMinutes * 60 * 1000 ;
254+ const percentElapsed = ( elapsedMs / timeoutMs ) * 100 ;
255+ for ( const threshold of ET_WARNING_THRESHOLDS ) {
256+ if ( percentElapsed >= threshold && ! state . emittedThresholds . has ( threshold ) ) {
257+ state . emittedThresholds . add ( threshold ) ;
258+ state . uninjectedThresholds . add ( threshold ) ;
259+ }
260+ }
261+ }
262+
141263function getMaxRunsConfig ( ) {
142264 const rawMax = process . env . AWF_MAX_RUNS ;
143265 if ( maxRunsConfigCache . rawMax === rawMax ) {
@@ -376,6 +498,12 @@ const ET_STEERING_MESSAGES = {
376498 95 : 'You have used 95% of your effective token budget. Finalize and submit your work now.' ,
377499 99 : 'You have used 99% of your effective token budget. You are about to be cut off. Submit immediately.' ,
378500} ;
501+ const TIMEOUT_STEERING_MESSAGES = {
502+ 80 : 'You have used 80% of your allotted run time. Begin planning to wrap up your current work.' ,
503+ 90 : 'You have used 90% of your allotted run time. Complete your current task and prepare final output.' ,
504+ 95 : 'You have used 95% of your allotted run time. Finalize and submit your work now.' ,
505+ 99 : 'You have used 99% of your allotted run time. You are about to time out. Submit immediately.' ,
506+ } ;
379507
380508/**
381509 * Pop the highest-priority pending steering threshold and return its warning
@@ -398,6 +526,21 @@ function getAndClearPendingSteeringMessage() {
398526 return `[AWF TOKEN WARNING] ${ text } ` ;
399527}
400528
529+ function getAndClearPendingTimeoutSteeringMessage ( ) {
530+ const timeoutMinutes = getTimeoutSteeringConfig ( ) ;
531+ const state = getTimeoutSteeringState ( timeoutMinutes ) ;
532+ if ( ! state ) return null ;
533+
534+ updateTimeoutSteeringThresholds ( state , timeoutMinutes ) ;
535+ if ( state . uninjectedThresholds . size === 0 ) return null ;
536+
537+ const maxThreshold = Math . max ( ...state . uninjectedThresholds ) ;
538+ state . uninjectedThresholds . delete ( maxThreshold ) ;
539+ const text = TIMEOUT_STEERING_MESSAGES [ maxThreshold ] ||
540+ `You have used ${ maxThreshold } % of your allotted run time.` ;
541+ return `[AWF TIME WARNING] ${ text } ` ;
542+ }
543+
401544/**
402545 * Inject a token-budget warning message into a request body.
403546 *
@@ -463,6 +606,12 @@ function injectSteeringMessage(body, provider, message) {
463606 return Buffer . from ( JSON . stringify ( parsed ) ) ;
464607}
465608
609+ function resetTimeoutSteeringForTests ( ) {
610+ timeoutSteeringState = createTimeoutSteeringState ( ) ;
611+ timeoutSteeringConfigCache . rawMinutes = undefined ;
612+ timeoutSteeringConfigCache . parsedMinutes = null ;
613+ }
614+
466615// ── Utility ───────────────────────────────────────────────────────────────────
467616
468617/**
@@ -628,19 +777,36 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath =
628777 if ( transformed ) body = transformed ;
629778 }
630779
780+ if ( req . method === 'POST' || req . method === 'PUT' || req . method === 'PATCH' ) {
781+ const sanitized = sanitizeNullToolCallTypes ( body ) ;
782+ if ( sanitized ) {
783+ body = sanitized . body ;
784+ logRequest ( 'info' , 'request_sanitized' , {
785+ request_id : requestId ,
786+ provider,
787+ normalized_tool_calls : sanitized . normalizedCount ,
788+ dropped_tool_calls : sanitized . droppedCount ,
789+ } ) ;
790+ }
791+ }
792+
631793 // Token steering: inject budget-warning messages into the request body when
632794 // cumulative usage has crossed a threshold since the last injection.
633795 // Gated by AWF_ENABLE_TOKEN_STEERING=true (opt-in).
634796 if ( isSteeringEnabled ( ) && ( req . method === 'POST' || req . method === 'PUT' ) ) {
635- const steeringMsg = getAndClearPendingSteeringMessage ( ) ;
636- if ( steeringMsg ) {
637- const steered = injectSteeringMessage ( body , provider , steeringMsg ) ;
797+ const steeringMessages = [
798+ { type : 'timeout' , message : getAndClearPendingTimeoutSteeringMessage ( ) } ,
799+ { type : 'token' , message : getAndClearPendingSteeringMessage ( ) } ,
800+ ] ;
801+ for ( const { type, message } of steeringMessages ) {
802+ if ( ! message ) continue ;
803+ const steered = injectSteeringMessage ( body , provider , message ) ;
638804 if ( steered ) {
639805 body = steered ;
640- logRequest ( 'info' , 'token_steering' , {
806+ logRequest ( 'info' , ` ${ type } _steering` , {
641807 request_id : requestId ,
642808 provider,
643- message : steeringMsg ,
809+ message,
644810 } ) ;
645811 }
646812 }
@@ -1023,6 +1189,8 @@ module.exports = {
10231189 // Exported for tests
10241190 resetEffectiveTokenGuardForTests,
10251191 resetMaxRunsGuardForTests,
1192+ resetTimeoutSteeringForTests,
10261193 getAndClearPendingSteeringMessage,
1194+ getAndClearPendingTimeoutSteeringMessage,
10271195 injectSteeringMessage,
10281196} ;
0 commit comments