converse-mcp-server
Version:
Converse MCP Server - Converse with other LLMs with chat and consensus tools
889 lines (794 loc) • 26.4 kB
JavaScript
/**
* Copilot SDK Provider
*
* Provider implementation for GitHub Copilot models using the @github/copilot-sdk.
* Implements the unified interface: async invoke(messages, options) => { content, stop_reason, rawResponse }
*
* Key differences from traditional providers:
* - Uses GitHub Copilot CLI subscription authentication - NOT API keys
* - Manages a singleton CopilotClient (spawns CLI process via JSON-RPC)
* - Creates a fresh CopilotSession per request, disconnected after each request
* - Bridges SDK push-based events to pull-based async generator for streaming
* - Requires GitHub CLI authenticated (gh auth login) with active Copilot subscription
*/
import { existsSync } from 'node:fs';
import { delimiter, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { debugLog, debugError } from '../utils/console.js';
import { ProviderError, ErrorCodes, StopReasons } from './interface.js';
const SUPPORTED_MODELS = {
copilot: {
modelName: 'copilot',
friendlyName: 'GitHub Copilot (via CLI SDK)',
contextWindow: 128000,
maxOutputTokens: 16384,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
timeout: 1800000,
description:
'GitHub Copilot via CLI SDK - uses default or env-configured model',
aliases: ['copilot-sdk', 'github-copilot'],
},
// OpenAI models
// Bare `gpt-5.6` (and the legacy `gpt-5` shortcut) route to Sol, matching
// Copilot's own bare-alias behavior. Terra is the recommended balanced tier.
// `codex` and `gpt` point at the latest GPT tier (reachable only via the
// `copilot:` namespace — bare `codex` routes to the Codex provider and bare
// `gpt*` keyword-routes to OpenAI before Copilot's catalog is consulted).
'gpt-5.6-sol': {
modelName: 'gpt-5.6-sol',
friendlyName: 'GPT-5.6 Sol (via Copilot)',
contextWindow: 1047576,
maxOutputTokens: 32768,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
supportsReasoningEffort: true,
timeout: 1800000,
description: 'OpenAI GPT-5.6 Sol via Copilot subscription',
aliases: ['gpt-5.6', 'gpt-5', 'gpt', 'codex'],
},
'gpt-5.6-terra': {
modelName: 'gpt-5.6-terra',
friendlyName: 'GPT-5.6 Terra (via Copilot)',
contextWindow: 1047576,
maxOutputTokens: 32768,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
supportsReasoningEffort: true,
timeout: 1800000,
description: 'OpenAI GPT-5.6 Terra via Copilot subscription',
aliases: [],
},
'gpt-5.6-luna': {
modelName: 'gpt-5.6-luna',
friendlyName: 'GPT-5.6 Luna (via Copilot)',
contextWindow: 1047576,
maxOutputTokens: 32768,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
supportsReasoningEffort: true,
timeout: 1800000,
description: 'OpenAI GPT-5.6 Luna via Copilot subscription',
aliases: [],
},
// Anthropic models
'claude-fable-5': {
modelName: 'claude-fable-5',
friendlyName: 'Claude Fable 5 (via Copilot)',
contextWindow: 200000,
maxOutputTokens: 32768,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
timeout: 1800000,
description: 'Anthropic Claude Fable 5 via Copilot subscription',
aliases: ['fable'],
},
'claude-sonnet-5': {
modelName: 'claude-sonnet-5',
friendlyName: 'Claude Sonnet 5 (via Copilot)',
contextWindow: 200000,
maxOutputTokens: 32768,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
timeout: 1800000,
description: 'Anthropic Claude Sonnet 5 via Copilot subscription',
aliases: ['sonnet'],
},
'claude-opus-5': {
modelName: 'claude-opus-5',
friendlyName: 'Claude Opus 5 (via Copilot)',
contextWindow: 200000,
maxOutputTokens: 32768,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
timeout: 1800000,
description: 'Anthropic Claude Opus 5 via Copilot subscription',
aliases: ['opus', 'claude'],
},
'claude-opus-4.8': {
modelName: 'claude-opus-4.8',
friendlyName: 'Claude Opus 4.8 (via Copilot)',
contextWindow: 200000,
maxOutputTokens: 32768,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
timeout: 1800000,
description: 'Anthropic Claude Opus 4.8 via Copilot subscription',
aliases: [],
},
// Google models
// GA `gemini-3.1-pro` does not exist upstream — keep the -preview suffix.
'gemini-3.1-pro-preview': {
modelName: 'gemini-3.1-pro-preview',
friendlyName: 'Gemini 3.1 Pro Preview (via Copilot)',
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
timeout: 1800000,
description: 'Google Gemini 3.1 Pro Preview via Copilot subscription',
aliases: ['gemini', 'gemini-3.1-pro'],
},
'gemini-3.5-flash': {
modelName: 'gemini-3.5-flash',
friendlyName: 'Gemini 3.5 Flash (via Copilot)',
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsStreaming: true,
supportsImages: false,
supportsWebSearch: false,
timeout: 1800000,
description: 'Google Gemini 3.5 Flash via Copilot subscription',
aliases: ['gemini-flash'],
},
};
class CopilotProviderError extends ProviderError {
constructor(message, code, originalError = null) {
super(message, code, originalError);
this.name = 'CopilotProviderError';
}
}
/**
* Check if Copilot SDK is available (installed as dependency)
*/
let _sdkAvailable = null;
function isCopilotSDKAvailable() {
if (_sdkAvailable !== null) return _sdkAvailable;
try {
// Use synchronous resolve to check if the package exists
import.meta.resolve('@github/copilot-sdk');
_sdkAvailable = true;
} catch {
_sdkAvailable = false;
}
return _sdkAvailable;
}
/**
* Dynamically import Copilot SDK (lazy loading)
*/
async function getCopilotSDK() {
try {
const { CopilotClient, RuntimeConnection } = await import(
'@github/copilot-sdk'
);
return { CopilotClient, RuntimeConnection };
} catch (error) {
throw new CopilotProviderError(
`Copilot SDK import failed: ${error.message}`,
ErrorCodes.API_ERROR,
error,
);
}
}
/**
* Locate a runnable `copilot` executable on PATH (e.g. a winget or global
* install). On Windows only real executables (.exe/.com) qualify — .cmd/.bat
* shims can't be spawned without a shell, and the SDK spawns the CLI without
* one, so handing it a shim would fail.
*/
function findCopilotBinaryOnPath() {
const pathDirs = (process.env.PATH || process.env.Path || '')
.split(delimiter)
.filter(Boolean);
const exts = process.platform === 'win32' ? ['.exe', '.com'] : [''];
for (const dir of pathDirs) {
for (const ext of exts) {
const candidate = join(dir, `copilot${ext}`);
if (existsSync(candidate)) return candidate;
}
}
return null;
}
/**
* Resolve the Copilot CLI runtime path deterministically.
*
* The SDK's built-in resolver (getBundledCliPath) reconstructs
* `<@github/copilot root>/index.js` from the package's `./sdk` export. That
* assumption breaks across layouts — pnpm stores with multiple versions, global
* installs, and loader-style `@github/copilot` variants that ship no root
* `index.js` — and there is no escape hatch wired up, so a wrong/missing
* bundled copy takes down the whole provider (the "Copilot CLI not found at
* ...@github\index.js" failure). We own discovery here and hand the SDK an
* explicit, verified path so that entire class of resolution failures can't
* occur.
*
* Precedence: explicit override → bundled npm CLI (index.js, run via node) →
* `copilot` on PATH. Returns null to let the SDK fall back to its own
* resolution as a last resort (preserves behavior on flat installs).
*/
function resolveCopilotCliPath(config) {
// 1. Explicit override (COPILOT_CLI_PATH via config or env)
const override =
config?.providers?.copilotclipath || process.env.COPILOT_CLI_PATH;
if (override && existsSync(override)) {
debugLog('[Copilot SDK] Using configured CLI path: %s', override);
return override;
}
// 2. Bundled npm CLI: resolve the @github/copilot/sdk export, walk to the
// package root, and use its index.js. A .js path is spawned via `node`, which
// also sidesteps Windows .cmd/.bat shim issues.
try {
const sdkEntry = fileURLToPath(import.meta.resolve('@github/copilot/sdk'));
const candidate = join(dirname(dirname(sdkEntry)), 'index.js');
if (existsSync(candidate)) {
debugLog('[Copilot SDK] Using bundled CLI: %s', candidate);
return candidate;
}
} catch {
// @github/copilot not resolvable from here — fall through.
}
// 3. Standalone `copilot` binary on PATH.
const binary = findCopilotBinaryOnPath();
if (binary) {
debugLog('[Copilot SDK] Using copilot binary from PATH: %s', binary);
return binary;
}
// 4. Nothing found — defer to the SDK's own resolution.
debugLog('[Copilot SDK] No CLI path resolved — deferring to SDK default');
return null;
}
// Module-level singleton client
let clientInstance = null;
let clientInitPromise = null;
/**
* Get or create the singleton CopilotClient
* The client manages the CLI process lifecycle via JSON-RPC
*/
async function getCopilotClient(cwd, config) {
if (clientInstance) {
return clientInstance;
}
if (clientInitPromise) {
return clientInitPromise;
}
clientInitPromise = (async () => {
const { CopilotClient, RuntimeConnection } = await getCopilotSDK();
const workingDirectory = cwd || process.cwd();
const clientOptions = {
useLoggedInUser: true,
workingDirectory,
};
const cliPath = resolveCopilotCliPath(config);
if (cliPath) {
clientOptions.connection = RuntimeConnection.forStdio({ path: cliPath });
}
clientInstance = new CopilotClient(clientOptions);
await clientInstance.start();
debugLog('[Copilot SDK] Client started (cwd: %s)', workingDirectory);
return clientInstance;
})();
try {
return await clientInitPromise;
} catch (error) {
clientInitPromise = null;
clientInstance = null;
throw error;
}
}
/**
* Convert message array to single prompt for Copilot
* Copilot expects single prompts, not message history
*/
function convertMessagesToPrompt(messages) {
if (!Array.isArray(messages)) {
throw new CopilotProviderError(
'Messages must be an array',
ErrorCodes.INVALID_MESSAGES,
);
}
if (messages.length === 0) {
throw new CopilotProviderError(
'Messages array cannot be empty',
ErrorCodes.INVALID_MESSAGES,
);
}
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
if (!lastUserMessage) {
throw new CopilotProviderError(
'No user message found in messages array',
ErrorCodes.INVALID_MESSAGES,
);
}
if (typeof lastUserMessage.content === 'string') {
return lastUserMessage.content;
}
if (Array.isArray(lastUserMessage.content)) {
const hasImages = lastUserMessage.content.some(
(item) => item.type === 'image',
);
if (hasImages) {
debugLog(
'[Copilot SDK] Warning: Images in message will be ignored (Copilot SDK does not support base64 images)',
);
}
const textParts = lastUserMessage.content
.filter((item) => item.type === 'text')
.map((item) => item.text);
return textParts.join('\n');
}
throw new CopilotProviderError(
'Invalid message content format',
ErrorCodes.INVALID_MESSAGES,
);
}
/**
* Get tool access level from config
* @param {object} config - Server configuration object
* @returns {'read-only' | 'full'}
*/
function getToolAccessLevel(config) {
const level = config?.providers?.copilottoolaccess || 'read-only';
return level === 'full' ? 'full' : 'read-only';
}
/**
* Create permission handler based on tool access level
*/
function createPermissionHandler(accessLevel) {
return (request) => {
if (accessLevel === 'full') {
return { kind: 'approved' };
}
// Read-only: allow read, deny everything else
if (request.kind === 'read') {
return { kind: 'approved' };
}
debugLog(
`[Copilot SDK] Permission denied for ${request.kind} (tool access: read-only)`,
);
return { kind: 'denied-by-rules' };
};
}
/**
* Resolve a friendly alias to its SDK model identifier (case-insensitive)
* Returns the resolved model name, or null if no alias matches
*/
function resolveModelAlias(name) {
if (typeof name !== 'string') return null;
const lower = name.toLowerCase().trim();
if (!lower) return null;
// Direct key match
if (SUPPORTED_MODELS[lower] && lower !== 'copilot') {
return SUPPORTED_MODELS[lower].modelName;
}
// Alias match
for (const config of Object.values(SUPPORTED_MODELS)) {
if (config.modelName === 'copilot') continue;
if (
config.aliases &&
config.aliases.some((alias) => alias.toLowerCase() === lower)
) {
return config.modelName;
}
}
return null;
}
/**
* Look up model config from SUPPORTED_MODELS by name or alias.
* Strips copilot: prefix and falls back to the base copilot config.
*/
function findModelConfig(modelName) {
if (typeof modelName !== 'string') return null;
let name = modelName;
if (name.toLowerCase().startsWith('copilot:')) {
name = name.slice('copilot:'.length).trim();
}
if (!name) return SUPPORTED_MODELS.copilot;
const nameLower = name.toLowerCase();
if (SUPPORTED_MODELS[nameLower]) {
return SUPPORTED_MODELS[nameLower];
}
for (const config of Object.values(SUPPORTED_MODELS)) {
if (
config.aliases &&
config.aliases.some((alias) => alias.toLowerCase() === nameLower)
) {
return config;
}
}
return null;
}
/**
* Resolve model to pass to SDK session
* Precedence: explicit model param > config COPILOT_MODEL > omit (SDK default)
*
* Handles copilot: prefix stripping, alias resolution, and env var fallback.
* Note: "copilot" is a Converse routing alias, not a valid SDK model ID.
*/
function resolveSessionModel(requestModel, config) {
const converseAliases = ['copilot', 'copilot-sdk', 'github-copilot'];
// Guard non-string inputs
if (typeof requestModel !== 'string') {
requestModel = '';
}
// Strip copilot: prefix (case-insensitive)
let effectiveModel = requestModel;
if (effectiveModel.toLowerCase().startsWith('copilot:')) {
effectiveModel = effectiveModel.slice('copilot:'.length).trim();
}
// Empty suffix or converse alias → use env/default
if (
!effectiveModel ||
converseAliases.includes(effectiveModel.toLowerCase())
) {
const envModel = config?.providers?.copilotmodel;
if (envModel) {
let resolved = typeof envModel === 'string' ? envModel : '';
if (resolved.toLowerCase().startsWith('copilot:')) {
resolved = resolved.slice('copilot:'.length).trim();
}
if (!resolved || converseAliases.includes(resolved.toLowerCase())) {
return undefined;
}
return resolveModelAlias(resolved) || resolved;
}
return undefined;
}
// Resolve alias or passthrough unknown models to SDK
return resolveModelAlias(effectiveModel) || effectiveModel;
}
/**
* Create streaming generator that bridges SDK push-based events to pull-based async generator
* Yields normalized events: start → delta(s) → usage? → end
*
* SDK Event Types (from session-events.d.ts):
* - assistant.message_delta → { data: { deltaContent } }
* - assistant.message → { data: { content } } (final complete message)
* - assistant.usage → { data: { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens } }
* - assistant.reasoning_delta → { data: { deltaContent } }
* - session.idle → processing complete
* - session.error → { data: { errorType, message } }
*/
/**
* Map tool-level reasoning_effort values to Copilot SDK's ReasoningEffort.
* Tool enum: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'max'
* SDK enum: 'low' | 'medium' | 'high' | 'xhigh'
*/
function mapReasoningEffort(effort) {
const mapping = {
none: 'low',
minimal: 'low',
low: 'low',
medium: 'medium',
high: 'high',
max: 'xhigh',
};
return mapping[effort] || undefined;
}
/**
* Check if a model supports reasoning effort via the Copilot SDK's models.list API.
* Results are cached by the SDK internally.
* Returns true if supported, false if not, or undefined if the check fails.
*/
async function checkReasoningSupport(client, modelId) {
try {
const models = await client.listModels();
const match = models.find((m) => m.id === modelId);
if (match) {
return match.capabilities?.supports?.reasoningEffort === true;
}
return undefined;
} catch (err) {
debugLog('[Copilot SDK] Failed to query model capabilities: %s', err.message);
return undefined;
}
}
async function* createStreamingGenerator(client, prompt, options, signal, config) {
const { model, timeout = 1800000, reasoning_effort } = options;
const sessionModel = resolveSessionModel(model, config);
const accessLevel = getToolAccessLevel(config);
const sessionConfig = {
streaming: true,
onPermissionRequest: createPermissionHandler(accessLevel),
};
if (sessionModel) {
sessionConfig.model = sessionModel;
}
if (reasoning_effort) {
const mapped = mapReasoningEffort(reasoning_effort);
if (mapped) {
const effectiveModel = sessionModel || model;
const modelDef = findModelConfig(effectiveModel);
let supported;
if (modelDef && modelDef.supportsReasoningEffort !== undefined) {
// Known model with explicit flag — use it directly (fast path)
supported = modelDef.supportsReasoningEffort;
} else {
// Unknown model or no static flag — query the SDK
supported = await checkReasoningSupport(client, effectiveModel);
}
if (supported === true) {
sessionConfig.reasoningEffort = mapped;
debugLog(`[Copilot SDK] Setting reasoningEffort: ${mapped} (from ${reasoning_effort})`);
} else if (supported === false) {
debugLog(`[Copilot SDK] Model "${effectiveModel}" does not support reasoningEffort (ignored)`);
} else {
// Could not determine — try optimistically, retry without on failure
sessionConfig.reasoningEffort = mapped;
debugLog(`[Copilot SDK] Model "${effectiveModel}" support unknown — trying reasoningEffort: ${mapped}`);
}
}
}
let session;
try {
session = await client.createSession(sessionConfig);
} catch (err) {
if (
sessionConfig.reasoningEffort &&
/does not support reasoning effort/i.test(err.message)
) {
debugLog('[Copilot SDK] Model rejected reasoningEffort — retrying without it');
delete sessionConfig.reasoningEffort;
session = await client.createSession(sessionConfig);
} else {
throw err;
}
}
try {
yield {
type: 'start',
provider: 'copilot',
model: sessionModel || 'copilot',
};
// Bridge push-based SDK events to pull-based generator using queue + promise
const eventQueue = [];
let waitResolve = null;
let done = false;
let streamError = null;
let usageData = null;
let receivedDeltas = false;
const unsubscribe = session.on((event) => {
switch (event.type) {
case 'assistant.message_delta':
receivedDeltas = true;
eventQueue.push({
type: 'delta',
data: { textDelta: event.data.deltaContent },
});
break;
case 'assistant.message':
// Final complete message — only use if no streaming deltas were received
if (!receivedDeltas && event.data.content) {
eventQueue.push({
type: 'delta',
data: { textDelta: event.data.content },
});
}
break;
case 'assistant.usage':
usageData = {
input_tokens: event.data.inputTokens || 0,
output_tokens: event.data.outputTokens || 0,
total_tokens:
(event.data.inputTokens || 0) + (event.data.outputTokens || 0),
cached_input_tokens: event.data.cacheReadTokens || 0,
};
break;
case 'session.idle':
done = true;
break;
case 'session.error':
streamError = new CopilotProviderError(
event.data.message || 'Session error',
ErrorCodes.API_ERROR,
);
done = true;
break;
}
// Wake up the generator if it's waiting
if (waitResolve) {
const resolve = waitResolve;
waitResolve = null;
resolve();
}
});
// Set up timeout
const timeoutId = setTimeout(() => {
streamError = new CopilotProviderError(
'Copilot SDK execution timeout',
ErrorCodes.TIMEOUT_ERROR,
);
done = true;
if (waitResolve) {
const resolve = waitResolve;
waitResolve = null;
resolve();
}
}, timeout);
try {
// Send the prompt — SDK returns a Promise; await to catch send errors
await session.send({ prompt });
// Pull events from queue until done
while (!done || eventQueue.length > 0) {
if (signal?.aborted) {
throw new CopilotProviderError('Request cancelled', 'CANCELLED');
}
if (eventQueue.length === 0 && !done) {
await new Promise((resolve) => {
waitResolve = resolve;
});
continue;
}
while (eventQueue.length > 0) {
yield eventQueue.shift();
}
}
if (streamError) {
throw streamError;
}
// Yield usage if available
if (usageData) {
yield { type: 'usage', usage: usageData };
}
yield {
type: 'end',
stop_reason: StopReasons.STOP,
finish_reason: 'stop',
};
} finally {
clearTimeout(timeoutId);
unsubscribe();
}
} finally {
try {
await session.disconnect();
} catch (disconnectError) {
debugError('[Copilot SDK] Session disconnect error', disconnectError);
}
}
}
export { resolveModelAlias, resolveSessionModel, resolveCopilotCliPath };
/**
* Copilot SDK Provider Implementation
*/
export const copilotProvider = {
async invoke(messages, options = {}) {
const {
model = 'copilot',
config,
stream = false,
signal,
reasoning_effort,
} = options;
if (!config) {
throw new CopilotProviderError(
'Configuration is required',
ErrorCodes.MISSING_API_KEY,
);
}
try {
const cwd = config.server?.client_cwd || process.cwd();
const client = await getCopilotClient(cwd, config);
const prompt = convertMessagesToPrompt(messages);
const sessionModel = resolveSessionModel(model, config);
const modelConfig = findModelConfig(sessionModel || model) || SUPPORTED_MODELS.copilot;
const invokeOptions = {
model,
timeout: modelConfig.timeout,
reasoning_effort,
};
if (stream) {
return createStreamingGenerator(client, prompt, invokeOptions, signal, config);
}
// Synchronous mode: consume streaming internally
const startTime = Date.now();
const generator = createStreamingGenerator(
client,
prompt,
invokeOptions,
signal,
config,
);
let content = '';
let usage = null;
for await (const event of generator) {
if (event.type === 'delta' && event.data?.textDelta) {
content += event.data.textDelta;
} else if (event.type === 'usage') {
usage = event.usage;
}
}
const responseTime = Date.now() - startTime;
return {
content,
stop_reason: StopReasons.STOP,
rawResponse: { content, usage },
metadata: {
provider: 'copilot',
model: sessionModel || 'copilot',
usage: usage
? {
input_tokens: usage.input_tokens || 0,
output_tokens: usage.output_tokens || 0,
total_tokens:
(usage.input_tokens || 0) + (usage.output_tokens || 0),
cached_input_tokens: usage.cached_input_tokens || 0,
}
: null,
response_time_ms: responseTime,
finish_reason: 'stop',
},
};
} catch (error) {
debugError('[Copilot SDK] Execution error', error);
if (
error.message?.includes('not authenticated') ||
error.message?.includes('authentication failed') ||
error.message?.includes('not logged in') ||
error.message?.includes('login required')
) {
throw new CopilotProviderError(
`Copilot SDK authentication failed. Ensure GitHub CLI is authenticated (gh auth login) and you have an active Copilot subscription. Original error: ${error.message}`,
ErrorCodes.INVALID_API_KEY,
error,
);
}
if (error.message?.includes('timeout')) {
throw new CopilotProviderError(
'Copilot SDK execution timeout',
ErrorCodes.TIMEOUT_ERROR,
error,
);
}
if (error.message?.includes('rate limit')) {
throw new CopilotProviderError(
'Rate limit exceeded',
ErrorCodes.RATE_LIMIT_EXCEEDED,
error,
);
}
if (error instanceof CopilotProviderError) {
throw error;
}
throw new CopilotProviderError(
error.message || 'Copilot SDK execution failed',
ErrorCodes.API_ERROR,
error,
);
}
},
/**
* Validate Copilot SDK configuration
* Returns true optimistically — auth errors surface at runtime
*/
validateConfig(_config) {
return isCopilotSDKAvailable();
},
isAvailable(config) {
return this.validateConfig(config);
},
getSupportedModels() {
return SUPPORTED_MODELS;
},
getModelConfig(modelName) {
return findModelConfig(modelName);
},
};