fix: support token auth for CLI SDK mock server to enable auto-model in smoke tests (#320072)

* fix: support token auth for CLI SDK mock server to enable auto-model in smoke tests

- Add `advanced.debug.overrideAuthType` setting to control HMAC vs token
  auth when overrideProxyUrl is set (default: HMAC for dev, token for tests)
- Update mock server model definitions to match real CAPI response shape
  (family, vendor, version, supported_endpoints, billing, etc.)
- Add `selected_model` to mock `/models/session` response (required by SDK
  auto-mode resolution)
- Add Responses API SSE handler for gpt-5.3-codex which uses `/responses`
  instead of `/chat/completions`
- DRY up mock model definitions with shared `ALL_MODELS` array

* fix: add inspectConfig to test mock for copilotCliAuth and skip the other CLI smoke tests for now

* Don't run in PRs for now
This commit is contained in:
Alexandru Dima
2026-06-05 13:52:11 +02:00
committed by GitHub
parent 95b4defad4
commit ff61118bc0
6 changed files with 345 additions and 118 deletions
@@ -631,22 +631,24 @@ export class CopilotCLISDK implements ICopilotCLISDK {
const overrideProxyUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl);
if (overrideProxyUrl) {
this.logService.info('[CopilotCLISession] Proxy URL configured, skipping client-side token validation');
return {
type: 'hmac',
hmac: 'empty',
host: 'https://github.com',
copilotUser: {
endpoints: {
api: overrideProxyUrl,
// `proxy` must also point at the mock server so that SDK
// calls to /copilot_internal/v2/token and /models/session
// are routed to the mock instead of the real GitHub API
// (which would reject the fake HMAC with a 401).
proxy: overrideProxyUrl,
}
// Only respect this from user (global) settings — a malicious workspace
// setting could downgrade auth from HMAC to token.
const authTypeInspect = this.configurationService.inspectConfig(ConfigKey.Shared.DebugOverrideAuthType);
const authType = authTypeInspect?.globalValue ?? 'hmac';
this.logService.info(`[CopilotCLISession] Proxy URL configured (authType=${authType}), skipping client-side token validation`);
const copilotUser = {
endpoints: {
api: overrideProxyUrl,
// `proxy` must also point at the mock server so that SDK
// calls to /copilot_internal/v2/token and /models/session
// are routed to the mock instead of the real GitHub API.
proxy: overrideProxyUrl,
}
};
if (authType === 'token') {
return { type: 'token', token: 'mock-token', host: 'https://github.com', copilotUser };
}
return { type: 'hmac', hmac: 'empty', host: 'https://github.com', copilotUser };
}
const { resolveAuthInfoFromToken } = await this.getPackage();
@@ -783,18 +783,16 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
await Promise.all(promises);
if (sessionOptions.copilotUrl) {
sdkSession.updateOptions({
authInfo: {
type: 'hmac',
hmac: 'empty',
host: 'https://github.com',
copilotUser: {
endpoints: {
api: sessionOptions.copilotUrl
}
}
}
});
// Only respect this from user (global) settings — a malicious workspace
// setting could downgrade auth from HMAC to token.
const authTypeInspect = this.configurationService.inspectConfig(ConfigKey.Shared.DebugOverrideAuthType);
const authType = authTypeInspect?.globalValue ?? 'hmac';
const copilotUser = { endpoints: { api: sessionOptions.copilotUrl } };
const host = 'https://github.com' as const;
const authInfo = authType === 'token'
? { type: 'token' as const, token: 'mock-token', host, copilotUser }
: { type: 'hmac' as const, hmac: 'empty', host, copilotUser };
sdkSession.updateOptions({ authInfo });
}
this.logService.trace(`[CopilotCLISession] Created new CopilotCLI session ${sdkSession.sessionId}.`);
@@ -80,6 +80,9 @@ describe('CopilotCLISDK Authentication', () => {
return 'https://proxy.example.com';
}
return undefined;
},
inspectConfig() {
return undefined;
}
} as unknown as IConfigurationService;
@@ -581,6 +581,8 @@ export namespace ConfigKey {
export namespace Shared {
/** Allows for overriding the base domain we use for making requests to the CAPI. This helps CAPI devs develop against a local instance. */
export const DebugOverrideProxyUrl = defineSetting<string | undefined>('advanced.debug.overrideProxyUrl', ConfigType.Simple, undefined);
/** Auth type to use when overrideProxyUrl or overrideCapiUrl is set. 'hmac' (default) for internal dev builds, 'token' for smoke tests / mock servers that don't support HMAC. */
export const DebugOverrideAuthType = defineSetting<'hmac' | 'token'>('advanced.debug.overrideAuthType', ConfigType.Simple, 'hmac');
export const DebugOverrideCAPIUrl = defineSetting<string | undefined>('advanced.debug.overrideCapiUrl', ConfigType.Simple, undefined);
export const DebugUseNodeFetchFetcher = defineSetting('advanced.debug.useNodeFetchFetcher', ConfigType.Simple, true);
export const DebugUseNodeFetcher = defineSetting('advanced.debug.useNodeFetcher', ConfigType.Simple, false);
+310 -91
View File
@@ -215,49 +215,116 @@ const MODEL = 'gpt-4o-2024-08-06';
* /models list, otherwise the SDK fails with "No model available".
*/
const EXTRA_MODELS = [
// gpt-5.3-codex — the Copilot CLI SDK's default model.
// Shape matches real CAPI /models response exactly.
{
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex (Mock)',
version: '2025-01-01',
vendor: 'copilot',
model_picker_enabled: false,
is_chat_default: false,
name: 'GPT-5.3-Codex (Mock)',
object: 'model',
version: 'gpt-5.3-codex',
vendor: 'OpenAI',
model_picker_enabled: true,
model_picker_category: 'powerful',
model_picker_price_category: 'medium',
is_chat_default: true,
is_chat_fallback: false,
billing: { is_premium: false, multiplier: 0 },
preview: false,
billing: { restricted_to: ['pro', 'edu', 'pro_plus', 'individual_trial', 'business', 'enterprise', 'max'], token_prices: { batch_size: 1000000, default: { cache_price: 17, context_max: 272000, input_price: 175, output_price: 1400 } } },
capabilities: {
type: 'chat',
family: 'gpt-4o',
family: 'gpt-5.3-codex',
tokenizer: 'o200k_base',
limits: { max_prompt_tokens: 10000000, max_output_tokens: 131072, max_context_window_tokens: 10000000 },
supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false },
object: 'model_capabilities',
limits: { max_prompt_tokens: 272000, max_output_tokens: 128000, max_context_window_tokens: 400000, vision: { max_prompt_image_size: 3145728, max_prompt_images: 1, supported_media_types: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] } },
supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: true, structured_outputs: true, reasoning_effort: ['low', 'medium', 'high', 'xhigh'] },
},
supported_endpoints: ['/chat/completions'],
supported_endpoints: ['/responses'],
},
// Anthropic Claude model — required by the Claude Code session type, which
// filters endpoints for `modelProvider: 'Anthropic'`, `apiType: 'messages'`,
// `supportsToolCalls: true`, and `showInModelPicker: true`
// (see `ClaudeCodeModels._fetchAvailableEndpoints`). Routes to the
// `/v1/messages` mock handler which emits Anthropic-format SSE.
// Anthropic Claude model — required by the Claude Code session type.
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5 (Mock)',
version: '2025-01-01',
object: 'model',
version: 'claude-sonnet-4.5',
vendor: 'Anthropic',
model_picker_enabled: true,
model_picker_category: 'versatile',
model_picker_price_category: 'medium',
is_chat_default: false,
is_chat_fallback: false,
billing: { is_premium: false, multiplier: 0 },
preview: false,
billing: { restricted_to: ['pro', 'pro_plus', 'max', 'business', 'enterprise'], token_prices: { batch_size: 1000000, default: { cache_price: 30, input_price: 300, output_price: 1500 } } },
capabilities: {
type: 'chat',
family: 'claude-sonnet-4.5',
tokenizer: 'o200k_base',
limits: { max_prompt_tokens: 200000, max_output_tokens: 8192, max_context_window_tokens: 200000 },
supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: true },
object: 'model_capabilities',
limits: { max_prompt_tokens: 168000, max_output_tokens: 32000, max_context_window_tokens: 200000, max_non_streaming_output_tokens: 16000, vision: { max_prompt_image_size: 3145728, max_prompt_images: 5, supported_media_types: ['image/jpeg', 'image/png', 'image/webp'] } },
supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: true, max_thinking_budget: 32000, min_thinking_budget: 1024 },
},
supported_endpoints: ['/v1/messages'],
supported_endpoints: ['/chat/completions', '/v1/messages'],
},
];
/**
* Complete model list used by both GET /models and GET /models/{id}.
* Kept in a single array so the two handlers always return consistent data.
*/
const ALL_MODELS = [
{
id: MODEL,
name: 'GPT-4o (Mock)',
object: 'model',
version: 'gpt-4o-2024-08-06',
vendor: 'Azure OpenAI',
model_picker_enabled: false,
model_picker_price_category: 'medium',
is_chat_default: false,
is_chat_fallback: true,
preview: false,
billing: { token_prices: { batch_size: 1000000, default: { cache_price: 125, input_price: 250, output_price: 1000 } } },
capabilities: {
type: 'chat',
family: 'gpt-4o',
tokenizer: 'o200k_base',
object: 'model_capabilities',
limits: {
// Use a very large token limit so the Responses API compaction
// threshold (90% of max_prompt_tokens) is never reached during
// perf benchmarks.
max_prompt_tokens: 10000000,
max_output_tokens: 131072,
max_context_window_tokens: 10000000,
},
supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false },
},
supported_endpoints: ['/chat/completions'],
},
{
id: 'gpt-4o-mini',
name: 'GPT-4o mini (Mock)',
object: 'model',
version: 'gpt-4o-mini-2024-07-18',
vendor: 'Azure OpenAI',
model_picker_enabled: false,
model_picker_price_category: 'low',
is_chat_default: false,
is_chat_fallback: false,
preview: false,
billing: { token_prices: { batch_size: 1000000, default: { cache_price: 15, input_price: 30, output_price: 120 } } },
capabilities: {
type: 'chat',
family: 'gpt-4o-mini',
tokenizer: 'o200k_base',
object: 'model_capabilities',
limits: { max_prompt_tokens: 12288, max_output_tokens: 4096, max_context_window_tokens: 128000 },
supports: { streaming: true, tool_calls: true, parallel_tool_calls: true },
},
supported_endpoints: ['/chat/completions'],
},
...EXTRA_MODELS,
];
/**
* @param {string} content
* @param {number} index
@@ -502,6 +569,7 @@ function handleRequest(req, res) {
readBody().then(() => {
json(200, {
available_models: [MODEL, 'gpt-4o-mini', ...EXTRA_MODELS.map(m => m.id)],
selected_model: 'gpt-5.3-codex',
session_token: 'perf-session-token-' + Date.now(),
expires_at: Math.floor(Date.now() / 1000) + 3600,
discounted_costs: {},
@@ -512,68 +580,7 @@ function handleRequest(req, res) {
// -- Models (DomainService.capiModelsURL = /models) --------------
if (path === '/models' && req.method === 'GET') {
json(200, {
data: [
{
id: MODEL,
name: 'GPT-4o (Mock)',
version: '2024-05-13',
vendor: 'copilot',
model_picker_enabled: true,
is_chat_default: true,
is_chat_fallback: true,
billing: { is_premium: false, multiplier: 0 },
capabilities: {
type: 'chat',
family: 'gpt-4o',
tokenizer: 'o200k_base',
limits: {
// Use a very large token limit so the Responses API compaction
// threshold (90% of max_prompt_tokens) is never reached during
// perf benchmarks.
max_prompt_tokens: 10000000,
max_output_tokens: 131072,
max_context_window_tokens: 10000000,
},
supports: {
streaming: true,
tool_calls: true,
parallel_tool_calls: true,
vision: false,
},
},
supported_endpoints: ['/chat/completions'],
},
{
id: 'gpt-4o-mini',
name: 'GPT-4o mini (Mock)',
version: '2024-07-18',
vendor: 'copilot',
model_picker_enabled: false,
is_chat_default: false,
is_chat_fallback: false,
billing: { is_premium: false, multiplier: 0 },
capabilities: {
type: 'chat',
family: 'gpt-4o-mini',
tokenizer: 'o200k_base',
limits: {
max_prompt_tokens: 10000000,
max_output_tokens: 131072,
max_context_window_tokens: 10000000,
},
supports: {
streaming: true,
tool_calls: true,
parallel_tool_calls: true,
vision: false,
},
},
supported_endpoints: ['/chat/completions'],
},
...EXTRA_MODELS,
],
});
json(200, { data: ALL_MODELS });
return;
}
@@ -584,22 +591,30 @@ function handleRequest(req, res) {
json(200, { state: 'accepted', terms: '' });
return;
}
json(200, {
const knownModel = ALL_MODELS.find(m => m.id === modelId);
// TODO: give a 404 for unknown models instead of a fallback response. This requires
const result = knownModel || {
id: modelId || MODEL,
name: 'GPT-4o (Mock)',
name: `${modelId} (Mock)`,
version: '2024-05-13',
vendor: 'copilot',
model_picker_enabled: true,
is_chat_default: true,
is_chat_fallback: true,
model_picker_enabled: false,
is_chat_default: false,
is_chat_fallback: false,
billing: { is_premium: false, multiplier: 0 },
capabilities: {
type: 'chat',
family: 'gpt-4o',
family: modelId || 'gpt-4o',
tokenizer: 'o200k_base',
limits: { max_prompt_tokens: 10000000, max_output_tokens: 131072, max_context_window_tokens: 10000000 },
object: 'model_capabilities',
limits: { max_prompt_tokens: 272000, max_output_tokens: 128000, max_context_window_tokens: 400000 },
supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false },
},
});
supported_endpoints: ['/chat/completions'],
};
const ts = new Date().toISOString().slice(11, -1);
_log(`[mock-llm] ${ts} GET /models/${modelId}${knownModel ? 'known' : 'fallback'}, family=${result.capabilities?.family}, endpoints=${JSON.stringify(result.supported_endpoints)}`);
json(200, result);
return;
}
@@ -641,8 +656,11 @@ function handleRequest(req, res) {
}
// -- Responses API (DomainService.capiResponsesURL = /responses) --
// The Responses API uses a different SSE event format than Chat Completions.
// The SDK expects events like response.created, response.output_item.added,
// response.output_text.delta, response.output_item.done, response.completed.
if (path === '/responses' && req.method === 'POST') {
readBody().then((/** @type {string} */ body) => handleChatCompletions(body, res));
readBody().then((/** @type {string} */ body) => handleResponsesApi(body, res));
return;
}
@@ -909,6 +927,207 @@ async function streamContent(res, chunks, isScenarioRequest) {
}
}
// ----- Responses API (OpenAI) ---------------------------------------------------
/**
* Handle a Responses API request. The Responses API uses a different SSE event
* format than Chat Completions the SDK expects `response.created`,
* `response.output_item.added`, `response.output_text.delta`,
* `response.output_item.done`, and `response.completed` events.
*
* The request body uses `input` (array of items) instead of `messages`.
*
* @param {string} body
* @param {http.ServerResponse} res
*/
async function handleResponsesApi(body, res) {
if (_verbose) {
_log(`[mock-llm] /responses request body:`);
try {
_log(_indentVerbose(_formatVerbose(JSON.parse(body))));
} catch {
_log(_indentVerbose(_formatVerbose(body)));
}
}
let scenarioId = DEFAULT_SCENARIO;
let isScenarioRequest = false;
/** @type {string[]} */
let requestToolNames = [];
try {
const parsed = JSON.parse(body);
// Responses API uses `input` array and `tools` array
const input = parsed.input || [];
const tools = parsed.tools || [];
requestToolNames = tools.map((/** @type {any} */ t) => t.name).filter(Boolean);
// Search input items for scenario tags (input items have role + content)
for (let i = input.length - 1; i >= 0; i--) {
const item = input[i];
if (item.role !== 'user') { continue; }
const content = typeof item.content === 'string'
? item.content
: Array.isArray(item.content)
? item.content.map((/** @type {any} */ c) => c.text || '').join('')
: '';
const match = content.match(/\[scenario:([^\]]+)\]/);
if (match && SCENARIOS[match[1]]) {
scenarioId = match[1];
isScenarioRequest = true;
break;
}
}
const ts = new Date().toISOString().slice(11, -1);
_log(`[mock-llm] ${ts} → responses-api: ${input.length} input items, ${requestToolNames.length} tools, scenario=${scenarioId}`);
} catch { }
const scenario = SCENARIOS[scenarioId] || SCENARIOS[DEFAULT_SCENARIO];
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Request-Id': 'perf-benchmark-' + Date.now(),
});
// For multi-turn tool-call scenarios, convert to Responses API tool_use format
if (isMultiTurnScenario(scenario) && requestToolNames.length > 0) {
// For now, fall back to content-only for Responses API
// (tool calls would need response.output_item with type: 'function_call')
}
// Resolve content chunks
const chunks = isMultiTurnScenario(scenario)
? getFirstContentTurn(scenario)
: /** @type {StreamChunk[]} */ (scenario);
await streamResponsesContent(res, chunks, isScenarioRequest);
}
/**
* Stream content as Responses API SSE events.
* @param {http.ServerResponse} res
* @param {StreamChunk[]} chunks
* @param {boolean} isScenarioRequest
*/
async function streamResponsesContent(res, chunks, isScenarioRequest) {
const responseId = `resp_mock_${Date.now()}`;
const outputItemId = `msg_mock_${Date.now()}`;
const model = 'gpt-5.3-codex';
// 1. response.created
res.write(`data: ${JSON.stringify({
type: 'response.created',
response: {
id: responseId,
object: 'response',
created_at: Math.floor(Date.now() / 1000),
model,
status: 'in_progress',
output: [],
usage: null,
},
})}\n\n`);
// 2. response.output_item.added — add a message output item
res.write(`data: ${JSON.stringify({
type: 'response.output_item.added',
output_index: 0,
item: {
id: outputItemId,
type: 'message',
role: 'assistant',
status: 'in_progress',
content: [],
},
})}\n\n`);
// 3. response.content_part.added — add a text content part
res.write(`data: ${JSON.stringify({
type: 'response.content_part.added',
output_index: 0,
content_index: 0,
part: { type: 'output_text', text: '' },
})}\n\n`);
// 4. Stream text deltas
let fullText = '';
for (const chunk of chunks) {
if (chunk.delayMs > 0) { await sleep(chunk.delayMs); }
fullText += chunk.content;
res.write(`data: ${JSON.stringify({
type: 'response.output_text.delta',
output_index: 0,
content_index: 0,
delta: chunk.content,
})}\n\n`);
}
// 5. response.output_text.done
res.write(`data: ${JSON.stringify({
type: 'response.output_text.done',
output_index: 0,
content_index: 0,
text: fullText,
})}\n\n`);
// 6. response.content_part.done
res.write(`data: ${JSON.stringify({
type: 'response.content_part.done',
output_index: 0,
content_index: 0,
part: { type: 'output_text', text: fullText },
})}\n\n`);
// 7. response.output_item.done
res.write(`data: ${JSON.stringify({
type: 'response.output_item.done',
output_index: 0,
item: {
id: outputItemId,
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: fullText }],
},
})}\n\n`);
// 8. response.completed — the terminal event the SDK waits for
res.write(`data: ${JSON.stringify({
type: 'response.completed',
response: {
id: responseId,
object: 'response',
created_at: Math.floor(Date.now() / 1000),
model,
status: 'completed',
output: [
{
id: outputItemId,
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: fullText }],
},
],
usage: {
input_tokens: 100,
output_tokens: Math.max(1, Math.ceil(fullText.length / 4)),
total_tokens: 100 + Math.max(1, Math.ceil(fullText.length / 4)),
input_tokens_details: { cached_tokens: 0 },
output_tokens_details: { reasoning_tokens: 0 },
},
},
})}\n\n`);
res.end();
if (isScenarioRequest) {
serverEvents.emit('scenarioCompletion');
}
}
// ----- Anthropic Messages API -------------------------------------------------
/**
@@ -96,6 +96,9 @@ export function setup(logger: Logger) {
// sessions.chat.localAgent.enabled exposes the "Local" session type.
await app.workbench.settingsEditor.addUserSettings([
['github.copilot.advanced.debug.overrideProxyUrl', JSON.stringify(mockServer.url)],
// Use token auth (not HMAC) so the SDK can call /models and
// /models/session against the mock server without HMAC validation.
['github.copilot.advanced.debug.overrideAuthType', '"token"'],
['chat.allowAnonymousAccess', 'true'],
['github.copilot.chat.githubMcpServer.enabled', 'false'],
['sessions.chat.localAgent.enabled', 'true'],
@@ -133,7 +136,7 @@ export function setup(logger: Logger) {
);
});
it('Test Copilot CLI session (sandbox)', async function () {
it.skip('Test Copilot CLI session (sandbox)', async function () {
// Sandbox-backed shell tool currently only runs cleanly on macOS
// in CI. On Linux the bubblewrap policy fails to start bash inside
// the sandbox; on Windows AppContainer cold-start usually exceeds