Skip to content

Commit c21c64f

Browse files
authored
Merge remote-tracking branch 'origin/main' into copilot/awf-fix-gpt5-4-edit-tool-call-error
# Conflicts: # containers/api-proxy/providers/copilot.js
2 parents 08725b3 + eb31de9 commit c21c64f

17 files changed

Lines changed: 662 additions & 213 deletions

containers/api-proxy/providers/anthropic.js

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
* Body transforms: model alias rewriting + optional prompt-cache optimisations
1212
*/
1313

14-
const { composeBodyTransforms, createBaseAdapterConfig, createAdapterMethods } = require('../proxy-utils');
14+
const {
15+
composeBodyTransforms,
16+
makeProviderNotConfiguredResponse,
17+
createBaseAdapterConfig,
18+
createAdapterMethods,
19+
} = require('../proxy-utils');
1520

1621
let makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA;
1722
try {
@@ -144,17 +149,11 @@ function createAnthropicAdapter(env, deps = {}) {
144149

145150
/** Response returned for all requests when no ANTHROPIC_API_KEY is configured. */
146151
getUnconfiguredResponse() {
147-
return {
148-
statusCode: 503,
149-
body: {
150-
error: {
151-
message: 'Credentials for Anthropic (port 10001) are not configured. Set ANTHROPIC_API_KEY to enable this provider.',
152-
type: 'provider_not_configured',
153-
provider: 'anthropic',
154-
port: 10001,
155-
},
156-
},
157-
};
152+
return makeProviderNotConfiguredResponse(
153+
'anthropic',
154+
10001,
155+
'Credentials for Anthropic (port 10001) are not configured. Set ANTHROPIC_API_KEY to enable this provider.'
156+
);
158157
},
159158

160159
/** /health response when not configured. */

containers/api-proxy/providers/copilot.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414
* accepts OAuth tokens, not API keys.
1515
*/
1616

17-
const { normalizeApiTarget, normalizeBasePath, createAdapterMethods, composeBodyTransforms } = require('../proxy-utils');
17+
const {
18+
normalizeApiTarget,
19+
normalizeBasePath,
20+
makeProviderNotConfiguredResponse,
21+
createAdapterMethods,
22+
composeBodyTransforms,
23+
} = require('../proxy-utils');
1824
const { URL } = require('url');
1925

2026
/**
@@ -343,17 +349,11 @@ function createCopilotAdapter(env, deps = {}) {
343349

344350
/** Response returned for all requests when no Copilot credentials are configured. */
345351
getUnconfiguredResponse() {
346-
return {
347-
statusCode: 503,
348-
body: {
349-
error: {
350-
message: 'Credentials for GitHub Copilot (port 10002) are not configured. Set COPILOT_GITHUB_TOKEN or COPILOT_API_KEY to enable this provider.',
351-
type: 'provider_not_configured',
352-
provider: 'copilot',
353-
port: 10002,
354-
},
355-
},
356-
};
352+
return makeProviderNotConfiguredResponse(
353+
'copilot',
354+
10002,
355+
'Credentials for GitHub Copilot (port 10002) are not configured. Set COPILOT_GITHUB_TOKEN or COPILOT_API_KEY to enable this provider.'
356+
);
357357
},
358358

359359
/** /health response when not configured. */

containers/api-proxy/providers/opencode.js

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use strict';
2-
const { createAdapterMethods } = require('../proxy-utils');
2+
const { makeProviderNotConfiguredResponse, createAdapterMethods } = require('../proxy-utils');
33

44
/**
55
* OpenCode provider adapter.
@@ -178,29 +178,17 @@ function createOpenCodeAdapter(env, { candidateAdapters = [] } = {}) {
178178
/** Response returned for all requests when OpenCode is not configured. */
179179
getUnconfiguredResponse() {
180180
if (!enabled) {
181-
return {
182-
statusCode: 503,
183-
body: {
184-
error: {
185-
message: 'OpenCode proxy (port 10004) is not enabled. Set AWF_ENABLE_OPENCODE=true and configure at least one of OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, or COPILOT_API_KEY.',
186-
type: 'provider_not_configured',
187-
provider: 'opencode',
188-
port: 10004,
189-
},
190-
},
191-
};
181+
return makeProviderNotConfiguredResponse(
182+
'opencode',
183+
10004,
184+
'OpenCode proxy (port 10004) is not enabled. Set AWF_ENABLE_OPENCODE=true and configure at least one of OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, or COPILOT_API_KEY.'
185+
);
192186
}
193-
return {
194-
statusCode: 503,
195-
body: {
196-
error: {
197-
message: 'Credentials for OpenCode (port 10004) are not configured. Set at least one of OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, or COPILOT_API_KEY.',
198-
type: 'provider_not_configured',
199-
provider: 'opencode',
200-
port: 10004,
201-
},
202-
},
203-
};
187+
return makeProviderNotConfiguredResponse(
188+
'opencode',
189+
10004,
190+
'Credentials for OpenCode (port 10004) are not configured. Set at least one of OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, or COPILOT_API_KEY.'
191+
);
204192
},
205193

206194
/** /health response when not configured. */

containers/api-proxy/proxy-request.js

Lines changed: 173 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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 ────────────────────────────────────────────────────────────
125199
let 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+
141263
function 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

Comments
 (0)