Skip to content

Commit 881da1d

Browse files
CopilotlpcoxCopilot
authored
Retry Anthropic requests after deprecated anthropic-beta header rejection (#3657)
* Initial plan * Add anthropic beta header retry handling * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 78261f1 commit 881da1d

2 files changed

Lines changed: 378 additions & 87 deletions

File tree

containers/api-proxy/proxy-request.js

Lines changed: 210 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,77 @@ const limiter = rateLimiter.create();
8585
/** When false, token-budget warnings are never injected into request bodies. */
8686
const isSteeringEnabled = () => process.env.AWF_ENABLE_TOKEN_STEERING === 'true';
8787

88+
const anthropicDeprecatedBetaValues = new Set();
89+
const ANTHROPIC_DEPRECATED_BETA_PATTERN = /Unexpected value\(s\)\s+`([^`]+)`\s+for the `anthropic-beta` header/;
90+
91+
function normalizeAnthropicBetaHeaderValue(value) {
92+
if (!value) return '';
93+
return Array.isArray(value) ? value.join(',') : String(value);
94+
}
95+
96+
function splitAnthropicBetaHeaderValue(value) {
97+
return normalizeAnthropicBetaHeaderValue(value).split(',').map(s => s.trim()).filter(Boolean);
98+
}
99+
100+
function updateAnthropicBetaHeader(headers, values) {
101+
if (!values.length) {
102+
delete headers['anthropic-beta'];
103+
return;
104+
}
105+
headers['anthropic-beta'] = values.join(',');
106+
}
107+
108+
function stripAnthropicBetaValuesFromHeaders(headers, valuesToStrip) {
109+
if (!headers['anthropic-beta'] || !valuesToStrip.size) return null;
110+
const existingValues = splitAnthropicBetaHeaderValue(headers['anthropic-beta']);
111+
if (!existingValues.length) {
112+
delete headers['anthropic-beta'];
113+
return { removed: [], remaining: [] };
114+
}
115+
const remaining = existingValues.filter(value => !valuesToStrip.has(value));
116+
const removed = existingValues.filter(value => valuesToStrip.has(value));
117+
if (!removed.length) return null;
118+
updateAnthropicBetaHeader(headers, remaining);
119+
return { removed, remaining };
120+
}
121+
122+
function maybeStripLearnedAnthropicBetaHeaders(headers, requestId) {
123+
const stripped = stripAnthropicBetaValuesFromHeaders(headers, anthropicDeprecatedBetaValues);
124+
if (!stripped) return;
125+
logRequest('warn', 'anthropic_beta_stripped', {
126+
request_id: requestId,
127+
provider: 'anthropic',
128+
mode: 'cached',
129+
removed_values: stripped.removed,
130+
remaining_values: stripped.remaining,
131+
message: 'Removed deprecated anthropic-beta values learned from prior upstream 400 responses',
132+
});
133+
}
134+
135+
function getAnthropicDeprecatedBetaValueFromBody(body) {
136+
const match = body.toString('utf8').match(ANTHROPIC_DEPRECATED_BETA_PATTERN);
137+
return match ? match[1].trim() : null;
138+
}
139+
140+
function learnAndStripAnthropicBetaHeader(headers, deprecatedValue, requestId) {
141+
anthropicDeprecatedBetaValues.add(deprecatedValue);
142+
if (anthropicDeprecatedBetaValues.size > 200) {
143+
const oldest = anthropicDeprecatedBetaValues.values().next().value;
144+
if (oldest !== undefined) anthropicDeprecatedBetaValues.delete(oldest);
145+
}
146+
const stripped = stripAnthropicBetaValuesFromHeaders(headers, new Set([deprecatedValue]));
147+
if (!stripped) return null;
148+
logRequest('warn', 'anthropic_beta_stripped', {
149+
request_id: requestId,
150+
provider: 'anthropic',
151+
mode: 'retry',
152+
removed_values: stripped.removed,
153+
remaining_values: stripped.remaining,
154+
message: `Removed deprecated anthropic-beta value rejected by Anthropic: ${deprecatedValue}`,
155+
});
156+
return stripped;
157+
}
158+
88159
function getUrlPathForSpan(requestUrl) {
89160
if (typeof requestUrl !== 'string' || !requestUrl) return '/';
90161
try {
@@ -364,6 +435,10 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath =
364435
headers['x-request-id'] = requestId;
365436
Object.assign(headers, injectHeaders);
366437

438+
if (provider === 'anthropic') {
439+
maybeStripLearnedAnthropicBetaHeaders(headers, requestId);
440+
}
441+
367442
const isCopilotHost =
368443
targetHost === 'githubcopilot.com' ||
369444
targetHost.endsWith('.githubcopilot.com');
@@ -426,17 +501,135 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath =
426501
return;
427502
}
428503

429-
const options = {
430-
hostname: targetHost, port: 443, path: upstreamPath,
431-
method: req.method, headers,
432-
agent: proxyAgent,
504+
const logRequestCompletion = (statusCode, responseBytes, initiatorSent, billingInfo) => {
505+
const duration = Date.now() - startTime;
506+
const sc = metrics.statusClass(statusCode);
507+
metrics.gaugeDec('active_requests', { provider });
508+
metrics.increment('requests_total', { provider, method: req.method, status_class: sc });
509+
metrics.increment('response_bytes_total', { provider }, responseBytes);
510+
metrics.observe('request_duration_ms', duration, { provider });
511+
if (statusCode >= 200 && statusCode < 300) {
512+
applyMaxRunsInvocation();
513+
}
514+
const logFields = {
515+
request_id: requestId, provider, method: req.method,
516+
path: sanitizeForLog(req.url), status: statusCode,
517+
duration_ms: duration, request_bytes: requestBytes,
518+
response_bytes: responseBytes, upstream_host: targetHost,
519+
};
520+
if (initiatorSent) logFields.x_initiator = initiatorSent;
521+
if (billingInfo) logFields.billing = billingInfo;
522+
logRequest('info', 'request_complete', logFields);
523+
};
524+
525+
const logUpstreamAuthError = (statusCode) => {
526+
if (statusCode === 400 || statusCode === 401 || statusCode === 403) {
527+
logRequest('warn', 'upstream_auth_error', {
528+
request_id: requestId, provider, status: statusCode,
529+
upstream_host: targetHost, path: sanitizeForLog(req.url),
530+
message: `Upstream returned ${statusCode} — check that the API key is valid and correctly formatted`,
531+
});
532+
}
433533
};
434534

435-
const proxyReq = https.request(options, (proxyRes) => {
436-
let responseBytes = 0;
437-
proxyRes.on('data', (chunk) => { responseBytes += chunk.length; });
535+
const sendUpstreamRequest = (requestHeaders, hasRetried = false) => {
536+
const options = {
537+
hostname: targetHost, port: 443, path: upstreamPath,
538+
method: req.method, headers: requestHeaders,
539+
agent: proxyAgent,
540+
};
541+
542+
const proxyReq = https.request(options, (proxyRes) => {
543+
let responseBytes = 0;
544+
const billingInfo = extractBillingHeaders(proxyRes.headers);
545+
const initiatorSent = requestHeaders['x-initiator'] || null;
546+
const shouldBufferAnthropic400 =
547+
provider === 'anthropic' &&
548+
!hasRetried &&
549+
proxyRes.statusCode === 400 &&
550+
!!requestHeaders['anthropic-beta'];
551+
552+
proxyRes.on('error', (err) => {
553+
otel.endSpanError(span, err, 502);
554+
handleRequestError(err, {
555+
res,
556+
requestId,
557+
provider,
558+
req,
559+
targetHost,
560+
startTime,
561+
statusCode: 502,
562+
clientMessage: 'Response stream error',
563+
onHeadersSent: () => {
564+
if (typeof res.destroy === 'function') res.destroy(err);
565+
},
566+
});
567+
});
568+
569+
if (shouldBufferAnthropic400) {
570+
const bufferedChunks = [];
571+
proxyRes.on('data', (chunk) => {
572+
responseBytes += chunk.length;
573+
bufferedChunks.push(chunk);
574+
});
575+
proxyRes.on('end', () => {
576+
const responseBody = Buffer.concat(bufferedChunks);
577+
const deprecatedValue = getAnthropicDeprecatedBetaValueFromBody(responseBody);
578+
if (deprecatedValue) {
579+
const retryHeaders = { ...requestHeaders };
580+
const stripped = learnAndStripAnthropicBetaHeader(retryHeaders, deprecatedValue, requestId);
581+
if (stripped) {
582+
sendUpstreamRequest(retryHeaders, true);
583+
return;
584+
}
585+
}
586+
587+
logRequestCompletion(proxyRes.statusCode, responseBytes, initiatorSent, billingInfo);
588+
logUpstreamAuthError(proxyRes.statusCode);
589+
590+
const resHeaders = {
591+
...proxyRes.headers,
592+
'x-request-id': requestId,
593+
'content-length': String(responseBody.length),
594+
};
595+
delete resHeaders['transfer-encoding'];
596+
res.writeHead(proxyRes.statusCode, resHeaders);
597+
res.end(responseBody);
598+
otel.endSpan(span, proxyRes.statusCode);
599+
});
600+
return;
601+
}
602+
603+
proxyRes.on('data', (chunk) => { responseBytes += chunk.length; });
604+
proxyRes.on('end', () => {
605+
logRequestCompletion(proxyRes.statusCode, responseBytes, initiatorSent, billingInfo);
606+
});
607+
608+
const resHeaders = { ...proxyRes.headers, 'x-request-id': requestId };
609+
logUpstreamAuthError(proxyRes.statusCode);
610+
res.writeHead(proxyRes.statusCode, resHeaders);
611+
proxyRes.pipe(res);
612+
613+
const isStreaming = (proxyRes.headers['content-type'] || '').includes('text/event-stream');
614+
trackTokenUsage(proxyRes, {
615+
requestId,
616+
provider,
617+
path: sanitizeForLog(req.url),
618+
startTime,
619+
metrics,
620+
billingInfo,
621+
initiatorSent,
622+
onUsage: (normalizedUsage, model) => {
623+
otel.setTokenAttributes(span, { provider, model, normalizedUsage, streaming: isStreaming });
624+
applyEffectiveTokenUsage(normalizedUsage, model);
625+
},
626+
onSpanEnd: (statusCode) => {
627+
otel.endSpan(span, statusCode);
628+
},
629+
});
630+
});
438631

439-
proxyRes.on('error', (err) => {
632+
proxyReq.on('error', (err) => {
440633
otel.endSpanError(span, err, 502);
441634
handleRequestError(err, {
442635
res,
@@ -446,89 +639,19 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath =
446639
targetHost,
447640
startTime,
448641
statusCode: 502,
449-
clientMessage: 'Response stream error',
450-
onHeadersSent: () => {
451-
if (typeof res.destroy === 'function') res.destroy(err);
642+
clientMessage: 'Proxy error',
643+
extraMetrics: (duration) => {
644+
metrics.increment('requests_total', { provider, method: req.method, status_class: '5xx' });
645+
metrics.observe('request_duration_ms', duration, { provider });
452646
},
453647
});
454648
});
455649

456-
const billingInfo = extractBillingHeaders(proxyRes.headers);
457-
const initiatorSent = headers['x-initiator'] || null;
458-
459-
proxyRes.on('end', () => {
460-
const duration = Date.now() - startTime;
461-
const sc = metrics.statusClass(proxyRes.statusCode);
462-
metrics.gaugeDec('active_requests', { provider });
463-
metrics.increment('requests_total', { provider, method: req.method, status_class: sc });
464-
metrics.increment('response_bytes_total', { provider }, responseBytes);
465-
metrics.observe('request_duration_ms', duration, { provider });
466-
if (proxyRes.statusCode >= 200 && proxyRes.statusCode < 300) {
467-
applyMaxRunsInvocation();
468-
}
469-
const logFields = {
470-
request_id: requestId, provider, method: req.method,
471-
path: sanitizeForLog(req.url), status: proxyRes.statusCode,
472-
duration_ms: duration, request_bytes: requestBytes,
473-
response_bytes: responseBytes, upstream_host: targetHost,
474-
};
475-
if (initiatorSent) logFields.x_initiator = initiatorSent;
476-
if (billingInfo) logFields.billing = billingInfo;
477-
logRequest('info', 'request_complete', logFields);
478-
});
479-
480-
const resHeaders = { ...proxyRes.headers, 'x-request-id': requestId };
481-
482-
if (proxyRes.statusCode === 400 || proxyRes.statusCode === 401 || proxyRes.statusCode === 403) {
483-
logRequest('warn', 'upstream_auth_error', {
484-
request_id: requestId, provider, status: proxyRes.statusCode,
485-
upstream_host: targetHost, path: sanitizeForLog(req.url),
486-
message: `Upstream returned ${proxyRes.statusCode} — check that the API key is valid and correctly formatted`,
487-
});
488-
}
489-
490-
res.writeHead(proxyRes.statusCode, resHeaders);
491-
proxyRes.pipe(res);
492-
493-
const isStreaming = (proxyRes.headers['content-type'] || '').includes('text/event-stream');
494-
trackTokenUsage(proxyRes, {
495-
requestId,
496-
provider,
497-
path: sanitizeForLog(req.url),
498-
startTime,
499-
metrics,
500-
billingInfo,
501-
initiatorSent,
502-
onUsage: (normalizedUsage, model) => {
503-
otel.setTokenAttributes(span, { provider, model, normalizedUsage, streaming: isStreaming });
504-
applyEffectiveTokenUsage(normalizedUsage, model);
505-
},
506-
onSpanEnd: (statusCode) => {
507-
otel.endSpan(span, statusCode);
508-
},
509-
});
510-
});
511-
512-
proxyReq.on('error', (err) => {
513-
otel.endSpanError(span, err, 502);
514-
handleRequestError(err, {
515-
res,
516-
requestId,
517-
provider,
518-
req,
519-
targetHost,
520-
startTime,
521-
statusCode: 502,
522-
clientMessage: 'Proxy error',
523-
extraMetrics: (duration) => {
524-
metrics.increment('requests_total', { provider, method: req.method, status_class: '5xx' });
525-
metrics.observe('request_duration_ms', duration, { provider });
526-
},
527-
});
528-
});
650+
if (body.length > 0) proxyReq.write(body);
651+
proxyReq.end();
652+
};
529653

530-
if (body.length > 0) proxyReq.write(body);
531-
proxyReq.end();
654+
sendUpstreamRequest(headers);
532655
});
533656
}
534657

@@ -546,6 +669,7 @@ module.exports = {
546669
resetEffectiveTokenGuardForTests,
547670
resetMaxRunsGuardForTests,
548671
resetTimeoutSteeringForTests,
672+
resetAnthropicDeprecatedBetaHeadersForTests: () => anthropicDeprecatedBetaValues.clear(),
549673
getAndClearPendingSteeringMessage,
550674
getAndClearPendingTimeoutSteeringMessage,
551675
injectSteeringMessage,

0 commit comments

Comments
 (0)