agentHost: revert reserved integration id change; tag Codex proxy with vscode_codex UA (#322600)

* Revert "Merge pull request #321964 from microsoft/fix-agenthost-reserved-integration-id"

This reverts commit b82871d0b1, reversing
changes made to 18ab318cd2.

* agentHost: do not pass an integration id into CAPIClient

The 'code-oss' integration id is reserved and rejected by
@vscode/copilot-api's CAPIClient constructor. Stop passing it in and
drop the now-unnecessary getIntegrationId() test overrides; the
Copilot-Integration-Id header still defaults via the library and the
per-request suppressIntegrationId opt-in is unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agentHost: tag Codex proxy requests with a vscode_codex user-agent

Mirror the Claude proxy and oaiLanguageModelServer.ts by transforming the
inbound codex user-agent (replacing the client-name portion with the
vscode_codex prefix) and forwarding it to CAPI on /v1/responses requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agentHost: tag Codex agent model-list fetch with a vscode_codex user-agent

The Codex agent's _refreshModels() call to ICopilotApiService.models() did
not set a User-Agent, unlike the Claude agent (which sends
vscode_claude_code/<version>). Add a matching vscode_codex/<version> header
so the model-list fetch is identifiable server-side, consistent with the
Codex proxy's responses requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agentHost: simplify CopilotApiService test construction

Address PR review: the anonymous CopilotApiService subclasses no longer
override anything after the getIntegrationId() removal, so replace them with
direct construction for readability and cleaner stack traces.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Tyler James Leonhardt
2026-06-23 21:28:04 +00:00
committed by GitHub
co-authored by Copilot
parent 185495af39
commit cebc749d20
9 changed files with 246 additions and 16 deletions
+1
View File
@@ -26,6 +26,7 @@ declare module '@vscode/copilot-api' {
json?: unknown;
method?: 'GET' | 'POST' | 'PUT';
signal?: IAbortSignal;
suppressIntegrationId?: boolean;
}
export type MakeRequestOptions = Omit<FetchOptions, 'callSite'> & {
@@ -321,7 +321,7 @@ export class ClaudeAgent extends Disposable implements IAgent {
}
try {
const userAgent = `${USER_AGENT_PREFIX}/${this._productService.version}`;
const all = await this._copilotApiService.models(tokenAtStart, { headers: { 'User-Agent': userAgent } });
const all = await this._copilotApiService.models(tokenAtStart, { headers: { 'User-Agent': userAgent }, suppressIntegrationId: true });
// Stale-write guard: if `authenticate()` rotated the token
// while we were awaiting the model list, a newer refresh has
// already published the right value — don't overwrite it.
@@ -253,7 +253,7 @@ export class ClaudeProxyService extends LoopbackProxyServer<IClaudeProxyState, s
const headers = buildOutboundHeaders(req.headers);
let models: CCAModel[];
try {
models = await this._copilotApiService.models(runtime.state.githubToken, { headers });
models = await this._copilotApiService.models(runtime.state.githubToken, { headers, suppressIntegrationId: true });
} catch (err) {
this._writeUpstreamErrorResponse(res, err);
return;
@@ -391,7 +391,7 @@ export class ClaudeProxyService extends LoopbackProxyServer<IClaudeProxyState, s
originalSdkModelId: string,
sessionId: string | undefined,
): Promise<void> {
const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal };
const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true };
let message: Anthropic.Message;
try {
message = await this._copilotApiService.messages(runtime.state.githubToken, body, options);
@@ -427,7 +427,7 @@ export class ClaudeProxyService extends LoopbackProxyServer<IClaudeProxyState, s
_originalSdkModelId: string,
sessionId: string | undefined,
): Promise<void> {
const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal };
const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true };
let stream: AsyncGenerator<Anthropic.MessageStreamEvent>;
try {
stream = this._copilotApiService.messages(runtime.state.githubToken, body, options);
@@ -15,6 +15,7 @@ import { generateUuid } from '../../../../base/common/uuid.js';
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
import { localize } from '../../../../nls.js';
import { ILogService } from '../../../log/common/log.js';
import { IProductService } from '../../../product/common/productService.js';
import { createSchema, platformSessionSchema, schemaProperty, type SessionMode } from '../../common/agentHostSchema.js';
import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js';
import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IMcpNotification, type AgentProvider } from '../../common/agentService.js';
@@ -93,6 +94,14 @@ const CLIENT_INFO = {
const CODEX_THINKING_LEVEL_KEY = 'thinkingLevel';
/**
* User-agent prefix applied to the Codex agent's outbound CAPI calls (e.g. the
* model-list fetch) so the traffic is identifiable server-side. Mirrors
* `claudeAgent.ts` and the `vscode_codex` prefix used by `codexProxyService.ts`
* and `oaiLanguageModelServer.ts`.
*/
const USER_AGENT_PREFIX = 'vscode_codex';
const CODEX_REASONING_EFFORTS: readonly ReasoningEffort[] = ['minimal', 'low', 'medium', 'high'];
/**
@@ -544,6 +553,7 @@ export class CodexAgent extends Disposable implements IAgent {
@ICodexProxyService private readonly _codexProxyService: ICodexProxyService,
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
@IAgentSdkDownloader private readonly _agentSdkDownloader: IAgentSdkDownloader,
@IProductService private readonly _productService: IProductService,
@IInstantiationService instantiationService: IInstantiationService,
) {
super();
@@ -719,7 +729,8 @@ export class CodexAgent extends Disposable implements IAgent {
private async _refreshModels(token: string): Promise<void> {
try {
const all = await this._copilotApiService.models(token);
const userAgent = `${USER_AGENT_PREFIX}/${this._productService.version}`;
const all = await this._copilotApiService.models(token, { headers: { 'User-Agent': userAgent }, suppressIntegrationId: true });
if (this._githubToken !== token) {
return;
}
@@ -72,6 +72,14 @@ type ICodexProxyRuntime = ILoopbackProxyRuntime<ICodexProxyState>;
const PROXY_USER_FACING_NAME = 'CodexProxyService';
/**
* User-agent prefix applied to outbound CAPI requests so the codex proxy's
* traffic is identifiable server-side. Mirrors `oaiLanguageModelServer.ts`
* in the Copilot Chat extension, which tags Codex requests with the same
* prefix.
*/
const USER_AGENT_PREFIX = 'vscode_codex';
/**
* When set to an absolute directory path, every `/v1/responses` request body
* and its full upstream response stream are written to that directory as
@@ -274,9 +282,11 @@ export class CodexProxyService extends LoopbackProxyServer<ICodexProxyState, str
// whatever `runtime.state.githubToken` has been rotated to.
const dispatchedToken = runtime.state.githubToken;
const headers = buildOutboundHeaders(req.headers);
try {
this._logService.info(`[${PROXY_USER_FACING_NAME}] forwarding to CAPI responses...`);
const upstream = await this._copilotApiService.responses(dispatchedToken, body, { signal: entry.ac.signal });
const upstream = await this._copilotApiService.responses(dispatchedToken, body, { headers, signal: entry.ac.signal, suppressIntegrationId: true });
const contentType = upstream.headers.get('content-type') ?? 'application/json';
const upstreamHeaders = [...upstream.headers.entries()].map(([k, v]) => `${k}: ${v}`).join(', ');
this._logService.info(`[${PROXY_USER_FACING_NAME}] <<< CAPI response: status=${upstream.status}, contentType=${contentType}, headers=[${upstreamHeaders}]`);
@@ -346,3 +356,38 @@ export class CodexProxyService extends LoopbackProxyServer<ICodexProxyState, str
}
}
}
/**
* Build the headers forwarded to {@link ICopilotApiService.responses} from the
* inbound codex request. Currently forwards `user-agent` (transformed via
* {@link transformUserAgent}); the remaining outbound headers are supplied by
* the API service itself.
*/
function buildOutboundHeaders(inbound: http.IncomingHttpHeaders): Record<string, string> {
const out: Record<string, string> = {};
const userAgent = inbound['user-agent'];
if (typeof userAgent === 'string' && userAgent.length > 0) {
out['User-Agent'] = transformUserAgent(userAgent);
}
return out;
}
/**
* Transform an incoming user-agent string by replacing the client name portion
* (before the first `/`) with {@link USER_AGENT_PREFIX}. This mirrors the
* transform in `oaiLanguageModelServer.ts` in the Copilot Chat extension,
* ensuring all Codex requests are tagged with a consistent prefix for
* server-side identification.
*
* Examples:
* - `codex/1.2.3` → `vscode_codex/1.2.3`
* - `OpenAI/Python/1.0` → `vscode_codex/Python/1.0`
* - `unknown` → `vscode_codex/unknown`
*/
function transformUserAgent(userAgent: string): string {
const slashIndex = userAgent.indexOf('/');
if (slashIndex === -1) {
return `${USER_AGENT_PREFIX}/${userAgent}`;
}
return `${USER_AGENT_PREFIX}${userAgent.substring(slashIndex)}`;
}
@@ -28,6 +28,19 @@ import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgree
export interface ICopilotApiServiceRequestOptions {
readonly headers?: Readonly<Record<string, string>>;
readonly signal?: AbortSignal;
/**
* Suppress the `Copilot-Integration-Id` header on this request.
*
* When unset, `@vscode/copilot-api` derives the integration id from the
* discovered Copilot SKU: a `no_auth_limited_copilot` SKU maps to
* `vscode-nl`, which the CAPI backend treats as the limited/no-auth
* integration and refuses premium models such as `claude-opus-4.7`.
* Setting this to `true` omits the header so CAPI authorizes against the
* token's real entitlement. Mirrors the Copilot Chat extension's
* `ClaudeStreamingPassThroughEndpoint.getEndpointFetchOptions()`.
*/
readonly suppressIntegrationId?: boolean;
}
/**
@@ -522,6 +535,9 @@ export class CopilotApiService implements ICopilotApiService {
...options?.headers,
'Authorization': `Bearer ${githubToken}`,
},
// Opt-in per request — see
// `ICopilotApiServiceRequestOptions.suppressIntegrationId`.
suppressIntegrationId: options?.suppressIntegrationId,
signal: options?.signal,
},
{ type: RequestType.Models },
@@ -566,6 +582,9 @@ export class CopilotApiService implements ICopilotApiService {
'X-Request-Id': requestId,
'OpenAI-Intent': 'conversation',
},
// Opt-in per request — see
// `ICopilotApiServiceRequestOptions.suppressIntegrationId`.
suppressIntegrationId: options?.suppressIntegrationId,
body,
signal: options?.signal,
},
@@ -750,6 +769,7 @@ export class CopilotApiService implements ICopilotApiService {
// paths already omit it). Thread a real per-turn initiator here if
// that signal ever becomes available at the proxy boundary.
},
suppressIntegrationId: options?.suppressIntegrationId,
body,
signal: options?.signal,
},
@@ -0,0 +1,141 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import type * as http from 'http';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { NullLogService } from '../../../../log/common/log.js';
import {
type ICopilotApiService,
type ICopilotApiServiceRequestOptions,
} from '../../../node/shared/copilotApiService.js';
import { CodexProxyService } from '../../../node/codex/codexProxyService.js';
// #region Test fakes
interface IResponsesCall {
githubToken: string;
body: string;
options: ICopilotApiServiceRequestOptions | undefined;
}
class FakeCopilotApiService implements ICopilotApiService {
declare readonly _serviceBrand: undefined;
readonly responsesCalls: IResponsesCall[] = [];
messages(): never {
throw new Error('messages not used by Codex proxy tests');
}
async countTokens(): Promise<never> {
throw new Error('countTokens not used by Codex proxy tests');
}
async models(): Promise<never> {
throw new Error('models not used by Codex proxy tests');
}
async responses(githubToken: string, body: string, options?: ICopilotApiServiceRequestOptions): Promise<Response> {
this.responsesCalls.push({ githubToken, body, options });
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('event: response.completed\ndata: {}\n\n'));
controller.close();
},
});
return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } });
}
async utilityChatCompletion(): Promise<never> {
throw new Error('utilityChatCompletion not used by Codex proxy tests');
}
}
// #endregion
// #region HTTP helpers
let _httpModule: typeof http | undefined;
async function getHttp(): Promise<typeof http> {
if (!_httpModule) {
_httpModule = await import('http');
}
return _httpModule;
}
function postResponses(url: string, init: { headers?: Record<string, string>; body?: string }): Promise<{ status: number; body: string }> {
return getHttp().then(httpMod => new Promise((resolve, reject) => {
const u = new URL(url);
const req = httpMod.request({
hostname: u.hostname,
port: u.port,
path: u.pathname + u.search,
method: 'POST',
headers: init.headers,
}, res => {
const chunks: Buffer[] = [];
res.on('data', c => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
res.on('error', reject);
});
req.on('error', reject);
if (init.body !== undefined) {
req.write(init.body);
}
req.end();
}));
}
// #endregion
const TOKEN = 'gh-test-token';
suite('CodexProxyService', () => {
ensureNoDisposablesAreLeakedInTestSuite();
async function withProxy(fn: (handle: { baseUrl: string; nonce: string }, fake: FakeCopilotApiService) => Promise<void>): Promise<void> {
const fake = new FakeCopilotApiService();
const service = new CodexProxyService(new NullLogService(), fake);
const handle = await service.start(TOKEN);
try {
await fn(handle, fake);
} finally {
handle.dispose();
service.dispose();
}
}
test('forwards transformed user-agent to CAPI responses', async () => {
await withProxy(async (handle, fake) => {
await postResponses(`${handle.baseUrl}/v1/responses`, {
headers: { 'Authorization': `Bearer ${handle.nonce}`, 'User-Agent': 'codex/1.2.3' },
body: JSON.stringify({ model: 'gpt-5', stream: true, input: [] }),
});
assert.strictEqual(fake.responsesCalls.at(-1)?.options?.headers?.['User-Agent'], 'vscode_codex/1.2.3');
});
});
test('keeps the suffix when transforming a multi-segment user-agent', async () => {
await withProxy(async (handle, fake) => {
await postResponses(`${handle.baseUrl}/v1/responses`, {
headers: { 'Authorization': `Bearer ${handle.nonce}`, 'User-Agent': 'OpenAI/Python/1.0' },
body: JSON.stringify({ model: 'gpt-5', stream: true, input: [] }),
});
assert.strictEqual(fake.responsesCalls.at(-1)?.options?.headers?.['User-Agent'], 'vscode_codex/Python/1.0');
});
});
test('omits User-Agent when the inbound request has none', async () => {
await withProxy(async (handle, fake) => {
await postResponses(`${handle.baseUrl}/v1/responses`, {
headers: { 'Authorization': `Bearer ${handle.nonce}` },
body: JSON.stringify({ model: 'gpt-5', stream: true, input: [] }),
});
assert.strictEqual(fake.responsesCalls.at(-1)?.options?.headers?.['User-Agent'], undefined);
});
});
});
@@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes
import { CodexSessionConfigKey, collaborationModeKind, narrowAdditionalDirectories, narrowApprovalPolicy, narrowBoolean, narrowPersonality, narrowReasoningEffort, narrowReasoningSummary, narrowSandboxMode, narrowWebSearchMode } from '../../../node/codex/codexSessionConfigKeys.js';
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
import { IProductService } from '../../../../../platform/product/common/productService.js';
import { ISessionDataService } from '../../../common/sessionDataService.js';
import { CodexAgent } from '../../../node/codex/codexAgent.js';
import { ICodexProxyService } from '../../../node/codex/codexProxyService.js';
@@ -24,6 +25,7 @@ function createAgent(disposables: Pick<DisposableStore, 'add'>): CodexAgent {
instantiationService.stub(ICodexProxyService, { _serviceBrand: undefined });
instantiationService.stub(IAgentConfigurationService, { _serviceBrand: undefined });
instantiationService.stub(IAgentSdkDownloader, { _serviceBrand: undefined });
instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService);
instantiationService.stub(ILogService, new NullLogService());
return disposables.add(instantiationService.createInstance(CodexAgent));
}
@@ -587,18 +587,23 @@ suite('CopilotApiService', () => {
assert.strictEqual(headers['OpenAI-Intent'], 'messages-proxy');
});
test('sends a derived Copilot-Integration-Id header by default', async () => {
test('suppressIntegrationId opt-in controls the Copilot-Integration-Id header', async () => {
const { fetch: fetchFn, captured } = routingFetch(
() => anthropicResponse([{ type: 'text', text: 'ok' }]),
);
const service = createService(fetchFn);
// @vscode/copilot-api derives the integration id from the license /
// SKU / build state and sends it on every request.
// Default (no opt-in): @vscode/copilot-api derives and sends the header.
await service.messages('gh-tok', baseRequest);
const headers = captured().init?.headers as Record<string, string>;
const withHeader = captured().init?.headers as Record<string, string>;
assert.ok(headers['Copilot-Integration-Id'], 'integration id should be present');
// Opt-in: the header is omitted entirely so CAPI authorizes against
// the token's real entitlement instead of the derived integration id.
await service.messages('gh-tok', baseRequest, { suppressIntegrationId: true });
const suppressed = captured().init?.headers as Record<string, string>;
assert.ok(withHeader['Copilot-Integration-Id'], 'integration id should be present by default');
assert.strictEqual(suppressed['Copilot-Integration-Id'], undefined, 'integration id should be suppressed when opted in');
});
});
@@ -1585,16 +1590,21 @@ suite('CopilotApiService', () => {
assert.strictEqual(capturedHeaders?.['Authorization'], 'Bearer gh-tok');
});
test('sends a derived Copilot-Integration-Id header by default', async () => {
test('suppressIntegrationId opt-in controls the Copilot-Integration-Id header', async () => {
const { fetch: fetchFn, captured } = routingFetch(() => modelsResponse([]));
const service = createService(fetchFn);
// @vscode/copilot-api derives the integration id from the license /
// SKU / build state and sends it on every request.
// Default (no opt-in): @vscode/copilot-api derives and sends the header.
await service.models('gh-tok');
const headers = captured().init?.headers as Record<string, string>;
const withHeader = captured().init?.headers as Record<string, string>;
assert.ok(headers['Copilot-Integration-Id'], 'integration id should be present');
// Opt-in: the header is omitted entirely so CAPI authorizes against
// the token's real entitlement instead of the derived integration id.
await service.models('gh-tok', { suppressIntegrationId: true });
const suppressed = captured().init?.headers as Record<string, string>;
assert.ok(withHeader['Copilot-Integration-Id'], 'integration id should be present by default');
assert.strictEqual(suppressed['Copilot-Integration-Id'], undefined, 'integration id should be suppressed when opted in');
});
});