From 1a6671e8cbc18ed08e29f57c698ff8170f3eec73 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Mon, 8 Jun 2026 11:16:35 +0200 Subject: [PATCH] feat(ai-copilot): add opt-in Copilot SDK transport Add an experimental, opt-in transport for `@theia/ai-copilot` that routes model discovery and chat through the official Copilot CLI via `@github/copilot-sdk`, instead of the direct REST API. The direct REST path sends the raw `gho_` OAuth token without the `Copilot-Integration-Id`/`Editor-Version` headers GitHub uses to gate model access, so only a baseline model set (e.g. GPT-4o) is exposed. The SDK/CLI is a recognized integration and surfaces the full current model lineup. Scope (gated behind the `ai-features.copilot.useSdk` preference, default off): - model discovery via `CopilotClient.listModels()` - single-turn streaming chat with all tools disabled and permission requests rejected (the CLI owns its own agent loop, incompatible with Theia's model-as-a-function + Theia-driven tool loop) New (node): - `copilot-sdk-mappers.ts` (+ spec): pure, testable model-id selection and message->prompt flattening (type-only SDK import) - `copilot-sdk-client-provider.ts`: token-keyed CLI client lifecycle - `copilot-sdk-language-model.ts`: streaming session -> Theia stream bridge, terminating on `session.idle`/`session.error`/cancellation Wiring: `useSdk` preference, manager branches discovery + model class on the flag, DI binding, frontend rebuilds the model set on toggle. README documents the mode and its known limitations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 553 +++++++----------- packages/ai-copilot/README.md | 35 ++ packages/ai-copilot/package.json | 1 + ...pilot-frontend-application-contribution.ts | 8 +- .../common/copilot-language-models-manager.ts | 5 + .../src/common/copilot-preferences.ts | 13 + .../src/node/copilot-backend-module.ts | 3 + .../copilot-language-models-manager-impl.ts | 90 ++- .../src/node/copilot-sdk-client-provider.ts | 145 +++++ .../src/node/copilot-sdk-language-model.ts | 171 ++++++ .../src/node/copilot-sdk-mappers.spec.ts | 113 ++++ .../src/node/copilot-sdk-mappers.ts | 132 +++++ 12 files changed, 888 insertions(+), 381 deletions(-) create mode 100644 packages/ai-copilot/src/node/copilot-sdk-client-provider.ts create mode 100644 packages/ai-copilot/src/node/copilot-sdk-language-model.ts create mode 100644 packages/ai-copilot/src/node/copilot-sdk-mappers.spec.ts create mode 100644 packages/ai-copilot/src/node/copilot-sdk-mappers.ts diff --git a/package-lock.json b/package-lock.json index 0f7871bf54b94..a84cfa4d6133c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3284,6 +3284,200 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, + "node_modules/@github/copilot": { + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.60.tgz", + "integrity": "sha512-+GjW+GJNo55nwJwt48o9szWcyhuY0u682cBKQI1ay9jVBX8DCCXC6HB6Tyv5/MaM4N7CxTiEgp48aVMkye8K+g==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.60", + "@github/copilot-darwin-x64": "1.0.60", + "@github/copilot-linux-arm64": "1.0.60", + "@github/copilot-linux-x64": "1.0.60", + "@github/copilot-linuxmusl-arm64": "1.0.60", + "@github/copilot-linuxmusl-x64": "1.0.60", + "@github/copilot-win32-arm64": "1.0.60", + "@github/copilot-win32-x64": "1.0.60" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.60.tgz", + "integrity": "sha512-TErNaVxsv+uB3bdHwdoKorCd1rhiRh7HkX48vnS7jwqa8EtGgAkzNrHKC7mruL2rnYOOsNIdPfhzQk+2Y6PSxQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.60.tgz", + "integrity": "sha512-PthhcR6PqbQlT04xQKTElpPSJOrJd65nK/l9Sjmpwtk21RrDKs13DCY/19ubP17updYUWBxp3VNfyfN3DAQKOA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.60.tgz", + "integrity": "sha512-AVahkDVQTiGmHvDjlb4CHO8CFEGqmCEipxi0qTA60oH3Y3W2C4aYBwEBtP/85pN3wUUKZJVrWTCcxdufUBuK2Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.60.tgz", + "integrity": "sha512-NwQjV2ZyUdJVAO4t7wiT+eR3uNWYP57xaLUIhf6JTMGpsTyN+mAFXW63xpwM/K+Pug62uRDQDBjEeOQRB7qZrA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.60.tgz", + "integrity": "sha512-AYGPc9vq2k248bVwUbiVJ65kIYYMQQ7ci+S3oefWBIyYtYwAH0n+Q/IGAj49IPrelBarYABAsX+EQZJJC8rhxw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.60.tgz", + "integrity": "sha512-9/F7yl0/9FpGvYR/TCQtbhu0vIaUVem6U7em85QYaEjkS45nK500pByCMWY0bXv2eSS8U2g+8FOAjfkyLlxwPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0.tgz", + "integrity": "sha512-OKjmJMDM+GB2uHr8UA6O0FNs1Gfw/tkoE5vUNlYmKbydc9Yjf6pvuBdseGjAVvzc6f9HIbB5eZKLUrxbOTw+yA==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.57", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-sdk/node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@github/copilot-sdk/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.60.tgz", + "integrity": "sha512-ZxxS+Ua1+7Puz80yTOpQ4WS+s32NjrxIsqo8gE0FpuZId16BGOGbWkzWQvR/k2AVBCqpLZ7SK3LfDVKuKJRbpA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.60.tgz", + "integrity": "sha512-e91ZlFz9J1lkadExLg36oN8Ms/xIa03vAEir3DmyCeYebZ+Y48vdS+BwhQEma+GLoxJUOhzHndCckGnMRfNIbA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, "node_modules/@google/genai": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.51.0.tgz", @@ -7908,7 +8102,6 @@ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@types/node": "*" } @@ -24294,180 +24487,6 @@ } } }, - "node_modules/puppeteer-core/node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer-core/node_modules/data-uri-to-buffer": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz", - "integrity": "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer-core/node_modules/degenerator": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-7.0.1.tgz", - "integrity": "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "quickjs-wasi": "^2.2.0" - } - }, - "node_modules/puppeteer-core/node_modules/get-uri": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.0.tgz", - "integrity": "sha512-CqtZlMKvfJeY0Zxv8wazDwXmSKmnMnsmNy8j8+wudi8EyG/pMUB1NqHc+Tv1QaNtpYsK9nOYjb7r7Ufu32RPSw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "basic-ftp": "^5.2.0", - "data-uri-to-buffer": "8.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer-core/node_modules/http-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", - "integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer-core/node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer-core/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/puppeteer-core/node_modules/pac-proxy-agent": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-9.0.1.tgz", - "integrity": "sha512-3ZOSpLboOlpW4yp8Cuv21KlTULRqyJ5Uuad3wXpSKFrxdNgcHEyoa22GRaZ2UlgCVuR6z+5BiavtYVvbajL/Yw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "get-uri": "8.0.0", - "http-proxy-agent": "9.0.0", - "https-proxy-agent": "9.0.0", - "pac-resolver": "9.0.1", - "quickjs-wasi": "^2.2.0", - "socks-proxy-agent": "10.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer-core/node_modules/pac-resolver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-9.0.1.tgz", - "integrity": "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "degenerator": "7.0.1", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "quickjs-wasi": "^2.2.0" - } - }, - "node_modules/puppeteer-core/node_modules/proxy-agent": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.1.tgz", - "integrity": "sha512-kccqGBqHZXR8onQhY/ganJjoO8QIKKRiFBhPOzbTZK16attzSZ/0XSmp9H7jrRxPKHjhGyx1q32lMPrJ3uLFgA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "http-proxy-agent": "9.0.0", - "https-proxy-agent": "9.0.0", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "9.0.1", - "proxy-from-env": "^2.0.0", - "socks-proxy-agent": "10.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer-core/node_modules/socks-proxy-agent": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz", - "integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/puppeteer-core/node_modules/webdriver-bidi-protocol": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", @@ -24647,180 +24666,6 @@ } } }, - "node_modules/puppeteer/node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer/node_modules/data-uri-to-buffer": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz", - "integrity": "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer/node_modules/degenerator": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-7.0.1.tgz", - "integrity": "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "quickjs-wasi": "^2.2.0" - } - }, - "node_modules/puppeteer/node_modules/get-uri": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.0.tgz", - "integrity": "sha512-CqtZlMKvfJeY0Zxv8wazDwXmSKmnMnsmNy8j8+wudi8EyG/pMUB1NqHc+Tv1QaNtpYsK9nOYjb7r7Ufu32RPSw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "basic-ftp": "^5.2.0", - "data-uri-to-buffer": "8.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer/node_modules/http-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", - "integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer/node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/puppeteer/node_modules/pac-proxy-agent": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-9.0.1.tgz", - "integrity": "sha512-3ZOSpLboOlpW4yp8Cuv21KlTULRqyJ5Uuad3wXpSKFrxdNgcHEyoa22GRaZ2UlgCVuR6z+5BiavtYVvbajL/Yw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "get-uri": "8.0.0", - "http-proxy-agent": "9.0.0", - "https-proxy-agent": "9.0.0", - "pac-resolver": "9.0.1", - "quickjs-wasi": "^2.2.0", - "socks-proxy-agent": "10.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer/node_modules/pac-resolver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-9.0.1.tgz", - "integrity": "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "degenerator": "7.0.1", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "quickjs-wasi": "^2.2.0" - } - }, - "node_modules/puppeteer/node_modules/proxy-agent": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.1.tgz", - "integrity": "sha512-kccqGBqHZXR8onQhY/ganJjoO8QIKKRiFBhPOzbTZK16attzSZ/0XSmp9H7jrRxPKHjhGyx1q32lMPrJ3uLFgA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "http-proxy-agent": "9.0.0", - "https-proxy-agent": "9.0.0", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "9.0.1", - "proxy-from-env": "^2.0.0", - "socks-proxy-agent": "10.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/puppeteer/node_modules/socks-proxy-agent": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz", - "integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -24889,14 +24734,6 @@ "node": ">=8" } }, - "node_modules/quickjs-wasi": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", - "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -30976,6 +30813,7 @@ "version": "1.72.0", "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", "dependencies": { + "@github/copilot-sdk": "^1.0.0", "@theia/ai-core": "1.72.0", "@theia/ai-openai": "1.72.0", "@theia/core": "1.72.0", @@ -31118,6 +30956,7 @@ "@theia/core": "1.72.0", "@theia/debug": "1.72.0", "@theia/editor": "1.72.0", + "@theia/file-search": "1.72.0", "@theia/filesystem": "1.72.0", "@theia/markers": "1.72.0", "@theia/monaco": "1.72.0", diff --git a/packages/ai-copilot/README.md b/packages/ai-copilot/README.md index c6efc53c3cb45..9a920b419a21b 100644 --- a/packages/ai-copilot/README.md +++ b/packages/ai-copilot/README.md @@ -51,6 +51,41 @@ For GitHub Enterprise users, configure the enterprise URL via the `ai-features.c } ``` +### Experimental: Copilot SDK transport (`ai-features.copilot.useSdk`) + +By default the extension talks directly to the GitHub Copilot REST API. As an +**opt-in experiment**, it can instead delegate to the official Copilot CLI via +the [`@github/copilot-sdk`](https://github.com/github/copilot-sdk) package by +enabling the `ai-features.copilot.useSdk` preference: + +```json +{ + "ai-features.copilot.useSdk": true +} +``` + +Because the SDK is a *recognized* GitHub integration, this mode reports the full +current model lineup (instead of only the baseline set such as GPT-4o that the +raw REST integration is entitled to). Toggling the preference rebuilds the +Copilot model set, so no restart is required. + +This mode is a prototype and has known limitations: + +- **Backend / Node only.** The SDK spawns the bundled `@github/copilot` CLI as a + subprocess over stdio, so it runs in the Theia backend. +- **Single-turn, tools disabled.** The CLI owns its own agent loop, which is + incompatible with Theia's model-as-a-function contract where Theia drives the + tool loop. To keep behaviour predictable the session is created with no tools + and all permission requests are rejected, so requests are effectively + single-turn chat completions. +- **Lossy history.** Multi-message histories (including tool-use/tool-result + turns) are flattened into a single prompt string; thinking messages are + dropped. +- **No structured output.** Structured/JSON output is not supported in this mode. +- **One CLI process per connection.** Each frontend connection currently spawns + its own CLI process; this is acceptable for prototyping but would need a + shared, per-user client for multi-user backends. + ### Commands - **Copilot: Sign In** - Initiates the OAuth device flow authentication diff --git a/packages/ai-copilot/package.json b/packages/ai-copilot/package.json index 2b4d9d5b10349..5d5837e680394 100644 --- a/packages/ai-copilot/package.json +++ b/packages/ai-copilot/package.json @@ -3,6 +3,7 @@ "version": "1.72.0", "description": "Theia - GitHub Copilot Integration", "dependencies": { + "@github/copilot-sdk": "^1.0.0", "@theia/ai-core": "1.72.0", "@theia/ai-openai": "1.72.0", "@theia/core": "1.72.0", diff --git a/packages/ai-copilot/src/browser/copilot-frontend-application-contribution.ts b/packages/ai-copilot/src/browser/copilot-frontend-application-contribution.ts index e06b7e97e2d50..81e82a251de66 100644 --- a/packages/ai-copilot/src/browser/copilot-frontend-application-contribution.ts +++ b/packages/ai-copilot/src/browser/copilot-frontend-application-contribution.ts @@ -19,7 +19,7 @@ import { CommandService, nls, PreferenceService } from '@theia/core'; import { FrontendApplicationContribution } from '@theia/core/lib/browser'; import { MessageService } from '@theia/core/lib/common/message-service'; import { CopilotAuthService, CopilotLanguageModelsManager, CopilotModelDescription, COPILOT_PROVIDER_ID } from '../common'; -import { COPILOT_ENABLED_PREF, COPILOT_MODEL_OVERRIDES_PREF, COPILOT_ENTERPRISE_URL_PREF } from '../common/copilot-preferences'; +import { COPILOT_ENABLED_PREF, COPILOT_MODEL_OVERRIDES_PREF, COPILOT_ENTERPRISE_URL_PREF, COPILOT_USE_SDK_PREF } from '../common/copilot-preferences'; import { AICorePreferences, PREFERENCE_NAME_MAX_RETRIES } from '@theia/ai-core/lib/common/ai-core-preferences'; import { CopilotCommands } from './copilot-command-contribution'; @@ -51,6 +51,7 @@ export class CopilotFrontendApplicationContribution implements FrontendApplicati this.preferenceService.ready.then(async () => { const enterpriseUrl = this.preferenceService.get(COPILOT_ENTERPRISE_URL_PREF); this.manager.setEnterpriseUrl(enterpriseUrl || undefined); + this.manager.setUseSdk(this.preferenceService.get(COPILOT_USE_SDK_PREF, false)); if (this.isCopilotEnabled()) { const authState = await this.authService.getAuthState(); @@ -86,6 +87,11 @@ export class CopilotFrontendApplicationContribution implements FrontendApplicati } else { this.manager.refreshModelsStatus(); } + } else if (event.preferenceName === COPILOT_USE_SDK_PREF && this.isCopilotEnabled()) { + // Switching transport changes the model implementation, so rebuild the model set. + this.manager.setUseSdk(this.preferenceService.get(COPILOT_USE_SDK_PREF, false)); + this.removeAllCopilotModels(); + this.initializeModels(); } }); diff --git a/packages/ai-copilot/src/common/copilot-language-models-manager.ts b/packages/ai-copilot/src/common/copilot-language-models-manager.ts index 73185dd4e35ac..ae5146b59b04e 100644 --- a/packages/ai-copilot/src/common/copilot-language-models-manager.ts +++ b/packages/ai-copilot/src/common/copilot-language-models-manager.ts @@ -68,6 +68,11 @@ export interface CopilotLanguageModelsManager { * Set the GitHub Enterprise URL for Copilot API requests. */ setEnterpriseUrl(url: string | undefined): void; + /** + * Enable or disable routing through the official Copilot CLI via `@github/copilot-sdk` + * instead of the direct REST API. + */ + setUseSdk(useSdk: boolean): void; /** * Refresh the status of all Copilot models (e.g., after authentication state changes). */ diff --git a/packages/ai-copilot/src/common/copilot-preferences.ts b/packages/ai-copilot/src/common/copilot-preferences.ts index 1c9214e1e8509..aa3b9c829184d 100644 --- a/packages/ai-copilot/src/common/copilot-preferences.ts +++ b/packages/ai-copilot/src/common/copilot-preferences.ts @@ -20,6 +20,7 @@ import { nls, PreferenceSchema } from '@theia/core'; export const COPILOT_ENABLED_PREF = 'ai-features.copilot.enabled'; export const COPILOT_MODEL_OVERRIDES_PREF = 'ai-features.copilot.modelOverrides'; export const COPILOT_ENTERPRISE_URL_PREF = 'ai-features.copilot.enterpriseUrl'; +export const COPILOT_USE_SDK_PREF = 'ai-features.copilot.useSdk'; export const CopilotPreferencesSchema: PreferenceSchema = { properties: { @@ -49,6 +50,18 @@ export const CopilotPreferencesSchema: PreferenceSchema = { 'GitHub Enterprise domain for Copilot API (e.g., `github.mycompany.com`). Leave empty for GitHub.com.'), title: AI_CORE_PREFERENCES_TITLE, default: '' + }, + [COPILOT_USE_SDK_PREF]: { + type: 'boolean', + markdownDescription: nls.localize('theia/ai/copilot/useSdk/mdDescription', + 'Experimental: route GitHub Copilot through the official Copilot CLI via the `@github/copilot-sdk` instead of ' + + 'the direct REST API. This makes the full, up-to-date model lineup available (the direct REST integration only ' + + 'exposes a baseline set such as GPT-4o). The Copilot CLI is launched as a background process and authenticated ' + + 'with your existing Copilot sign-in.\n\n' + + '**Known prototype limitations:** chat is handled as single-turn requests, tool calling is disabled, and ' + + 'structured output is not supported on this path. Leave disabled to use the direct REST integration.'), + title: AI_CORE_PREFERENCES_TITLE, + default: false } } }; diff --git a/packages/ai-copilot/src/node/copilot-backend-module.ts b/packages/ai-copilot/src/node/copilot-backend-module.ts index b65017621fa8c..8a7c43fbc8dce 100644 --- a/packages/ai-copilot/src/node/copilot-backend-module.ts +++ b/packages/ai-copilot/src/node/copilot-backend-module.ts @@ -26,12 +26,15 @@ import { } from '../common'; import { CopilotOAuthConfig, DEFAULT_COPILOT_OAUTH_CONFIG } from '../common/copilot-oauth-config'; import { CopilotLanguageModelsManagerImpl } from './copilot-language-models-manager-impl'; +import { CopilotSdkClientProvider } from './copilot-sdk-client-provider'; import { CopilotAuthServiceImpl } from './copilot-auth-service-impl'; const copilotConnectionModule = ConnectionContainerModule.create(({ bind }) => { bind(CopilotAuthServiceImpl).toSelf().inSingletonScope(); bind(CopilotAuthService).toService(CopilotAuthServiceImpl); + bind(CopilotSdkClientProvider).toSelf().inSingletonScope(); + bind(CopilotLanguageModelsManagerImpl).toSelf().inSingletonScope(); bind(CopilotLanguageModelsManager).toService(CopilotLanguageModelsManagerImpl); diff --git a/packages/ai-copilot/src/node/copilot-language-models-manager-impl.ts b/packages/ai-copilot/src/node/copilot-language-models-manager-impl.ts index 5a21e3354a0bc..c322db29c2642 100644 --- a/packages/ai-copilot/src/node/copilot-language-models-manager-impl.ts +++ b/packages/ai-copilot/src/node/copilot-language-models-manager-impl.ts @@ -14,12 +14,14 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { LanguageModelRegistry, LanguageModelStatus } from '@theia/ai-core'; +import { LanguageModel, LanguageModelRegistry, LanguageModelStatus } from '@theia/ai-core'; import { Disposable, DisposableCollection } from '@theia/core'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { CopilotLanguageModelsManager, CopilotModelDescription, COPILOT_PROVIDER_ID, getCopilotApiBaseUrl } from '../common'; import { CopilotOAuthConfig } from '../common/copilot-oauth-config'; import { CopilotLanguageModel } from './copilot-language-model'; +import { CopilotSdkLanguageModel } from './copilot-sdk-language-model'; +import { CopilotSdkClientProvider } from './copilot-sdk-client-provider'; import { CopilotAuthServiceImpl } from './copilot-auth-service-impl'; /** @@ -38,7 +40,11 @@ export class CopilotLanguageModelsManagerImpl implements CopilotLanguageModelsMa @inject(CopilotOAuthConfig) protected readonly oauthConfig: CopilotOAuthConfig; + @inject(CopilotSdkClientProvider) + protected readonly sdkClientProvider: CopilotSdkClientProvider; + protected enterpriseUrl: string | undefined; + protected useSdk = false; protected readonly toDispose = new DisposableCollection(); @postConstruct() @@ -56,6 +62,17 @@ export class CopilotLanguageModelsManagerImpl implements CopilotLanguageModelsMa this.enterpriseUrl = url; } + setUseSdk(useSdk: boolean): void { + if (this.useSdk === useSdk) { + return; + } + this.useSdk = useSdk; + // Release any running CLI process when leaving SDK mode. + if (!useSdk) { + this.sdkClientProvider.reset(); + } + } + protected async calculateStatus(): Promise { const authState = await this.authService.getAuthState(); if (authState.isAuthenticated) { @@ -71,35 +88,53 @@ export class CopilotLanguageModelsManagerImpl implements CopilotLanguageModelsMa const model = await this.languageModelRegistry.getLanguageModel(modelDescription.id); if (model) { - if (!(model instanceof CopilotLanguageModel)) { + if (model instanceof CopilotSdkLanguageModel) { + await this.languageModelRegistry.patchLanguageModel(modelDescription.id, { + model: modelDescription.model, + status, + maxRetries: modelDescription.maxRetries + }); + } else if (model instanceof CopilotLanguageModel) { + await this.languageModelRegistry.patchLanguageModel(modelDescription.id, { + model: modelDescription.model, + enableStreaming: modelDescription.enableStreaming, + supportsStructuredOutput: modelDescription.supportsStructuredOutput, + status, + maxRetries: modelDescription.maxRetries + }); + } else { console.warn(`Copilot: model ${modelDescription.id} is not a Copilot model`); continue; } - await this.languageModelRegistry.patchLanguageModel(modelDescription.id, { - model: modelDescription.model, - enableStreaming: modelDescription.enableStreaming, - supportsStructuredOutput: modelDescription.supportsStructuredOutput, - status, - maxRetries: modelDescription.maxRetries - }); } else { - this.languageModelRegistry.addLanguageModels([ - new CopilotLanguageModel( - modelDescription.id, - modelDescription.model, - status, - modelDescription.enableStreaming, - modelDescription.supportsStructuredOutput, - modelDescription.maxRetries, - () => this.authService.getAccessToken(), - () => this.enterpriseUrl, - () => this.oauthConfig.userAgent - ) - ]); + this.languageModelRegistry.addLanguageModels([this.createLanguageModel(modelDescription, status)]); } } } + protected createLanguageModel(modelDescription: CopilotModelDescription, status: LanguageModelStatus): LanguageModel { + if (this.useSdk) { + return new CopilotSdkLanguageModel( + modelDescription.id, + modelDescription.model, + status, + modelDescription.maxRetries, + () => this.sdkClientProvider.getClient() + ); + } + return new CopilotLanguageModel( + modelDescription.id, + modelDescription.model, + status, + modelDescription.enableStreaming, + modelDescription.supportsStructuredOutput, + modelDescription.maxRetries, + () => this.authService.getAccessToken(), + () => this.enterpriseUrl, + () => this.oauthConfig.userAgent + ); + } + removeLanguageModels(...modelIds: string[]): void { this.languageModelRegistry.removeLanguageModels(modelIds); } @@ -109,7 +144,7 @@ export class CopilotLanguageModelsManagerImpl implements CopilotLanguageModelsMa const allModels = await this.languageModelRegistry.getLanguageModels(); for (const model of allModels) { - if (model instanceof CopilotLanguageModel && model.id.startsWith(`${COPILOT_PROVIDER_ID}/`)) { + if ((model instanceof CopilotLanguageModel || model instanceof CopilotSdkLanguageModel) && model.id.startsWith(`${COPILOT_PROVIDER_ID}/`)) { await this.languageModelRegistry.patchLanguageModel(model.id, { status }); @@ -118,6 +153,15 @@ export class CopilotLanguageModelsManagerImpl implements CopilotLanguageModelsMa } async fetchAvailableModelIds(): Promise { + if (this.useSdk) { + try { + return await this.sdkClientProvider.listModelIds(); + } catch (error) { + console.warn('Copilot: failed to fetch available models via the Copilot SDK:', error); + return []; + } + } + const accessToken = await this.authService.getAccessToken(); if (!accessToken) { return []; diff --git a/packages/ai-copilot/src/node/copilot-sdk-client-provider.ts b/packages/ai-copilot/src/node/copilot-sdk-client-provider.ts new file mode 100644 index 0000000000000..7874c51eaf95e --- /dev/null +++ b/packages/ai-copilot/src/node/copilot-sdk-client-provider.ts @@ -0,0 +1,145 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { Disposable } from '@theia/core'; +import { CopilotClient } from '@github/copilot-sdk'; +import { CopilotOAuthConfig } from '../common/copilot-oauth-config'; +import { CopilotAuthServiceImpl } from './copilot-auth-service-impl'; +import { selectSdkModelIds } from './copilot-sdk-mappers'; + +/** + * Manages the lifecycle of a {@link CopilotClient}, which spawns and talks to the + * official Copilot CLI over JSON-RPC. + * + * A single client is started lazily and shared for the lifetime of the + * (connection-scoped) backend container. It is keyed on the authenticated user's + * token so that a sign-in/sign-out transparently recreates the client against the + * new identity. + * + * Note: this provider is bound in the per-connection container, so each frontend + * connection runs its own CLI process. That is acceptable for this prototype but + * would need a shared, per-user client cache for multi-user backend deployments. + */ +@injectable() +export class CopilotSdkClientProvider implements Disposable { + + @inject(CopilotAuthServiceImpl) + protected readonly authService: CopilotAuthServiceImpl; + + @inject(CopilotOAuthConfig) + protected readonly oauthConfig: CopilotOAuthConfig; + + protected clientPromise: Promise | undefined; + protected clientToken: string | undefined; + + /** + * Returns a started {@link CopilotClient} for the currently authenticated user, + * creating (or recreating, if the identity changed) one on demand. + */ + async getClient(): Promise { + const token = await this.authService.getAccessToken(); + if (!token) { + throw new Error('Not authenticated with GitHub Copilot. Please sign in first.'); + } + if (this.clientPromise && this.clientToken === token) { + return this.clientPromise; + } + // First use or a changed identity: (re)create the client. Capture the + // previous client and assign synchronously so concurrent callers share + // the same promise instead of spawning multiple CLI processes. + const previous = this.clientPromise; + this.clientToken = token; + this.clientPromise = this.recreate(token, previous); + return this.clientPromise; + } + + /** + * Lists the model IDs available to the authenticated user via the Copilot CLI. + * Because the CLI is a recognized Copilot integration, this returns the full + * current model lineup rather than the baseline set exposed by the direct REST API. + */ + async listModelIds(): Promise { + const client = await this.getClient(); + const models = await client.listModels(); + return selectSdkModelIds(models); + } + + protected async recreate(token: string, previous: Promise | undefined): Promise { + if (previous) { + try { + const previousClient = await previous; + await previousClient.stop(); + } catch (error) { + console.warn('Copilot SDK: failed to stop previous client:', error); + } + } + const client = new CopilotClient({ + gitHubToken: token, + useLoggedInUser: false, + baseDirectory: this.getBaseDirectory(), + logLevel: 'error' + }); + try { + await client.start(); + } catch (error) { + // Don't cache a failed start, otherwise every later call replays the rejection. + if (this.clientToken === token) { + this.clientPromise = undefined; + this.clientToken = undefined; + } + throw error; + } + return client; + } + + protected getBaseDirectory(): string { + // Use a Theia-scoped Copilot home so the embedded CLI does not read or write + // the user's real ~/.copilot login/session state. Setting baseDirectory also + // makes the SDK disable the OS keychain for the spawned CLI. + const dir = path.join(os.tmpdir(), 'theia-ai-copilot-sdk'); + try { + fs.mkdirSync(dir, { recursive: true }); + } catch (error) { + console.warn('Copilot SDK: failed to create base directory:', error); + } + return dir; + } + + /** + * Stops and clears the current client. Safe to call when no client exists. + */ + async reset(): Promise { + const previous = this.clientPromise; + this.clientPromise = undefined; + this.clientToken = undefined; + if (previous) { + try { + const client = await previous; + await client.stop(); + } catch (error) { + console.warn('Copilot SDK: failed to stop client during reset:', error); + } + } + } + + dispose(): void { + this.reset(); + } +} diff --git a/packages/ai-copilot/src/node/copilot-sdk-language-model.ts b/packages/ai-copilot/src/node/copilot-sdk-language-model.ts new file mode 100644 index 0000000000000..4537860f81a33 --- /dev/null +++ b/packages/ai-copilot/src/node/copilot-sdk-language-model.ts @@ -0,0 +1,171 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { + LanguageModel, + LanguageModelResponse, + LanguageModelStatus, + LanguageModelStreamResponsePart, + UserRequest +} from '@theia/ai-core'; +import { CancellationToken } from '@theia/core'; +import type { CopilotClient, CopilotSession, PermissionRequestResult } from '@github/copilot-sdk'; +import { buildSdkPrompt, flattenSdkPrompt } from './copilot-sdk-mappers'; + +/** + * Language model implementation for GitHub Copilot backed by the official Copilot + * CLI via `@github/copilot-sdk`. + * + * The CLI is an agent that owns its own tool-calling loop. To keep this prototype + * aligned with Theia's "model is a function" contract, the model is used as a plain + * chat backend: all agent tools (built-in and custom) are disabled and each request + * is served by a fresh, streaming session. Multi-turn tool use and structured output + * are intentionally not supported on this path (see the package README). + */ +export class CopilotSdkLanguageModel implements LanguageModel { + + constructor( + public readonly id: string, + public model: string, + public status: LanguageModelStatus, + public maxRetries: number, + protected readonly clientProvider: () => Promise, + ) { } + + async request(request: UserRequest, cancellationToken?: CancellationToken): Promise { + const client = await this.clientProvider(); + const prompt = flattenSdkPrompt(buildSdkPrompt(request.messages)); + + const session = await client.createSession({ + model: this.model, + streaming: true, + // Prototype scope: expose no tools to the agent so the CLI behaves as a + // plain chat backend instead of running its own tool loop on the backend host. + availableTools: [], + onPermissionRequest: (): PermissionRequestResult => ({ kind: 'reject' }) + }); + + return { stream: this.streamResponse(session, prompt, cancellationToken) }; + } + + protected async *streamResponse( + session: CopilotSession, + prompt: string, + cancellationToken?: CancellationToken + ): AsyncIterable { + const queue: LanguageModelStreamResponsePart[] = []; + let notify: (() => void) | undefined; + let finished = false; + let failure: unknown; + let inputTokens: number | undefined; + let outputTokens: number | undefined; + + const wake = () => { + const resolve = notify; + notify = undefined; + resolve?.(); + }; + const push = (part: LanguageModelStreamResponsePart) => { + queue.push(part); + wake(); + }; + const finish = (error?: unknown) => { + if (error !== undefined) { + failure = error; + } + finished = true; + wake(); + }; + + const disposables: Array<() => void> = []; + disposables.push(session.on('assistant.message_delta', event => { + if (event.data.deltaContent) { + push({ content: event.data.deltaContent }); + } + })); + disposables.push(session.on('assistant.reasoning_delta', event => { + if (event.data.deltaContent) { + push({ thought: event.data.deltaContent, signature: '' }); + } + })); + disposables.push(session.on('assistant.message', event => { + if (typeof event.data.outputTokens === 'number') { + outputTokens = event.data.outputTokens; + } + })); + disposables.push(session.on('assistant.usage', event => { + if (typeof event.data.inputTokens === 'number') { + inputTokens = event.data.inputTokens; + } + if (typeof event.data.outputTokens === 'number') { + outputTokens = event.data.outputTokens; + } + })); + disposables.push(session.on('session.idle', () => finish())); + disposables.push(session.on('session.error', event => { + // The CLI can report a terminal error (auth, quota, rate limit, context + // limit, ...) without a following `session.idle`. Surface it as a stream + // failure so the request rejects instead of hanging forever. + const data = event.data; + const detail = data.statusCode !== undefined ? ` (${data.errorType}, status ${data.statusCode})` : ` (${data.errorType})`; + const error = new Error(`Copilot request failed${detail}: ${data.message}`); + if (data.stack) { + error.stack = data.stack; + } + finish(error); + })); + + const cancelListener = cancellationToken?.onCancellationRequested(() => { + session.abort().catch(() => { /* ignore abort failures */ }); + finish(); + }); + + try { + if (cancellationToken?.isCancellationRequested) { + // Already cancelled before we started: don't bother sending. + await session.abort().catch(() => { /* ignore abort failures */ }); + finish(); + } else { + await session.send({ prompt }); + } + while (true) { + while (queue.length > 0) { + yield queue.shift()!; + } + if (finished) { + break; + } + await new Promise(resolve => { notify = resolve; }); + } + if (failure !== undefined) { + throw failure; + } + if (inputTokens !== undefined || outputTokens !== undefined) { + yield { input_tokens: inputTokens ?? 0, output_tokens: outputTokens ?? 0 }; + } + } finally { + for (const dispose of disposables) { + dispose(); + } + cancelListener?.dispose(); + try { + await session.disconnect(); + } catch (error) { + console.warn('Copilot SDK: failed to disconnect session:', error); + } + } + } +} diff --git a/packages/ai-copilot/src/node/copilot-sdk-mappers.spec.ts b/packages/ai-copilot/src/node/copilot-sdk-mappers.spec.ts new file mode 100644 index 0000000000000..f934d6d98fdf5 --- /dev/null +++ b/packages/ai-copilot/src/node/copilot-sdk-mappers.spec.ts @@ -0,0 +1,113 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { expect } from 'chai'; +import { LanguageModelMessage } from '@theia/ai-core'; +import type { ModelInfo } from '@github/copilot-sdk'; +import { selectSdkModelIds, buildSdkPrompt, flattenSdkPrompt } from './copilot-sdk-mappers'; + +// Minimal ModelInfo factory: selectSdkModelIds only reads `id` and `policy.state`. +function model(id: string, state?: 'enabled' | 'disabled' | 'unconfigured'): ModelInfo { + return { id, policy: state ? { state } : undefined } as unknown as ModelInfo; +} + +describe('copilot-sdk-mappers - selectSdkModelIds', () => { + it('keeps enabled and unconfigured models and drops disabled ones', () => { + const result = selectSdkModelIds([ + model('gpt-4o', 'enabled'), + model('o1', 'disabled'), + model('claude', 'unconfigured'), + model('gpt-5') + ]); + expect(result).to.deep.equal(['gpt-4o', 'claude', 'gpt-5']); + }); + + it('preserves order and removes duplicates', () => { + const result = selectSdkModelIds([ + model('a', 'enabled'), + model('b', 'enabled'), + model('a', 'enabled') + ]); + expect(result).to.deep.equal(['a', 'b']); + }); + + it('returns an empty list when given no models', () => { + expect(selectSdkModelIds([])).to.deep.equal([]); + }); +}); + +describe('copilot-sdk-mappers - buildSdkPrompt', () => { + it('forwards a lone user turn verbatim with no system text', () => { + const messages: LanguageModelMessage[] = [ + { actor: 'user', type: 'text', text: 'hello world' } + ]; + const result = buildSdkPrompt(messages); + expect(result.systemText).to.equal(''); + expect(result.prompt).to.equal('hello world'); + }); + + it('extracts and concatenates system messages', () => { + const messages: LanguageModelMessage[] = [ + { actor: 'system', type: 'text', text: 'You are helpful.' }, + { actor: 'system', type: 'text', text: 'Be concise.' }, + { actor: 'user', type: 'text', text: 'hi' } + ]; + const result = buildSdkPrompt(messages); + expect(result.systemText).to.equal('You are helpful.\n\nBe concise.'); + expect(result.prompt).to.equal('hi'); + }); + + it('renders a multi-message history as a role-labelled transcript', () => { + const messages: LanguageModelMessage[] = [ + { actor: 'user', type: 'text', text: 'first' }, + { actor: 'ai', type: 'text', text: 'reply' }, + { actor: 'user', type: 'text', text: 'second' } + ]; + const result = buildSdkPrompt(messages); + expect(result.prompt).to.equal('User: first\n\nAssistant: reply\n\nUser: second'); + }); + + it('drops thinking messages from the conversation body', () => { + const messages: LanguageModelMessage[] = [ + { actor: 'ai', type: 'thinking', thinking: 'internal', signature: 's' }, + { actor: 'user', type: 'text', text: 'only user' } + ]; + const result = buildSdkPrompt(messages); + expect(result.prompt).to.equal('only user'); + }); + + it('summarises tool use and tool result messages in the transcript', () => { + const messages: LanguageModelMessage[] = [ + { actor: 'user', type: 'text', text: 'run it' }, + { actor: 'ai', type: 'tool_use', id: 'call_1', name: 'foo', input: { x: 1 } }, + { actor: 'user', type: 'tool_result', tool_use_id: 'call_1', name: 'foo', content: 'done' } + ]; + const result = buildSdkPrompt(messages); + expect(result.prompt).to.equal( + 'User: run it\n\nAssistant: [tool call: foo {"x":1}]\n\nUser: [tool result: done]' + ); + }); +}); + +describe('copilot-sdk-mappers - flattenSdkPrompt', () => { + it('prepends system text when present', () => { + expect(flattenSdkPrompt({ systemText: 'sys', prompt: 'body' })).to.equal('sys\n\nbody'); + }); + + it('returns the prompt unchanged when there is no system text', () => { + expect(flattenSdkPrompt({ systemText: '', prompt: 'body' })).to.equal('body'); + }); +}); diff --git a/packages/ai-copilot/src/node/copilot-sdk-mappers.ts b/packages/ai-copilot/src/node/copilot-sdk-mappers.ts new file mode 100644 index 0000000000000..11bf4a503d165 --- /dev/null +++ b/packages/ai-copilot/src/node/copilot-sdk-mappers.ts @@ -0,0 +1,132 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { LanguageModelMessage } from '@theia/ai-core'; +// Type-only import: keeps this module free of any runtime dependency on the +// Copilot SDK so the pure mappers can be unit-tested without the CLI installed. +import type { ModelInfo } from '@github/copilot-sdk'; + +/** + * Selects the model IDs that should be surfaced to Theia from the list returned + * by `CopilotClient.listModels()`. + * + * Models whose policy is explicitly `disabled` are filtered out; `enabled` and + * `unconfigured` models are kept. Order is preserved and duplicates are removed. + */ +export function selectSdkModelIds(models: ModelInfo[]): string[] { + const result: string[] = []; + const seen = new Set(); + for (const model of models) { + if (!model.id || seen.has(model.id)) { + continue; + } + if (model.policy && model.policy.state === 'disabled') { + continue; + } + seen.add(model.id); + result.push(model.id); + } + return result; +} + +/** + * The result of flattening a Theia message history into a single prompt for the + * agentic Copilot SDK session. + */ +export interface SdkPrompt { + /** Concatenated content of all system messages (may be empty). */ + systemText: string; + /** The user-facing prompt body derived from the non-system messages. */ + prompt: string; +} + +function roleLabel(message: LanguageModelMessage): string { + switch (message.actor) { + case 'ai': + return 'Assistant'; + case 'system': + return 'System'; + default: + return 'User'; + } +} + +function messageToText(message: LanguageModelMessage): string { + if (LanguageModelMessage.isTextMessage(message)) { + return message.text; + } + if (LanguageModelMessage.isToolUseMessage(message)) { + return `[tool call: ${message.name} ${JSON.stringify(message.input)}]`; + } + if (LanguageModelMessage.isToolResultMessage(message)) { + const content = message.content === undefined + ? '' + : (typeof message.content === 'string' ? message.content : JSON.stringify(message.content)); + return `[tool result: ${content}]`; + } + if (LanguageModelMessage.isImageMessage(message)) { + return '[image omitted]'; + } + return ''; +} + +/** + * Flattens a Theia message history into an {@link SdkPrompt}. + * + * The Copilot SDK session is a stateful agent that accepts a single prompt + * string per `send()` call, so the full Theia history is collapsed here. System + * messages are extracted separately. A lone user turn is forwarded verbatim; + * richer histories are rendered as a role-labelled transcript. + * + * This is a lossy mapping by design (see the prototype limitations documented in + * the package README) and is intended for single-turn requests. + */ +export function buildSdkPrompt(messages: LanguageModelMessage[]): SdkPrompt { + const systemParts: string[] = []; + const conversation: LanguageModelMessage[] = []; + for (const message of messages) { + if (message.actor === 'system' && LanguageModelMessage.isTextMessage(message)) { + systemParts.push(message.text); + } else if (message.type !== 'thinking') { + conversation.push(message); + } + } + + const systemText = systemParts.join('\n\n').trim(); + + let prompt: string; + if (conversation.length === 1 && LanguageModelMessage.isTextMessage(conversation[0]) && conversation[0].actor === 'user') { + prompt = conversation[0].text; + } else { + prompt = conversation + .map(message => `${roleLabel(message)}: ${messageToText(message)}`) + .join('\n\n') + .trim(); + } + + return { systemText, prompt }; +} + +/** + * Combines the system text and prompt body of an {@link SdkPrompt} into the + * single string handed to `session.send({ prompt })`. + */ +export function flattenSdkPrompt(sdkPrompt: SdkPrompt): string { + if (sdkPrompt.systemText) { + return `${sdkPrompt.systemText}\n\n${sdkPrompt.prompt}`.trim(); + } + return sdkPrompt.prompt; +}