UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

889 lines (794 loc) 26.4 kB
/** * 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); }, };