From 05c9cf897ddeb83244d41b369356f9c57e1e3d5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 03:38:26 +0000 Subject: [PATCH 1/3] Initial plan From b417b62c447df0e5fac95b8ca86b8e469f9cc245 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 03:48:34 +0000 Subject: [PATCH 2/3] fix(api-proxy): retry Copilot 400 model-not-supported with backoff --- containers/api-proxy/proxy-request.js | 34 +- .../server.model-not-supported.test.js | 302 ++++++++++++++++++ containers/api-proxy/upstream-response.js | 83 ++++- 3 files changed, 402 insertions(+), 17 deletions(-) create mode 100644 containers/api-proxy/server.model-not-supported.test.js diff --git a/containers/api-proxy/proxy-request.js b/containers/api-proxy/proxy-request.js index ac61031fd..37a3713e4 100644 --- a/containers/api-proxy/proxy-request.js +++ b/containers/api-proxy/proxy-request.js @@ -21,7 +21,7 @@ const { learnAndStripDeprecatedHeaderValue, } = require('./deprecated-header-tracker'); const { extractBillingHeaders } = require('./billing-headers'); -const { createUpstreamResponseHandlers } = require('./upstream-response'); +const { createUpstreamResponseHandlers, MAX_MODEL_NOT_SUPPORTED_RETRIES } = require('./upstream-response'); const { createRateLimitChecker } = require('./rate-limit'); const { createProxyWebSocket } = require('./websocket-proxy'); const { @@ -93,6 +93,20 @@ const limiter = rateLimiter.create(); /** When false, token-budget warnings are never injected into request bodies. */ const isSteeringEnabled = () => process.env.AWF_ENABLE_TOKEN_STEERING === 'true'; +/** + * Backoff delays (ms) between successive model-not-supported retries. + * Index 0 → delay before the 1st retry, index 1 → delay before the 2nd retry. + */ +const MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS = [1000, 2000]; + +/** Resolves after `ms` milliseconds (overridable in tests via module-level setter). */ +let _sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +/** @internal Test-only: replace the sleep implementation so retries are instant. */ +function _setSleepForTests(fn) { _sleep = fn; } +/** @internal Test-only: restore the real sleep implementation. */ +function _resetSleepForTests() { _sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); } + function getUrlPathForSpan(requestUrl) { if (typeof requestUrl !== 'string' || !requestUrl) return '/'; try { @@ -246,11 +260,13 @@ const { handleUpstreamResponse } = createUpstreamResponseHandlers({ * @param {object} requestHeaders - Headers for the upstream request * @param {{ body: Buffer, targetHost: string, upstreamPath: string, req: object, * res: object, provider: string, requestId: string, startTime: number, - * span: object, requestBytes: number, hasRetried?: boolean }} ctx + * span: object, requestBytes: number, hasRetried?: boolean, + * modelNotSupportedRetryCount?: number }} ctx */ function sendUpstreamRequest(requestHeaders, { body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, hasRetried = false, + modelNotSupportedRetryCount = 0, }) { const options = { hostname: targetHost, port: 443, path: upstreamPath, @@ -262,10 +278,22 @@ function sendUpstreamRequest(requestHeaders, { handleUpstreamResponse(proxyRes, requestHeaders, { body, res, provider, requestId, req, targetHost, startTime, span, requestBytes, hasRetried, + modelNotSupportedRetryCount, onRetry: (retryHeaders) => sendUpstreamRequest(retryHeaders, { body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, hasRetried: true, + modelNotSupportedRetryCount, }), + onModelNotSupportedRetry: () => { + const delayMs = MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS[modelNotSupportedRetryCount] ?? 2000; + _sleep(delayMs).then(() => { + sendUpstreamRequest(requestHeaders, { + body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, + hasRetried, + modelNotSupportedRetryCount: modelNotSupportedRetryCount + 1, + }); + }); + }, }); }); @@ -500,4 +528,6 @@ module.exports = { getAndClearPendingSteeringMessage, getAndClearPendingTimeoutSteeringMessage, injectSteeringMessage, + _setSleepForTests, + _resetSleepForTests, }; diff --git a/containers/api-proxy/server.model-not-supported.test.js b/containers/api-proxy/server.model-not-supported.test.js new file mode 100644 index 000000000..1c7a7e8cb --- /dev/null +++ b/containers/api-proxy/server.model-not-supported.test.js @@ -0,0 +1,302 @@ +/** + * Tests for the transient Copilot "400 model not supported" retry logic. + * + * When the Copilot catalogue API returns a model set that does not include the + * requested model, the proxy retries the request up to MAX_MODEL_NOT_SUPPORTED_RETRIES + * times with a configurable backoff delay before surfacing the error to the caller. + */ + +const https = require('https'); +const { EventEmitter } = require('events'); + +const originalHttpsProxy = process.env.HTTPS_PROXY; +let proxyRequest; +let _setSleepForTests; +let _resetSleepForTests; + +beforeAll(() => { + delete process.env.HTTPS_PROXY; + jest.resetModules(); + ({ proxyRequest } = require('./server')); + ({ _setSleepForTests, _resetSleepForTests } = require('./proxy-request')); + // Make retries instant — no real setTimeout delays in unit tests. + _setSleepForTests(() => Promise.resolve()); +}); + +afterAll(() => { + _resetSleepForTests(); + if (originalHttpsProxy === undefined) { + delete process.env.HTTPS_PROXY; + } else { + process.env.HTTPS_PROXY = originalHttpsProxy; + } + jest.resetModules(); +}); + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function makeReq(headers = {}) { + const req = new EventEmitter(); + req.url = '/v1/chat/completions'; + req.method = 'POST'; + req.headers = { 'content-type': 'application/json', ...headers }; + return req; +} + +function makeRes() { + const res = { + headersSent: false, + setHeader: jest.fn(), + writeHead: jest.fn(() => { res.headersSent = true; }), + end: jest.fn(), + destroy: jest.fn(), + }; + return res; +} + +function makeProxyReq() { + const proxyReq = new EventEmitter(); + proxyReq.end = jest.fn(); + proxyReq.write = jest.fn(); + proxyReq.destroy = jest.fn(); + return proxyReq; +} + +function makeProxyRes(statusCode, headers = { 'content-type': 'application/json' }) { + const proxyRes = new EventEmitter(); + proxyRes.statusCode = statusCode; + proxyRes.headers = headers; + proxyRes.pipe = jest.fn(); + return proxyRes; +} + +/** Flush all pending microtasks/promises so async retry callbacks can run. */ +function flushPromises() { + return new Promise(resolve => setImmediate(resolve)); +} + +function getStructuredLogs(writeSpy, eventName) { + return writeSpy.mock.calls + .map(([line]) => { try { return JSON.parse(line); } catch { return null; } }) + .filter(entry => entry && entry.event === eventName); +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +describe('proxyRequest copilot model-not-supported retry', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('retries once after Copilot returns 400 model not supported, then succeeds', async () => { + const stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const responseHandlers = []; + const capturedOptions = []; + + jest.spyOn(https, 'request').mockImplementation((options, cb) => { + capturedOptions.push(options); + responseHandlers.push(cb); + return makeProxyReq(); + }); + + const req = makeReq(); + const res = makeRes(); + proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot'); + req.emit('end'); + + expect(capturedOptions).toHaveLength(1); + + // First response: 400 model not supported + const firstResponse = makeProxyRes(400); + responseHandlers[0](firstResponse); + firstResponse.emit('data', Buffer.from( + '{"message":"The requested model is not supported"}' + )); + firstResponse.emit('end'); + + await flushPromises(); + + // Retry should have been dispatched + expect(capturedOptions).toHaveLength(2); + + // Second response: 200 success + const secondResponse = makeProxyRes(200); + responseHandlers[1](secondResponse); + expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({ + 'x-request-id': expect.any(String), + })); + + const retryLogs = getStructuredLogs(stdoutWriteSpy, 'model_not_supported_retry'); + expect(retryLogs).toHaveLength(1); + expect(retryLogs[0]).toMatchObject({ + provider: 'copilot', + retry_attempt: 1, + max_retries: 2, + }); + }); + + it('retries a second time when the first retry also returns 400 model not supported', async () => { + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const responseHandlers = []; + const capturedOptions = []; + + jest.spyOn(https, 'request').mockImplementation((options, cb) => { + capturedOptions.push(options); + responseHandlers.push(cb); + return makeProxyReq(); + }); + + const req = makeReq(); + const res = makeRes(); + proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot'); + req.emit('end'); + + // First attempt: 400 model not supported → retry 1 + const resp1 = makeProxyRes(400); + responseHandlers[0](resp1); + resp1.emit('data', Buffer.from('{"message":"The requested model is not supported"}')); + resp1.emit('end'); + await flushPromises(); + + expect(capturedOptions).toHaveLength(2); + + // Retry 1: 400 model not supported → retry 2 + const resp2 = makeProxyRes(400); + responseHandlers[1](resp2); + resp2.emit('data', Buffer.from('{"message":"The requested model is not supported"}')); + resp2.emit('end'); + await flushPromises(); + + expect(capturedOptions).toHaveLength(3); + + // Retry 2: 200 success + const resp3 = makeProxyRes(200); + responseHandlers[2](resp3); + expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + }); + + it('surfaces the 400 to the client after exhausting all retries', async () => { + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const responseHandlers = []; + const capturedOptions = []; + + jest.spyOn(https, 'request').mockImplementation((options, cb) => { + capturedOptions.push(options); + responseHandlers.push(cb); + return makeProxyReq(); + }); + + const req = makeReq(); + const res = makeRes(); + proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot'); + req.emit('end'); + + const errorBody = '{"message":"The requested model is not supported"}'; + + // All 3 attempts return 400 model not supported + for (let attempt = 0; attempt < 3; attempt++) { + const resp = makeProxyRes(400); + responseHandlers[attempt](resp); + resp.emit('data', Buffer.from(errorBody)); + resp.emit('end'); + await flushPromises(); + } + + // 3 total attempts (original + 2 retries), no 4th + expect(capturedOptions).toHaveLength(3); + expect(res.writeHead).toHaveBeenCalledWith(400, expect.objectContaining({ + 'x-request-id': expect.any(String), + })); + expect(res.end).toHaveBeenCalledWith(Buffer.from(errorBody)); + }); + + it('does not retry a 400 that is not model-not-supported', async () => { + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const responseHandlers = []; + const capturedOptions = []; + + jest.spyOn(https, 'request').mockImplementation((options, cb) => { + capturedOptions.push(options); + responseHandlers.push(cb); + return makeProxyReq(); + }); + + const req = makeReq(); + const res = makeRes(); + proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot'); + req.emit('end'); + + const resp = makeProxyRes(400); + responseHandlers[0](resp); + resp.emit('data', Buffer.from('{"message":"max_tokens exceeded"}')); + resp.emit('end'); + await flushPromises(); + + // No retry for unrelated 400 + expect(capturedOptions).toHaveLength(1); + expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object)); + }); + + it('does not retry model-not-supported for non-copilot providers', async () => { + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const responseHandlers = []; + const capturedOptions = []; + + jest.spyOn(https, 'request').mockImplementation((options, cb) => { + capturedOptions.push(options); + responseHandlers.push(cb); + return makeProxyReq(); + }); + + const req = makeReq(); + const res = makeRes(); + // Use openai provider — model-not-supported retry only applies to copilot + proxyRequest(req, res, 'api.openai.com', { Authorization: '******' }, 'openai'); + req.emit('end'); + + const resp = makeProxyRes(400); + responseHandlers[0](resp); + resp.emit('data', Buffer.from('{"message":"The requested model is not supported"}')); + resp.emit('end'); + await flushPromises(); + + expect(capturedOptions).toHaveLength(1); + expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object)); + }); + + it('sends an identical request body on retry', async () => { + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const responseHandlers = []; + const capturedOptions = []; + const capturedBodies = []; + + jest.spyOn(https, 'request').mockImplementation((options, cb) => { + capturedOptions.push(options); + responseHandlers.push(cb); + const proxyReq = makeProxyReq(); + proxyReq.write = jest.fn(chunk => capturedBodies.push(chunk)); + return proxyReq; + }); + + const req = makeReq(); + const requestPayload = '{"model":"claude-opus-4.6","messages":[{"role":"user","content":"hi"}]}'; + const res = makeRes(); + proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot'); + req.emit('data', Buffer.from(requestPayload)); + req.emit('end'); + + const resp1 = makeProxyRes(400); + responseHandlers[0](resp1); + resp1.emit('data', Buffer.from('{"message":"The requested model is not supported"}')); + resp1.emit('end'); + await flushPromises(); + + expect(capturedOptions).toHaveLength(2); + // Both attempts should carry the same body + expect(capturedBodies[0].toString()).toBe(capturedBodies[1].toString()); + + const resp2 = makeProxyRes(200); + responseHandlers[1](resp2); + expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + }); +}); diff --git a/containers/api-proxy/upstream-response.js b/containers/api-proxy/upstream-response.js index 5618a6c96..2d38a196a 100644 --- a/containers/api-proxy/upstream-response.js +++ b/containers/api-proxy/upstream-response.js @@ -1,5 +1,26 @@ 'use strict'; +/** Maximum number of times to retry a Copilot 400 "model not supported" response. */ +const MAX_MODEL_NOT_SUPPORTED_RETRIES = 2; + +/** + * Pattern matching the Copilot error for a model that is not yet visible in + * the caller's entitlement catalogue. The error is transient — the catalogue + * is non-deterministic and often stabilises within seconds. + */ +const MODEL_NOT_SUPPORTED_PATTERN = /the requested model is not supported/i; + +/** + * Return true when the response body contains a Copilot "model not supported" + * error message. + * + * @param {Buffer} body + * @returns {boolean} + */ +function parseModelNotSupportedFromBody(body) { + return MODEL_NOT_SUPPORTED_PATTERN.test(body.toString('utf8')); +} + function createUpstreamResponseHandlers({ metrics, logRequest, @@ -47,15 +68,23 @@ function createUpstreamResponseHandlers({ } function handleUpstreamResponse(proxyRes, requestHeaders, { - res, provider, requestId, req, targetHost, startTime, span, requestBytes, hasRetried, onRetry, + res, provider, requestId, req, targetHost, startTime, span, requestBytes, + hasRetried, onRetry, + modelNotSupportedRetryCount = 0, onModelNotSupportedRetry, }) { let responseBytes = 0; const billingInfo = extractBillingHeaders(proxyRes.headers); const initiatorSent = requestHeaders['x-initiator'] || null; - const shouldBuffer400ForHeaderStrip = - (provider === 'anthropic' || provider === 'copilot') && - !hasRetried && - proxyRes.statusCode === 400; + + // Buffer the 400 response body when we may need to inspect it for either: + // (a) a deprecated Anthropic/Copilot beta-header value (first attempt only), or + // (b) a transient Copilot "model not supported" catalogue error (up to MAX retries). + const shouldBuffer400 = + proxyRes.statusCode === 400 && + ( + ((provider === 'anthropic' || provider === 'copilot') && !hasRetried) || + (provider === 'copilot' && modelNotSupportedRetryCount < MAX_MODEL_NOT_SUPPORTED_RETRIES) + ); const completionCtx = { startTime, provider, req, requestBytes, targetHost, requestId }; const authErrCtx = { requestId, provider, targetHost, req }; @@ -71,7 +100,7 @@ function createUpstreamResponseHandlers({ }); }); - if (shouldBuffer400ForHeaderStrip) { + if (shouldBuffer400) { const bufferedChunks = []; proxyRes.on('data', (chunk) => { responseBytes += chunk.length; @@ -79,18 +108,40 @@ function createUpstreamResponseHandlers({ }); proxyRes.on('end', () => { const responseBody = Buffer.concat(bufferedChunks); - const deprecated = parseDeprecatedHeaderFromBody(responseBody); - if (deprecated) { - const retryHeaders = { ...requestHeaders }; - const stripped = learnAndStripDeprecatedHeaderValue( - retryHeaders, deprecated.header, deprecated.value, requestId, provider, - ); - if (stripped) { - onRetry(retryHeaders); - return; + + // ── (a) Deprecated beta-header retry (first attempt for anthropic/copilot) ── + if (!hasRetried && (provider === 'anthropic' || provider === 'copilot')) { + const deprecated = parseDeprecatedHeaderFromBody(responseBody); + if (deprecated) { + const retryHeaders = { ...requestHeaders }; + const stripped = learnAndStripDeprecatedHeaderValue( + retryHeaders, deprecated.header, deprecated.value, requestId, provider, + ); + if (stripped) { + onRetry(retryHeaders); + return; + } } } + // ── (b) Transient model-not-supported retry (copilot only, up to MAX) ────── + if ( + provider === 'copilot' && + modelNotSupportedRetryCount < MAX_MODEL_NOT_SUPPORTED_RETRIES && + onModelNotSupportedRetry && + parseModelNotSupportedFromBody(responseBody) + ) { + logRequest('warn', 'model_not_supported_retry', { + request_id: requestId, + provider, + retry_attempt: modelNotSupportedRetryCount + 1, + max_retries: MAX_MODEL_NOT_SUPPORTED_RETRIES, + message: `Copilot returned 400 model not supported (transient); retrying (attempt ${modelNotSupportedRetryCount + 1}/${MAX_MODEL_NOT_SUPPORTED_RETRIES})`, + }); + onModelNotSupportedRetry(); + return; + } + logRequestCompletion(proxyRes.statusCode, responseBytes, initiatorSent, billingInfo, completionCtx); logUpstreamAuthError(proxyRes.statusCode, authErrCtx); @@ -139,4 +190,6 @@ function createUpstreamResponseHandlers({ module.exports = { createUpstreamResponseHandlers, + parseModelNotSupportedFromBody, + MAX_MODEL_NOT_SUPPORTED_RETRIES, }; From e99cc19f1be3705d09472c809e73034e259757ee Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 26 May 2026 20:58:12 -0700 Subject: [PATCH 3/3] Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- containers/api-proxy/proxy-request.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/api-proxy/proxy-request.js b/containers/api-proxy/proxy-request.js index 37a3713e4..8a1fa38f3 100644 --- a/containers/api-proxy/proxy-request.js +++ b/containers/api-proxy/proxy-request.js @@ -21,7 +21,7 @@ const { learnAndStripDeprecatedHeaderValue, } = require('./deprecated-header-tracker'); const { extractBillingHeaders } = require('./billing-headers'); -const { createUpstreamResponseHandlers, MAX_MODEL_NOT_SUPPORTED_RETRIES } = require('./upstream-response'); +const { createUpstreamResponseHandlers } = require('./upstream-response'); const { createRateLimitChecker } = require('./rate-limit'); const { createProxyWebSocket } = require('./websocket-proxy'); const {