Skip to content

Commit 140c4fa

Browse files
Copilotlpcox
andauthored
Route Copilot auto requests to Codex-compatible Responses models (#9422)
* Initial plan * fix: route Copilot auto for Responses requests Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: enforce concrete model policy for Copilot Responses auto Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
1 parent aa3a952 commit 140c4fa

6 files changed

Lines changed: 310 additions & 18 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
'use strict';
2+
3+
let makeModelBodyTransform;
4+
5+
describe('Copilot Responses auto routing', () => {
6+
const originalEnv = { ...process.env };
7+
8+
beforeEach(() => {
9+
delete process.env.AWF_ALLOWED_MODELS;
10+
delete process.env.AWF_DISALLOWED_MODELS;
11+
jest.isolateModules(() => {
12+
({ makeModelBodyTransform } = require('./model-config'));
13+
});
14+
});
15+
16+
afterEach(() => {
17+
process.env = { ...originalEnv };
18+
});
19+
20+
const models = {
21+
copilot: ['gpt-5.3-codex', 'gpt-5.4-codex', 'gpt-5.5-chat'],
22+
};
23+
const metadata = [
24+
{ id: 'gpt-5.3-codex', supportedEndpoints: ['/responses'] },
25+
{ id: 'gpt-5.4-codex', supportedEndpoints: ['/responses', '/chat/completions'] },
26+
{ id: 'gpt-5.5-chat', supportedEndpoints: ['/chat/completions'] },
27+
];
28+
29+
function createTransform(overrides = {}) {
30+
return makeModelBodyTransform(
31+
'copilot',
32+
overrides.models || models,
33+
overrides.refresh || jest.fn(),
34+
() => new Set(['copilot']),
35+
overrides.getRuntimeModels || (() => metadata),
36+
overrides.isNativeCopilot ?? true,
37+
);
38+
}
39+
40+
it('routes Responses auto to the highest-version inventory model compatible with Codex', async () => {
41+
const transform = createTransform();
42+
const req = { method: 'POST', url: '/v1/responses?stream=true' };
43+
const body = Buffer.from(JSON.stringify({
44+
model: 'copilot/auto',
45+
input: 'Hello',
46+
tools: [{ type: 'custom' }],
47+
}));
48+
49+
const result = await transform(body, req);
50+
51+
expect(JSON.parse(result.toString('utf8'))).toEqual({
52+
model: 'gpt-5.4-codex',
53+
input: 'Hello',
54+
tools: [{ type: 'custom' }],
55+
});
56+
expect(req.awfModelCandidates).toEqual(['gpt-5.4-codex', 'gpt-5.3-codex']);
57+
});
58+
59+
it('skips a disabled higher-version Codex model', async () => {
60+
const transform = createTransform({
61+
getRuntimeModels: () => metadata.map(record => ({
62+
...record,
63+
modelPickerEnabled: record.id !== 'gpt-5.4-codex',
64+
})),
65+
});
66+
const req = { method: 'POST', url: '/responses' };
67+
68+
const result = await transform(Buffer.from('{"model":"auto"}'), req);
69+
70+
expect(JSON.parse(result.toString('utf8')).model).toBe('gpt-5.3-codex');
71+
expect(req.awfModelCandidates).toEqual(['gpt-5.3-codex']);
72+
});
73+
74+
it.each([
75+
['AWF_ALLOWED_MODELS', ['copilot/gpt-5.3-codex']],
76+
['AWF_DISALLOWED_MODELS', ['copilot/gpt-5.4-codex']],
77+
])('routes auto using concrete-model policy from %s', async (key, patterns) => {
78+
process.env[key] = JSON.stringify(patterns);
79+
jest.isolateModules(() => {
80+
({ makeModelBodyTransform } = require('./model-config'));
81+
});
82+
const refresh = jest.fn();
83+
const transform = createTransform({ refresh });
84+
const req = { method: 'POST', url: '/responses' };
85+
86+
const result = await transform(Buffer.from('{"model":"copilot/auto"}'), req);
87+
88+
expect(JSON.parse(result.toString('utf8')).model).toBe('gpt-5.3-codex');
89+
expect(req.awfModelCandidates).toEqual(['gpt-5.3-codex']);
90+
expect(refresh).not.toHaveBeenCalled();
91+
});
92+
93+
it.each([
94+
['AWF_ALLOWED_MODELS', ['gpt-5.5-chat']],
95+
['AWF_DISALLOWED_MODELS', ['*codex*']],
96+
])('fails closed after one refresh when %s excludes all compatible models', async (key, patterns) => {
97+
process.env[key] = JSON.stringify(patterns);
98+
jest.isolateModules(() => {
99+
({ makeModelBodyTransform } = require('./model-config'));
100+
});
101+
const refresh = jest.fn();
102+
const transform = createTransform({ refresh });
103+
104+
await expect(transform(
105+
Buffer.from('{"model":"auto"}'),
106+
{ method: 'POST', url: '/responses' },
107+
)).rejects.toMatchObject({
108+
statusCode: 503,
109+
code: 'copilot_auto_responses_model_unavailable',
110+
});
111+
expect(refresh).toHaveBeenCalledTimes(1);
112+
expect(refresh).toHaveBeenCalledWith('copilot');
113+
});
114+
115+
it('fails closed when all compatible models are disabled', async () => {
116+
const refresh = jest.fn();
117+
const transform = createTransform({
118+
getRuntimeModels: () => metadata.map(record => ({ ...record, modelPickerEnabled: false })),
119+
refresh,
120+
});
121+
122+
await expect(transform(
123+
Buffer.from('{"model":"auto"}'),
124+
{ method: 'POST', url: '/responses' },
125+
)).rejects.toMatchObject({
126+
statusCode: 503,
127+
code: 'copilot_auto_responses_model_unavailable',
128+
});
129+
expect(refresh).toHaveBeenCalledTimes(1);
130+
});
131+
132+
it('leaves Chat Completions auto unchanged', async () => {
133+
const transform = createTransform();
134+
const body = Buffer.from(JSON.stringify({ model: 'auto', messages: [] }));
135+
136+
await expect(transform(body, { method: 'POST', url: '/chat/completions' })).resolves.toBeNull();
137+
});
138+
139+
it('refreshes the Copilot inventory before reporting no compatible model', async () => {
140+
const refreshedModels = { copilot: ['gpt-4o'] };
141+
const refresh = jest.fn(async () => {
142+
refreshedModels.copilot = ['gpt-5.3-codex'];
143+
});
144+
const transform = createTransform({
145+
models: refreshedModels,
146+
getRuntimeModels: () => metadata,
147+
refresh,
148+
});
149+
const req = { method: 'POST', url: '/responses' };
150+
const result = await transform(Buffer.from('{"model":"auto"}'), req);
151+
152+
expect(refresh).toHaveBeenCalledWith('copilot');
153+
expect(JSON.parse(result.toString('utf8')).model).toBe('gpt-5.3-codex');
154+
});
155+
156+
it('fails explicitly when the inventory has no Codex-compatible Responses model', async () => {
157+
const transform = createTransform({
158+
models: { copilot: ['gpt-5.5-chat'] },
159+
refresh: jest.fn(),
160+
});
161+
162+
await expect(transform(
163+
Buffer.from('{"model":"auto"}'),
164+
{ method: 'POST', url: '/responses' },
165+
)).rejects.toMatchObject({
166+
statusCode: 503,
167+
code: 'copilot_auto_responses_model_unavailable',
168+
});
169+
});
170+
171+
it('does not route auto for custom Copilot targets', async () => {
172+
const transform = createTransform({ isNativeCopilot: false });
173+
174+
const result = transform
175+
? await transform(Buffer.from('{"model":"auto"}'), { method: 'POST', url: '/responses' })
176+
: null;
177+
expect(result).toBeNull();
178+
});
179+
});

‎containers/api-proxy/model-body-rewriter.js‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
const { parseBodyAsObject } = require('./body-utils');
1212
const { resolveModel } = require('./model-resolver');
13-
const { stripRedundantProviderPrefix } = require('./model-utils');
13+
const { compareByVersion, stripRedundantProviderPrefix } = require('./model-utils');
14+
const { isModelPermittedByPolicy } = require('./guards/model-policy-guard');
1415

1516
/**
1617
* Attempt to rewrite the "model" field in a JSON request body using the alias map.
@@ -77,7 +78,62 @@ function stripRedundantModelPrefixInBody(body, provider) {
7778
return Buffer.from(JSON.stringify(parsed), 'utf8');
7879
}
7980

81+
function isCopilotAutoResponsesRequest(body, req) {
82+
if (req?.method !== 'POST' || typeof req.url !== 'string') return false;
83+
let pathname;
84+
try {
85+
pathname = new URL(req.url, 'http://localhost').pathname;
86+
} catch {
87+
return false;
88+
}
89+
if (pathname !== '/responses' && pathname !== '/v1/responses') return false;
90+
const parsed = parseBodyAsObject(body);
91+
return typeof parsed?.model === 'string' &&
92+
stripRedundantProviderPrefix(parsed.model, 'copilot').toLowerCase() === 'auto';
93+
}
94+
95+
function rewriteCopilotAutoResponsesModelInBody(body, availableModels, modelRecords, modelPolicyConfig) {
96+
const available = new Set(
97+
(availableModels || []).filter(model => typeof model === 'string').map(model => model.toLowerCase()),
98+
);
99+
const recordsById = new Map(
100+
(modelRecords || [])
101+
.filter(record => typeof record?.id === 'string')
102+
.map(record => [record.id.toLowerCase(), record]),
103+
);
104+
const candidates = [...available]
105+
.map(id => recordsById.get(id))
106+
.filter(record => record &&
107+
record.modelPickerEnabled !== false &&
108+
/(?:^|[-.])codex(?:$|[-.])/i.test(record.id) &&
109+
Array.isArray(record.supportedEndpoints) &&
110+
record.supportedEndpoints.some(endpoint => ['/responses', '/v1/responses', 'responses'].includes(endpoint)) &&
111+
isModelPermittedByPolicy(
112+
record.id,
113+
modelPolicyConfig?.allowedModels,
114+
modelPolicyConfig?.disallowedModels,
115+
'copilot',
116+
))
117+
.map(record => record.id)
118+
.sort(compareByVersion);
119+
if (candidates.length === 0) return null;
120+
121+
const parsed = parseBodyAsObject(body);
122+
const originalModel = parsed.model;
123+
const resolvedModel = candidates[0];
124+
parsed.model = resolvedModel;
125+
return {
126+
body: Buffer.from(JSON.stringify(parsed), 'utf8'),
127+
originalModel,
128+
resolvedModel,
129+
candidates,
130+
log: [`[model-resolver] Copilot Responses auto: "${originalModel}" → "${resolvedModel}"`],
131+
};
132+
}
133+
80134
module.exports = {
81135
rewriteModelInBody,
82136
stripRedundantModelPrefixInBody,
137+
isCopilotAutoResponsesRequest,
138+
rewriteCopilotAutoResponsesModelInBody,
83139
};

‎containers/api-proxy/model-config.js‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@ const {
55
filterResolvableAliases,
66
filterAvailableModelsToConfiguredProviders,
77
} = require('./model-resolver');
8-
const { rewriteModelInBody } = require('./model-body-rewriter');
8+
const {
9+
rewriteModelInBody,
10+
isCopilotAutoResponsesRequest,
11+
rewriteCopilotAutoResponsesModelInBody,
12+
} = require('./model-body-rewriter');
913
const { sanitizeForLog, logRequest } = require('./logging');
1014
const { diag } = require('./token-persistence');
1115
const { getCopilotModelFallbackPolicy } = require('./providers/copilot-auth');
12-
const { ALLOWED_MODELS, DISALLOWED_MODELS } = require('./guards/model-policy-guard');
16+
const {
17+
ALLOWED_MODELS,
18+
DISALLOWED_MODELS,
19+
} = require('./guards/model-policy-guard');
1320
const { isModelPriceable } = require('./guards/ai-credits-guard');
1421

1522
const MODEL_ALIASES_RAW = (process.env.AWF_MODEL_ALIASES || '').trim() || undefined;
@@ -121,19 +128,53 @@ function getEffectiveModelFallbackForReflect(adapters) {
121128
return effectiveByProvider;
122129
}
123130

124-
function makeModelBodyTransform(provider, cachedModels, refreshProviderModelsForResolution, getConfiguredModelCacheKeys) {
125-
if (!MODEL_ALIASES) return null;
131+
function makeModelBodyTransform(
132+
provider,
133+
cachedModels,
134+
refreshProviderModelsForResolution,
135+
getConfiguredModelCacheKeys,
136+
getRuntimeModels = () => [],
137+
isNativeCopilot = false,
138+
) {
139+
const canRouteCopilotAutoResponses = provider === 'copilot' && isNativeCopilot === true;
140+
if (!MODEL_ALIASES && !canRouteCopilotAutoResponses) return null;
126141
const providerModelFallback = getModelFallbackForProvider(provider);
127142
const resolvableModels = () => (
128143
getConfiguredModelCacheKeys
129144
? filterAvailableModelsToConfiguredProviders(cachedModels, getConfiguredModelCacheKeys())
130145
: cachedModels
131146
);
132147
return async (body, req) => {
133-
let result = rewriteModelInBody(body, provider, MODEL_ALIASES.models, resolvableModels(), providerModelFallback, MODEL_POLICY_CONFIG);
134-
if (!result || (result.fallback && result.fallback.activated)) {
135-
await refreshProviderModelsForResolution(provider);
148+
let result = null;
149+
if (
150+
canRouteCopilotAutoResponses &&
151+
isCopilotAutoResponsesRequest(body, req)
152+
) {
153+
const getAutoResolution = () => rewriteCopilotAutoResponsesModelInBody(
154+
body,
155+
cachedModels.copilot,
156+
getRuntimeModels('copilot'),
157+
MODEL_POLICY_CONFIG,
158+
);
159+
result = getAutoResolution();
160+
if (!result) {
161+
await refreshProviderModelsForResolution(provider);
162+
result = getAutoResolution();
163+
}
164+
if (!result) {
165+
const error = new Error('No available GitHub Copilot model supports Responses and Codex tools');
166+
error.statusCode = 503;
167+
error.code = 'copilot_auto_responses_model_unavailable';
168+
error.type = 'service_unavailable';
169+
throw error;
170+
}
171+
}
172+
if (!result && MODEL_ALIASES) {
136173
result = rewriteModelInBody(body, provider, MODEL_ALIASES.models, resolvableModels(), providerModelFallback, MODEL_POLICY_CONFIG);
174+
if (!result || (result.fallback && result.fallback.activated)) {
175+
await refreshProviderModelsForResolution(provider);
176+
result = rewriteModelInBody(body, provider, MODEL_ALIASES.models, resolvableModels(), providerModelFallback, MODEL_POLICY_CONFIG);
177+
}
137178
}
138179
if (!result) return null;
139180
// Store ranked candidates on the request object so endpoint-blocked retry

‎containers/api-proxy/server.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ if (!HTTPS_PROXY) {
101101
}
102102

103103
const { createAllAdapters } = require('./providers');
104+
const {
105+
resolveApiKey,
106+
resolveCopilotAuthToken,
107+
deriveCopilotApiTarget,
108+
isGithubCopilotCatalogTarget,
109+
} = require('./providers/copilot-auth');
104110

105111
/**
106112
* Model cache keys of the provider slots that are actually configured for this
@@ -124,6 +130,11 @@ function makeModelBodyTransform(provider) {
124130
cachedModels,
125131
refreshProviderModelsForResolution,
126132
getConfiguredModelCacheKeys,
133+
getRuntimeModels,
134+
provider === 'copilot' &&
135+
!resolveApiKey(process.env) &&
136+
Boolean(resolveCopilotAuthToken(process.env)) &&
137+
isGithubCopilotCatalogTarget(deriveCopilotApiTarget(process.env)),
127138
);
128139
}
129140

‎docs/api-proxy-sidecar.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,12 @@ that catalog even when `chatgpt.com` is allowed, so `auto` can fail with
117117
`The requested model is not supported`.
118118

119119
This OpenAI-native `auto` limitation does not apply to Copilot's own `auto`
120-
model selector. Harnesses that route through the Copilot provider (port
121-
`10002`) — including Codex and Pi — can request `auto` (or the LiteLLM-style
122-
`copilot/auto`) and the api-proxy sidecar forwards `auto` to Copilot after
123-
stripping the redundant provider prefix, so Copilot resolves it dynamically at
124-
request time.
120+
model selector. Pi's Chat Completions requests continue to forward `auto`
121+
unchanged. For native GitHub Copilot Responses requests, including Codex's
122+
`copilot/auto`, the sidecar selects the highest-version Codex model in the live
123+
Copilot inventory that advertises Responses support. It keeps the Copilot
124+
provider and credentials; if the inventory has no eligible model, the sidecar
125+
returns an explicit error. Custom Copilot/BYOK targets are not rewritten.
125126

126127
Since [PR #9005](https://github.com/github/gh-aw-firewall/pull/9005), the
127128
redundant `<provider>/` prefix strip applied above is unconditional for every

0 commit comments

Comments
 (0)