Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
501 changes: 7 additions & 494 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4569,7 +4569,7 @@
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.120",
"@anthropic-ai/sdk": "^0.68.0",
"@github/copilot": "^0.0.343",
"@github/copilot": "^0.0.354",
"@google/genai": "^1.22.0",
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
"@microsoft/tiktokenizer": "^1.0.10",
Expand Down
149 changes: 102 additions & 47 deletions src/extension/agents/copilotcli/node/copilotCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,66 +3,50 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { ModelProvider } from '@github/copilot/sdk';
import type { SessionOptions } from '@github/copilot/sdk';
import type { ChatSessionProviderOptionItem } from 'vscode';
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
import { IEnvService } from '../../../../platform/env/common/envService';
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
import { ILogService } from '../../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { createServiceIdentifier } from '../../../../util/common/services';
import { Lazy } from '../../../../util/vs/base/common/lazy';
import { Disposable, IDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle';
import { getCopilotLogger } from './logger';
import { ensureNodePtyShim } from './nodePtyShim';

const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel';
const DEFAULT_CLI_MODEL: ModelProvider = {
type: 'anthropic',
model: 'claude-sonnet-4.5'
};

/**
* Convert a model ID to a ModelProvider object for the Copilot CLI SDK
*/
export function getModelProvider(modelId: string): ModelProvider {
// Keep logic minimal; advanced mapping handled by resolveModelProvider in modelMapping.ts.
if (modelId.startsWith('claude-')) {
return {
type: 'anthropic',
model: modelId
};
} else if (modelId.startsWith('gpt-')) {
return {
type: 'openai',
model: modelId
};
}
return DEFAULT_CLI_MODEL;
}
const DEFAULT_CLI_MODEL = 'claude-sonnet-4';

export interface ICopilotCLIModels {
_serviceBrand: undefined;
toModelProvider(modelId: string): ModelProvider;
toModelProvider(modelId: string): string;
getDefaultModel(): Promise<ChatSessionProviderOptionItem>;
setDefaultModel(model: ChatSessionProviderOptionItem): Promise<void>;
getAvailableModels(): Promise<ChatSessionProviderOptionItem[]>;
}

export const ICopilotCLISDK = createServiceIdentifier<ICopilotCLISDK>('ICopilotCLISDK');

export const ICopilotCLIModels = createServiceIdentifier<ICopilotCLIModels>('ICopilotCLIModels');

export class CopilotCLIModels implements ICopilotCLIModels {
declare _serviceBrand: undefined;
private readonly _availableModels: Lazy<Promise<ChatSessionProviderOptionItem[]>>;
constructor(
@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
) {
this._availableModels = new Lazy<Promise<ChatSessionProviderOptionItem[]>>(() => this._getAvailableModels());
}
public toModelProvider(modelId: string) {
// TODO: replace with SDK-backed lookup once dynamic model list available.
return getModelProvider(modelId);
return modelId;
}
public async getDefaultModel() {
// We control this
const models = await this.getAvailableModels();
const defaultModel = models.find(m => m.id.toLowerCase().includes(DEFAULT_CLI_MODEL.model.toLowerCase())) ?? models[0];
const defaultModel = models.find(m => m.id.toLowerCase() === DEFAULT_CLI_MODEL.toLowerCase()) ?? models[0];
const preferredModelId = this.extensionContext.globalState.get<string>(COPILOT_CLI_MODEL_MEMENTO_KEY, defaultModel.id);

return models.find(m => m.id === preferredModelId) ?? defaultModel;
Expand All @@ -78,22 +62,12 @@ export class CopilotCLIModels implements ICopilotCLIModels {
}

private async _getAvailableModels(): Promise<ChatSessionProviderOptionItem[]> {
return [{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5'
},
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4'
},
{
id: 'claude-haiku-4.5',
name: 'Claude Haiku 4.5'
},
{
id: 'gpt-5',
name: 'GPT-5'
}];
const { getAvailableModels } = await this.copilotCLISDK.getPackage();
const models = await getAvailableModels();
return models.map(model => ({
id: model.model,
name: model.label
} satisfies ChatSessionProviderOptionItem));
}
}

Expand All @@ -106,8 +80,6 @@ export interface ICopilotCLISDK {
getPackage(): Promise<typeof import('@github/copilot/sdk')>;
}

export const ICopilotCLISDK = createServiceIdentifier<ICopilotCLISDK>('ICopilotCLISDK');

export class CopilotCLISDK implements ICopilotCLISDK {
declare _serviceBrand: undefined;

Expand All @@ -120,11 +92,94 @@ export class CopilotCLISDK implements ICopilotCLISDK {
public async getPackage(): Promise<typeof import('@github/copilot/sdk')> {
try {
// Ensure the node-pty shim exists before importing the SDK (required for CLI sessions)
await ensureNodePtyShim(this.extensionContext.extensionPath, this.envService.appRoot);
await ensureNodePtyShim(this.extensionContext.extensionPath, this.envService.appRoot, this.logService);
return await import('@github/copilot/sdk');
} catch (error) {
this.logService.error(`[CopilotCLISDK] Failed to load @github/copilot/sdk: ${error}`);
throw error;
}
}
}

export interface ICopilotCLISessionOptionsService {
readonly _serviceBrand: undefined;
createOptions(
options: SessionOptions,
permissionHandler: CopilotCLIPermissionsHandler
): Promise<SessionOptions>;
}
export const ICopilotCLISessionOptionsService = createServiceIdentifier<ICopilotCLISessionOptionsService>('ICopilotCLISessionOptionsService');

export class CopilotCLISessionOptionsService implements ICopilotCLISessionOptionsService {
declare _serviceBrand: undefined;
constructor(
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
@ILogService private readonly logService: ILogService,
) { }

public async createOptions(options: SessionOptions, permissionHandler: CopilotCLIPermissionsHandler) {
const copilotToken = await this._authenticationService.getAnyGitHubSession();
const workingDirectory = options.workingDirectory ?? await this.getWorkspaceFolderPath();
const allOptions: SessionOptions = {
env: {
...process.env,
COPILOTCLI_DISABLE_NONESSENTIAL_TRAFFIC: '1'
},
logger: getCopilotLogger(this.logService),
requestPermission: async (permissionRequest) => {
return await permissionHandler.getPermissions(permissionRequest);
},
authInfo: {
type: 'token',
token: copilotToken?.accessToken ?? '',
host: 'https://github.com'
},
...options,
};

if (workingDirectory) {
allOptions.workingDirectory = workingDirectory;
}
return allOptions;
}
private async getWorkspaceFolderPath() {
if (this.workspaceService.getWorkspaceFolders().length === 0) {
return undefined;
}
if (this.workspaceService.getWorkspaceFolders().length === 1) {
return this.workspaceService.getWorkspaceFolders()[0].fsPath;
}
const folder = await this.workspaceService.showWorkspaceFolderPicker();
return folder?.uri?.fsPath;
}
}


/**
* Perhaps temporary interface to handle permission requests from the Copilot CLI SDK
* Perhaps because the SDK needs a better way to handle this in long term per session.
*/
export interface ICopilotCLIPermissions {
onDidRequestPermissions(handler: SessionOptions['requestPermission']): IDisposable;
}

export class CopilotCLIPermissionsHandler extends Disposable implements ICopilotCLIPermissions {
private _handler: SessionOptions['requestPermission'] | undefined;

public onDidRequestPermissions(handler: SessionOptions['requestPermission']): IDisposable {
this._handler = handler;
return this._register(toDisposable(() => {
this._handler = undefined;
}));
}

public async getPermissions(permission: Parameters<NonNullable<SessionOptions['requestPermission']>>[0]): Promise<ReturnType<NonNullable<SessionOptions['requestPermission']>>> {
if (!this._handler) {
return {
kind: "denied-interactively-by-user"
};
}
return await this._handler(permission);
}
}
Loading