From 2ad19f4fd5e43b03e1db5dcbcaeb2201aa33b0e5 Mon Sep 17 00:00:00 2001 From: Aditya Mer Date: Thu, 29 Jan 2026 11:17:03 +0530 Subject: [PATCH] feat(providers): migrate GitHub Copilot to official SDK Replace custom device-flow OAuth and token exchange with the official @github/copilot-sdk. The SDK manages a Copilot CLI subprocess for authentication and API access. Changes: - Add @github/copilot-sdk dependency (v0.1.19) - Create github-copilot-sdk.ts as core SDK wrapper - Rename github-copilot-auth.ts to github-copilot-login.ts - Merge github-copilot-token.ts into github-copilot-sdk.ts - Remove extensions/copilot-proxy plugin entirely - Update login flow to verify CLI auth status instead of device flow - Add SDK-based model discovery via listCopilotModels() - Update docs and UI labels to reflect SDK-based authentication - Remove copilot-proxy from auth choices and plugin auto-enable BREAKING CHANGE: GitHub Copilot now requires the Copilot CLI to be installed and authenticated via 'copilot auth login' before use. --- .github/labeler.yml | 6 +- .secrets.baseline | 9 - docs/providers/github-copilot.md | 43 ++-- extensions/copilot-proxy/README.md | 24 --- extensions/copilot-proxy/clawdbot.plugin.json | 11 -- extensions/copilot-proxy/index.ts | 142 -------------- extensions/copilot-proxy/package.json | 11 -- package.json | 1 + pnpm-lock.yaml | 177 ++++++++--------- ...s-github-copilot-provider-token-is.test.ts | 4 +- ...fault-baseurl-token-exchange-fails.test.ts | 4 +- src/agents/models-config.providers.ts | 2 +- ...-github-copilot-profile-env-tokens.test.ts | 4 +- src/agents/pi-embedded-runner/compact.ts | 2 +- src/agents/pi-embedded-runner/run.ts | 3 +- src/cli/models-cli.ts | 2 +- src/commands/auth-choice-options.ts | 15 +- .../auth-choice.apply.copilot-proxy.ts | 14 -- .../auth-choice.apply.github-copilot.ts | 7 +- src/commands/auth-choice.apply.ts | 2 - .../auth-choice.preferred-provider.ts | 1 - src/commands/auth-choice.test.ts | 2 +- src/commands/models.ts | 2 +- src/commands/onboard-types.ts | 1 - src/config/plugin-auto-enable.ts | 1 - src/infra/provider-usage.fetch.copilot.ts | 72 ++----- src/providers/github-copilot-auth.ts | 185 ------------------ src/providers/github-copilot-login.ts | 95 +++++++++ src/providers/github-copilot-models.ts | 48 ++++- src/providers/github-copilot-sdk.test.ts | 104 ++++++++++ src/providers/github-copilot-sdk.ts | 181 +++++++++++++++++ src/providers/github-copilot-token.test.ts | 81 -------- src/providers/github-copilot-token.ts | 127 ------------ 33 files changed, 570 insertions(+), 813 deletions(-) delete mode 100644 extensions/copilot-proxy/README.md delete mode 100644 extensions/copilot-proxy/clawdbot.plugin.json delete mode 100644 extensions/copilot-proxy/index.ts delete mode 100644 extensions/copilot-proxy/package.json delete mode 100644 src/commands/auth-choice.apply.copilot-proxy.ts delete mode 100644 src/providers/github-copilot-auth.ts create mode 100644 src/providers/github-copilot-login.ts create mode 100644 src/providers/github-copilot-sdk.test.ts create mode 100644 src/providers/github-copilot-sdk.ts delete mode 100644 src/providers/github-copilot-token.test.ts delete mode 100644 src/providers/github-copilot-token.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..d459efc49 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -180,10 +180,12 @@ - "docs/cli/security.md" - "docs/gateway/security.md" -"extensions: copilot-proxy": +"provider: github-copilot": - changed-files: - any-glob-to-any-file: - - "extensions/copilot-proxy/**" + - "src/providers/github-copilot-*.ts" + - "docs/providers/github-copilot.md" + "extensions: diagnostics-otel": - changed-files: - any-glob-to-any-file: diff --git a/.secrets.baseline b/.secrets.baseline index 826d5b4de..176fedbfa 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -854,15 +854,6 @@ "line_number": 214 } ], - "extensions/copilot-proxy/index.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/copilot-proxy/index.ts", - "hashed_secret": "50f013532a9770a2c2cfdc38b7581dd01df69b70", - "is_verified": false, - "line_number": 4 - } - ], "extensions/google-antigravity-auth/index.ts": [ { "type": "Base64 High Entropy String", diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index c7b68d1bd..61b03fe20 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -1,5 +1,5 @@ --- -summary: "Sign in to GitHub Copilot from Moltbot using the device flow" +summary: "Sign in to GitHub Copilot from Moltbot using the official SDK" read_when: - You want to use GitHub Copilot as a model provider - You need the `moltbot models auth login-github-copilot` flow @@ -9,36 +9,30 @@ read_when: ## What is GitHub Copilot? GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot -models for your GitHub account and plan. Moltbot can use Copilot as a model -provider in two different ways. +models for your GitHub account and plan. Moltbot uses the official +`@github/copilot-sdk` to integrate with Copilot. -## Two ways to use Copilot in Moltbot +## Prerequisites -### 1) Built-in GitHub Copilot provider (`github-copilot`) +The official SDK requires the **Copilot CLI** to be installed and authenticated: -Use the native device-login flow to obtain a GitHub token, then exchange it for -Copilot API tokens when Moltbot runs. This is the **default** and simplest path -because it does not require VS Code. +```bash +# Install Copilot CLI (if not already installed) +npm install -g @github/copilot-cli -### 2) Copilot Proxy plugin (`copilot-proxy`) - -Use the **Copilot Proxy** VS Code extension as a local bridge. Moltbot talks to -the proxy’s `/v1` endpoint and uses the model list you configure there. Choose -this when you already run Copilot Proxy in VS Code or need to route through it. -You must enable the plugin and keep the VS Code extension running. - -Use GitHub Copilot as a model provider (`github-copilot`). The login command runs -the GitHub device flow, saves an auth profile, and updates your config to use that -profile. +# Authenticate with GitHub +copilot auth login +``` ## CLI setup +After authenticating with the Copilot CLI, verify your auth in Moltbot: + ```bash moltbot models auth login-github-copilot ``` -You'll be prompted to visit a URL and enter a one-time code. Keep the terminal -open until it completes. +This checks your Copilot CLI authentication status and creates an auth profile. ### Optional flags @@ -63,8 +57,7 @@ moltbot models set github-copilot/gpt-4o ## Notes -- Requires an interactive TTY; run it directly in a terminal. -- Copilot model availability depends on your plan; if a model is rejected, try - another ID (for example `github-copilot/gpt-4.1`). -- The login stores a GitHub token in the auth profile store and exchanges it for a - Copilot API token when Moltbot runs. +- Requires the Copilot CLI (`copilot`) to be installed and in your PATH. +- Run `copilot auth login` first to authenticate with GitHub. +- Model availability depends on your Copilot subscription plan. +- The official SDK manages token exchange internally. diff --git a/extensions/copilot-proxy/README.md b/extensions/copilot-proxy/README.md deleted file mode 100644 index bf1261659..000000000 --- a/extensions/copilot-proxy/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Copilot Proxy (Clawdbot plugin) - -Provider plugin for the **Copilot Proxy** VS Code extension. - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -clawdbot plugins enable copilot-proxy -``` - -Restart the Gateway after enabling. - -## Authenticate - -```bash -clawdbot models auth login --provider copilot-proxy --set-default -``` - -## Notes - -- Copilot Proxy must be running in VS Code. -- Base URL must include `/v1`. diff --git a/extensions/copilot-proxy/clawdbot.plugin.json b/extensions/copilot-proxy/clawdbot.plugin.json deleted file mode 100644 index c27a03f7d..000000000 --- a/extensions/copilot-proxy/clawdbot.plugin.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "copilot-proxy", - "providers": [ - "copilot-proxy" - ], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/copilot-proxy/index.ts b/extensions/copilot-proxy/index.ts deleted file mode 100644 index 7c68410e7..000000000 --- a/extensions/copilot-proxy/index.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; - -const DEFAULT_BASE_URL = "http://localhost:3000/v1"; -const DEFAULT_API_KEY = "n/a"; -const DEFAULT_CONTEXT_WINDOW = 128_000; -const DEFAULT_MAX_TOKENS = 8192; -const DEFAULT_MODEL_IDS = [ - "gpt-5.2", - "gpt-5.2-codex", - "gpt-5.1", - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5-mini", - "claude-opus-4.5", - "claude-sonnet-4.5", - "claude-haiku-4.5", - "gemini-3-pro", - "gemini-3-flash", - "grok-code-fast-1", -] as const; - -function normalizeBaseUrl(value: string): string { - const trimmed = value.trim(); - if (!trimmed) return DEFAULT_BASE_URL; - let normalized = trimmed; - while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); - if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`; - return normalized; -} - -function validateBaseUrl(value: string): string | undefined { - const normalized = normalizeBaseUrl(value); - try { - new URL(normalized); - } catch { - return "Enter a valid URL"; - } - return undefined; -} - -function parseModelIds(input: string): string[] { - const parsed = input - .split(/[\n,]/) - .map((model) => model.trim()) - .filter(Boolean); - return Array.from(new Set(parsed)); -} - -function buildModelDefinition(modelId: string) { - return { - id: modelId, - name: modelId, - api: "openai-completions", - reasoning: false, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, - }; -} - -const copilotProxyPlugin = { - id: "copilot-proxy", - name: "Copilot Proxy", - description: "Local Copilot Proxy (VS Code LM) provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api) { - api.registerProvider({ - id: "copilot-proxy", - label: "Copilot Proxy", - docsPath: "/providers/models", - auth: [ - { - id: "local", - label: "Local proxy", - hint: "Configure base URL + models for the Copilot Proxy server", - kind: "custom", - run: async (ctx) => { - const baseUrlInput = await ctx.prompter.text({ - message: "Copilot Proxy base URL", - initialValue: DEFAULT_BASE_URL, - validate: validateBaseUrl, - }); - - const modelInput = await ctx.prompter.text({ - message: "Model IDs (comma-separated)", - initialValue: DEFAULT_MODEL_IDS.join(", "), - validate: (value) => - parseModelIds(value).length > 0 ? undefined : "Enter at least one model id", - }); - - const baseUrl = normalizeBaseUrl(baseUrlInput); - const modelIds = parseModelIds(modelInput); - const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0]; - const defaultModelRef = `copilot-proxy/${defaultModelId}`; - - return { - profiles: [ - { - profileId: "copilot-proxy:local", - credential: { - type: "token", - provider: "copilot-proxy", - token: DEFAULT_API_KEY, - }, - }, - ], - configPatch: { - models: { - providers: { - "copilot-proxy": { - baseUrl, - apiKey: DEFAULT_API_KEY, - api: "openai-completions", - authHeader: false, - models: modelIds.map((modelId) => buildModelDefinition(modelId)), - }, - }, - }, - agents: { - defaults: { - models: Object.fromEntries( - modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]), - ), - }, - }, - }, - defaultModel: defaultModelRef, - notes: [ - "Start the Copilot Proxy VS Code extension before using these models.", - "Copilot Proxy serves /v1/chat/completions; base URL must include /v1.", - "Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.", - ], - }; - }, - }, - ], - }); - }, -}; - -export default copilotProxyPlugin; diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json deleted file mode 100644 index 2d4753446..000000000 --- a/extensions/copilot-proxy/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@moltbot/copilot-proxy", - "version": "2026.1.27-beta.1", - "type": "module", - "description": "Moltbot Copilot Proxy provider plugin", - "moltbot": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/package.json b/package.json index 04322f3af..0ae3d6d89 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "@aws-sdk/client-bedrock": "^3.975.0", "@buape/carbon": "0.14.0", "@clack/prompts": "^0.11.0", + "@github/copilot-sdk": "^0.1.19", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..e6f50bc87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 + '@github/copilot-sdk': + specifier: ^0.1.19 + version: 0.1.19 '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.39.3) @@ -264,8 +267,6 @@ importers: extensions/bluebubbles: {} - extensions/copilot-proxy: {} - extensions/diagnostics-otel: dependencies: '@opentelemetry/api': @@ -383,12 +384,12 @@ importers: '@microsoft/agents-hosting-extensions-teams': specifier: ^1.2.2 version: 1.2.2 - moltbot: - specifier: workspace:* - version: link:../.. express: specifier: ^5.2.1 version: 5.2.1 + moltbot: + specifier: workspace:* + version: link:../.. proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -1040,6 +1041,50 @@ packages: '@eshaz/web-worker@1.2.2': resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==} + '@github/copilot-darwin-arm64@0.0.394': + resolution: {integrity: sha512-qDmDFiFaYFW45UhxylN2JyQRLVGLCpkr5UmgbfH5e0aksf+69qytK/MwpD2Cq12KdTjyGMEorlADkSu5eftELA==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@github/copilot-darwin-x64@0.0.394': + resolution: {integrity: sha512-iN4YwSVFxhASiBjLk46f+AzRTNHCvYcmyTKBASxieMIhnDxznYmpo+haFKPCv2lCsEWU8s5LARCnXxxx8J1wKA==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@github/copilot-linux-arm64@0.0.394': + resolution: {integrity: sha512-9NeGvmO2tGztuneXZfYAyW3fDk6Pdl6Ffg8MAUaevA/p0awvA+ti/Vh0ZSTcI81nDTjkzONvrcIcjYAN7x0oSg==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@github/copilot-linux-x64@0.0.394': + resolution: {integrity: sha512-toahsYQORrP/TPSBQ7sxj4/fJg3YUrD0ksCj/Z4y2vT6EwrE9iC2BspKgQRa4CBoCqxYDNB2blc+mQ1UuzPOxg==} + cpu: [x64] + os: [linux] + hasBin: true + + '@github/copilot-sdk@0.1.19': + resolution: {integrity: sha512-h/KvYb6g99v9SurNJGxeXUatmP7GO8KHTAb68GYfmgUqH1EUeN5g0xMUc5lvKxAi7hwj2OxRR73dd37zMMiiiQ==} + engines: {node: '>=18.0.0'} + + '@github/copilot-win32-arm64@0.0.394': + resolution: {integrity: sha512-R7XBP3l+oeDuBrP0KD80ZBEMsZoxAW8QO2MNsDUV8eVrNJnp6KtGHoA+iCsKYKNOD6wHA/q5qm/jR+gpsz46Aw==} + cpu: [arm64] + os: [win32] + hasBin: true + + '@github/copilot-win32-x64@0.0.394': + resolution: {integrity: sha512-/XYV8srP+pMXbf9Gc3wr58zCzBZvsdA3X4poSvr2uU8yCZ6E4pD0agFaZ1c/CikANJi8nb0Id3kulhEhePz/3A==} + cpu: [x64] + os: [win32] + hasBin: true + + '@github/copilot@0.0.394': + resolution: {integrity: sha512-koSiaHvVwjgppgh+puxf6dgsR8ql/WST1scS5bjzMsJFfWk7f4xtEXla7TCQfSGoZkCmCsr2Tis27v5TpssiCg==} + hasBin: true + '@glideapps/ts-necessities@2.2.3': resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==} @@ -3214,11 +3259,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawdbot@2026.1.24-3: - resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==} - engines: {node: '>=22.12.0'} - hasBin: true - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -5457,6 +5497,10 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.1: + resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==} + engines: {node: '>=14.0.0'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -6646,6 +6690,39 @@ snapshots: '@eshaz/web-worker@1.2.2': optional: true + '@github/copilot-darwin-arm64@0.0.394': + optional: true + + '@github/copilot-darwin-x64@0.0.394': + optional: true + + '@github/copilot-linux-arm64@0.0.394': + optional: true + + '@github/copilot-linux-x64@0.0.394': + optional: true + + '@github/copilot-sdk@0.1.19': + dependencies: + '@github/copilot': 0.0.394 + vscode-jsonrpc: 8.2.1 + zod: 4.3.6 + + '@github/copilot-win32-arm64@0.0.394': + optional: true + + '@github/copilot-win32-x64@0.0.394': + optional: true + + '@github/copilot@0.0.394': + optionalDependencies: + '@github/copilot-darwin-arm64': 0.0.394 + '@github/copilot-darwin-x64': 0.0.394 + '@github/copilot-linux-arm64': 0.0.394 + '@github/copilot-linux-x64': 0.0.394 + '@github/copilot-win32-arm64': 0.0.394 + '@github/copilot-win32-x64': 0.0.394 + '@glideapps/ts-necessities@2.2.3': {} '@google/genai@1.34.0': @@ -9098,84 +9175,6 @@ snapshots: dependencies: clsx: 2.1.1 - clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3): - dependencies: - '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.975.0 - '@buape/carbon': 0.14.0(hono@4.11.4) - '@clack/prompts': 0.11.0 - '@grammyjs/runner': 2.0.3(grammy@1.39.3) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3) - '@homebridge/ciao': 1.3.4 - '@line/bot-sdk': 10.6.0 - '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.49.3 - '@mozilla/readability': 0.6.0 - '@sinclair/typebox': 0.34.47 - '@slack/bolt': 4.6.0(@types/express@5.0.6) - '@slack/web-api': 7.13.0 - '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) - ajv: 8.17.1 - body-parser: 2.2.2 - chalk: 5.6.2 - chokidar: 5.0.0 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482) - cli-highlight: 2.1.11 - commander: 14.0.2 - croner: 9.1.0 - detect-libc: 2.1.2 - discord-api-types: 0.38.37 - dotenv: 17.2.3 - express: 5.2.1 - file-type: 21.3.0 - grammy: 1.39.3 - hono: 4.11.4 - jiti: 2.6.1 - json5: 2.2.3 - jszip: 3.10.1 - linkedom: 0.18.12 - long: 5.3.2 - markdown-it: 14.1.0 - node-edge-tts: 1.2.9 - osc-progress: 0.3.0 - pdfjs-dist: 5.4.530 - playwright-core: 1.58.0 - proper-lockfile: 4.1.2 - qrcode-terminal: 0.12.0 - sharp: 0.34.5 - sqlite-vec: 0.1.7-alpha.2 - tar: 7.5.4 - tslog: 4.10.2 - undici: 7.19.0 - ws: 8.19.0 - yaml: 2.8.2 - zod: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': 0.1.88 - node-llama-cpp: 3.15.0(typescript@5.9.3) - transitivePeerDependencies: - - '@discordjs/opus' - - '@modelcontextprotocol/sdk' - - '@types/express' - - audio-decode - - aws-crt - - bufferutil - - canvas - - debug - - devtools-protocol - - encoding - - ffmpeg-static - - jimp - - link-preview-js - - node-opus - - opusscript - - supports-color - - typescript - - utf-8-validate - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -11689,6 +11688,8 @@ snapshots: - tsx - yaml + vscode-jsonrpc@8.2.1: {} + web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} diff --git a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts index f990e7838..f12c3f138 100644 --- a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts +++ b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts @@ -51,7 +51,7 @@ describe("models-config", () => { try { vi.resetModules(); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: vi.fn().mockResolvedValue({ token: "copilot", @@ -97,7 +97,7 @@ describe("models-config", () => { baseUrl: "https://api.copilot.example", }); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken, })); diff --git a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts index 7f2ae84f7..57e900bb0 100644 --- a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts +++ b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts @@ -51,7 +51,7 @@ describe("models-config", () => { try { vi.resetModules(); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test", resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")), })); @@ -105,7 +105,7 @@ describe("models-config", () => { ), ); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: vi.fn().mockResolvedValue({ token: "copilot", diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a176dac8a..8b7d66fed 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -3,7 +3,7 @@ import type { ModelDefinitionConfig } from "../config/types.models.js"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken, -} from "../providers/github-copilot-token.js"; +} from "../providers/github-copilot-sdk.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index f414680cc..2d73306c3 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -87,7 +87,7 @@ describe("models-config", () => { baseUrl: "https://api.copilot.example", }); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken, })); @@ -117,7 +117,7 @@ describe("models-config", () => { try { vi.resetModules(); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: vi.fn().mockResolvedValue({ token: "copilot", diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index dc68561c2..02720a77b 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -144,7 +144,7 @@ export async function compactEmbeddedPiSessionDirect( ); } } else if (model.provider === "github-copilot") { - const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js"); + const { resolveCopilotApiToken } = await import("../../providers/github-copilot-sdk.js"); const copilotToken = await resolveCopilotApiToken({ githubToken: apiKeyInfo.apiKey, }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 870453f38..bed77c1b1 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -229,8 +229,7 @@ export async function runEmbeddedPiAgent( return; } if (model.provider === "github-copilot") { - const { resolveCopilotApiToken } = - await import("../../providers/github-copilot-token.js"); + const { resolveCopilotApiToken } = await import("../../providers/github-copilot-sdk.js"); const copilotToken = await resolveCopilotApiToken({ githubToken: apiKeyInfo.apiKey, }); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index f68631d18..1f471c23a 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -348,7 +348,7 @@ export function registerModelsCli(program: Command) { auth .command("login-github-copilot") - .description("Login to GitHub Copilot via GitHub device flow (TTY required)") + .description("Verify GitHub Copilot CLI authentication status") .option("--profile-id ", "Auth profile id (default: github-copilot:github)") .option("--yes", "Overwrite existing profile without prompting", false) .action(async (opts) => { diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 6b49ff17b..05d16da8c 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -79,9 +79,9 @@ const AUTH_CHOICE_GROUP_DEFS: { }, { value: "copilot", - label: "Copilot", - hint: "GitHub + local proxy", - choices: ["github-copilot", "copilot-proxy"], + label: "GitHub Copilot", + hint: "Official SDK (via Copilot CLI)", + choices: ["github-copilot"], }, { value: "openrouter", @@ -149,8 +149,8 @@ export function buildAuthChoiceOptions(params: { }); options.push({ value: "github-copilot", - label: "GitHub Copilot (GitHub device login)", - hint: "Uses GitHub device flow", + label: "GitHub Copilot (SDK)", + hint: "Uses official GitHub Copilot SDK", }); options.push({ value: "gemini-api-key", label: "Google Gemini API key" }); options.push({ @@ -165,11 +165,6 @@ export function buildAuthChoiceOptions(params: { }); options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" }); options.push({ value: "qwen-portal", label: "Qwen OAuth" }); - options.push({ - value: "copilot-proxy", - label: "Copilot Proxy (local)", - hint: "Local proxy for VS Code Copilot models", - }); options.push({ value: "apiKey", label: "Anthropic API key" }); // Token flow is currently Anthropic-only; use CLI for advanced providers. options.push({ diff --git a/src/commands/auth-choice.apply.copilot-proxy.ts b/src/commands/auth-choice.apply.copilot-proxy.ts deleted file mode 100644 index 390684697..000000000 --- a/src/commands/auth-choice.apply.copilot-proxy.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; - -export async function applyAuthChoiceCopilotProxy( - params: ApplyAuthChoiceParams, -): Promise { - return await applyAuthChoicePluginProvider(params, { - authChoice: "copilot-proxy", - pluginId: "copilot-proxy", - providerId: "copilot-proxy", - methodId: "local", - label: "Copilot Proxy", - }); -} diff --git a/src/commands/auth-choice.apply.github-copilot.ts b/src/commands/auth-choice.apply.github-copilot.ts index 30a1591b2..79ae093ee 100644 --- a/src/commands/auth-choice.apply.github-copilot.ts +++ b/src/commands/auth-choice.apply.github-copilot.ts @@ -1,4 +1,4 @@ -import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +import { githubCopilotLoginCommand } from "../providers/github-copilot-login.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyAuthProfileConfig } from "./onboard-auth.js"; @@ -11,10 +11,11 @@ export async function applyAuthChoiceGitHubCopilot( await params.prompter.note( [ - "This will open a GitHub device login to authorize Copilot.", + "This will verify your GitHub Copilot CLI authentication.", + "Run 'copilot auth login' first if not already authenticated.", "Requires an active GitHub Copilot subscription.", ].join("\n"), - "GitHub Copilot", + "GitHub Copilot SDK", ); if (!process.stdin.isTTY) { diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index f139b509f..f4c3f1117 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -3,7 +3,6 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js"; import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; -import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js"; import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js"; import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js"; import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js"; @@ -44,7 +43,6 @@ export async function applyAuthChoice( applyAuthChoiceGitHubCopilot, applyAuthChoiceGoogleAntigravity, applyAuthChoiceGoogleGeminiCli, - applyAuthChoiceCopilotProxy, applyAuthChoiceQwenPortal, ]; diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 6fe26b59a..b9c5cb533 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -21,7 +21,6 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "synthetic-api-key": "synthetic", "venice-api-key": "venice", "github-copilot": "github-copilot", - "copilot-proxy": "copilot-proxy", "minimax-cloud": "minimax", "minimax-api": "minimax", "minimax-api-lightning": "minimax", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 981588410..c9c58fd91 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -9,7 +9,7 @@ import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import type { AuthChoice } from "./onboard-types.js"; -vi.mock("../providers/github-copilot-auth.js", () => ({ +vi.mock("../providers/github-copilot-login.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), })); diff --git a/src/commands/models.ts b/src/commands/models.ts index 5a1c103c8..0b18df89b 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -1,4 +1,4 @@ -export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +export { githubCopilotLoginCommand } from "../providers/github-copilot-login.js"; export { modelsAliasesAddCommand, modelsAliasesListCommand, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index aa1d9afe0..ff2ccd97c 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -29,7 +29,6 @@ export type AuthChoice = | "minimax-api-lightning" | "opencode-zen" | "github-copilot" - | "copilot-proxy" | "qwen-portal" | "skip"; export type GatewayAuthChoice = "token" | "password"; diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index a7632e41f..473711f48 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -32,7 +32,6 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ { pluginId: "google-antigravity-auth", providerId: "google-antigravity" }, { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, { pluginId: "qwen-portal-auth", providerId: "qwen-portal" }, - { pluginId: "copilot-proxy", providerId: "copilot-proxy" }, ]; function isRecord(value: unknown): value is Record { diff --git a/src/infra/provider-usage.fetch.copilot.ts b/src/infra/provider-usage.fetch.copilot.ts index 3782982aa..9a25bd01f 100644 --- a/src/infra/provider-usage.fetch.copilot.ts +++ b/src/infra/provider-usage.fetch.copilot.ts @@ -1,66 +1,24 @@ -import { fetchJson } from "./provider-usage.fetch.shared.js"; -import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; -import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; +/** + * GitHub Copilot usage tracking. + * + * Note: The official @github/copilot-sdk does not currently expose usage/quota APIs. + * This module returns a placeholder indicating that usage tracking is not available. + */ -type CopilotUsageResponse = { - quota_snapshots?: { - premium_interactions?: { percent_remaining?: number | null }; - chat?: { percent_remaining?: number | null }; - }; - copilot_plan?: string; -}; +import { PROVIDER_LABELS } from "./provider-usage.shared.js"; +import type { ProviderUsageSnapshot } from "./provider-usage.types.js"; export async function fetchCopilotUsage( - token: string, - timeoutMs: number, - fetchFn: typeof fetch, + _token: string, + _timeoutMs: number, + _fetchFn: typeof fetch, ): Promise { - const res = await fetchJson( - "https://api.github.com/copilot_internal/user", - { - headers: { - Authorization: `token ${token}`, - "Editor-Version": "vscode/1.96.2", - "User-Agent": "GitHubCopilotChat/0.26.7", - "X-Github-Api-Version": "2025-04-01", - }, - }, - timeoutMs, - fetchFn, - ); - - if (!res.ok) { - return { - provider: "github-copilot", - displayName: PROVIDER_LABELS["github-copilot"], - windows: [], - error: `HTTP ${res.status}`, - }; - } - - const data = (await res.json()) as CopilotUsageResponse; - const windows: UsageWindow[] = []; - - if (data.quota_snapshots?.premium_interactions) { - const remaining = data.quota_snapshots.premium_interactions.percent_remaining; - windows.push({ - label: "Premium", - usedPercent: clampPercent(100 - (remaining ?? 0)), - }); - } - - if (data.quota_snapshots?.chat) { - const remaining = data.quota_snapshots.chat.percent_remaining; - windows.push({ - label: "Chat", - usedPercent: clampPercent(100 - (remaining ?? 0)), - }); - } - + // The official SDK does not expose usage/quota APIs + // Return a placeholder indicating unavailable status return { provider: "github-copilot", displayName: PROVIDER_LABELS["github-copilot"], - windows, - plan: data.copilot_plan, + windows: [], + error: "Usage tracking not available via official SDK", }; } diff --git a/src/providers/github-copilot-auth.ts b/src/providers/github-copilot-auth.ts deleted file mode 100644 index be81164a0..000000000 --- a/src/providers/github-copilot-auth.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { intro, note, outro, spinner } from "@clack/prompts"; - -import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js"; -import { updateConfig } from "../commands/models/shared.js"; -import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; -import { logConfigUpdated } from "../config/logging.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { stylePromptTitle } from "../terminal/prompt-style.js"; - -const CLIENT_ID = "Iv1.b507a08c87ecfe98"; -const DEVICE_CODE_URL = "https://github.com/login/device/code"; -const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; - -type DeviceCodeResponse = { - device_code: string; - user_code: string; - verification_uri: string; - expires_in: number; - interval: number; -}; - -type DeviceTokenResponse = - | { - access_token: string; - token_type: string; - scope?: string; - } - | { - error: string; - error_description?: string; - error_uri?: string; - }; - -function parseJsonResponse(value: unknown): T { - if (!value || typeof value !== "object") { - throw new Error("Unexpected response from GitHub"); - } - return value as T; -} - -async function requestDeviceCode(params: { scope: string }): Promise { - const body = new URLSearchParams({ - client_id: CLIENT_ID, - scope: params.scope, - }); - - const res = await fetch(DEVICE_CODE_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }); - - if (!res.ok) { - throw new Error(`GitHub device code failed: HTTP ${res.status}`); - } - - const json = parseJsonResponse(await res.json()); - if (!json.device_code || !json.user_code || !json.verification_uri) { - throw new Error("GitHub device code response missing fields"); - } - return json; -} - -async function pollForAccessToken(params: { - deviceCode: string; - intervalMs: number; - expiresAt: number; -}): Promise { - const bodyBase = new URLSearchParams({ - client_id: CLIENT_ID, - device_code: params.deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }); - - while (Date.now() < params.expiresAt) { - const res = await fetch(ACCESS_TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: bodyBase, - }); - - if (!res.ok) { - throw new Error(`GitHub device token failed: HTTP ${res.status}`); - } - - const json = parseJsonResponse(await res.json()); - if ("access_token" in json && typeof json.access_token === "string") { - return json.access_token; - } - - const err = "error" in json ? json.error : "unknown"; - if (err === "authorization_pending") { - await new Promise((r) => setTimeout(r, params.intervalMs)); - continue; - } - if (err === "slow_down") { - await new Promise((r) => setTimeout(r, params.intervalMs + 2000)); - continue; - } - if (err === "expired_token") { - throw new Error("GitHub device code expired; run login again"); - } - if (err === "access_denied") { - throw new Error("GitHub login cancelled"); - } - throw new Error(`GitHub device flow error: ${err}`); - } - - throw new Error("GitHub device code expired; run login again"); -} - -export async function githubCopilotLoginCommand( - opts: { profileId?: string; yes?: boolean }, - runtime: RuntimeEnv, -) { - if (!process.stdin.isTTY) { - throw new Error("github-copilot login requires an interactive TTY."); - } - - intro(stylePromptTitle("GitHub Copilot login")); - - const profileId = opts.profileId?.trim() || "github-copilot:github"; - const store = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }); - - if (store.profiles[profileId] && !opts.yes) { - note( - `Auth profile already exists: ${profileId}\nRe-running will overwrite it.`, - stylePromptTitle("Existing credentials"), - ); - } - - const spin = spinner(); - spin.start("Requesting device code from GitHub..."); - const device = await requestDeviceCode({ scope: "read:user" }); - spin.stop("Device code ready"); - - note( - [`Visit: ${device.verification_uri}`, `Code: ${device.user_code}`].join("\n"), - stylePromptTitle("Authorize"), - ); - - const expiresAt = Date.now() + device.expires_in * 1000; - const intervalMs = Math.max(1000, device.interval * 1000); - - const polling = spinner(); - polling.start("Waiting for GitHub authorization..."); - const accessToken = await pollForAccessToken({ - deviceCode: device.device_code, - intervalMs, - expiresAt, - }); - polling.stop("GitHub access token acquired"); - - upsertAuthProfile({ - profileId, - credential: { - type: "token", - provider: "github-copilot", - token: accessToken, - // GitHub device flow token doesn't reliably include expiry here. - // Leave expires unset; we'll exchange into Copilot token plus expiry later. - }, - }); - - await updateConfig((cfg) => - applyAuthProfileConfig(cfg, { - provider: "github-copilot", - profileId, - mode: "token", - }), - ); - - logConfigUpdated(runtime); - runtime.log(`Auth profile: ${profileId} (github-copilot/token)`); - - outro("Done"); -} diff --git a/src/providers/github-copilot-login.ts b/src/providers/github-copilot-login.ts new file mode 100644 index 000000000..d128ed154 --- /dev/null +++ b/src/providers/github-copilot-login.ts @@ -0,0 +1,95 @@ +import { intro, note, outro, spinner } from "@clack/prompts"; + +import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js"; +import { updateConfig } from "../commands/models/shared.js"; +import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +import { logConfigUpdated } from "../config/logging.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; +import { + getCopilotAuthStatus, + ensureCopilotClientStarted, + stopCopilotClient, +} from "./github-copilot-sdk.js"; + +export async function githubCopilotLoginCommand( + opts: { profileId?: string; yes?: boolean }, + runtime: RuntimeEnv, +) { + if (!process.stdin.isTTY) { + throw new Error("github-copilot login requires an interactive TTY."); + } + + intro(stylePromptTitle("GitHub Copilot login")); + + const profileId = opts.profileId?.trim() || "github-copilot:github"; + const store = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }); + + if (store.profiles[profileId] && !opts.yes) { + note( + `Auth profile already exists: ${profileId}\nRe-running will overwrite it.`, + stylePromptTitle("Existing credentials"), + ); + } + + const spin = spinner(); + spin.start("Checking GitHub Copilot authentication status..."); + + try { + await ensureCopilotClientStarted(); + const authStatus = await getCopilotAuthStatus(); + + if (!authStatus.isAuthenticated) { + spin.stop("Not authenticated"); + note( + [ + "GitHub Copilot CLI is not authenticated.", + "Please run 'copilot auth login' in your terminal first,", + "then retry this command.", + ].join("\n"), + stylePromptTitle("Authentication required"), + ); + await stopCopilotClient(); + throw new Error("GitHub Copilot not authenticated. Run 'copilot auth login' first."); + } + + spin.stop(`Authenticated as ${authStatus.login ?? authStatus.authType ?? "user"}`); + + // Store a marker profile indicating SDK-managed authentication + upsertAuthProfile({ + profileId, + credential: { + type: "token", + provider: "github-copilot", + // The SDK manages tokens internally, so we store a marker token + token: "sdk-managed", + }, + }); + + await updateConfig((cfg) => + applyAuthProfileConfig(cfg, { + provider: "github-copilot", + profileId, + mode: "token", + }), + ); + + logConfigUpdated(runtime); + runtime.log(`Auth profile: ${profileId} (github-copilot/sdk-managed)`); + if (authStatus.login) { + runtime.log(`Logged in as: ${authStatus.login}`); + } + if (authStatus.statusMessage) { + note(authStatus.statusMessage, stylePromptTitle("Status")); + } + } catch (err) { + spin.stop("Error"); + await stopCopilotClient(); + throw err; + } + + await stopCopilotClient(); + outro("Done"); +} diff --git a/src/providers/github-copilot-models.ts b/src/providers/github-copilot-models.ts index 31b126b64..ae231818e 100644 --- a/src/providers/github-copilot-models.ts +++ b/src/providers/github-copilot-models.ts @@ -1,12 +1,11 @@ import type { ModelDefinitionConfig } from "../config/types.js"; +import { listCopilotModels, type CopilotModelInfo } from "./github-copilot-sdk.js"; const DEFAULT_CONTEXT_WINDOW = 128_000; const DEFAULT_MAX_TOKENS = 8192; -// Copilot model ids vary by plan/org and can change. -// We keep this list intentionally broad; if a model isn't available Copilot will -// return an error and users can remove it from their config. -const DEFAULT_MODEL_IDS = [ +// Fallback model ids if SDK model discovery fails +const FALLBACK_MODEL_IDS = [ "gpt-4o", "gpt-4.1", "gpt-4.1-mini", @@ -16,8 +15,45 @@ const DEFAULT_MODEL_IDS = [ "o3-mini", ] as const; -export function getDefaultCopilotModelIds(): string[] { - return [...DEFAULT_MODEL_IDS]; +/** + * Get available model IDs from the Copilot SDK. + * Falls back to hardcoded list if SDK discovery fails. + */ +export async function getDefaultCopilotModelIds(): Promise { + try { + const models = await listCopilotModels(); + if (models.length > 0) { + return models.map((m) => m.id); + } + } catch { + // Fall through to fallback list + } + return [...FALLBACK_MODEL_IDS]; +} + +/** + * Get available model IDs synchronously (fallback list only). + * Use getDefaultCopilotModelIds() for SDK-based discovery. + */ +export function getDefaultCopilotModelIdsSync(): string[] { + return [...FALLBACK_MODEL_IDS]; +} + +/** + * Build a model definition from SDK model info. + */ +export function buildCopilotModelDefinitionFromSdk(model: CopilotModelInfo): ModelDefinitionConfig { + return { + id: model.id, + name: model.name, + // The SDK manages API routing internally + api: "openai-responses", + reasoning: false, + input: model.capabilities?.supports?.vision ? ["text", "image"] : ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.capabilities?.limits?.max_context_window_tokens ?? DEFAULT_CONTEXT_WINDOW, + maxTokens: model.capabilities?.limits?.max_prompt_tokens ?? DEFAULT_MAX_TOKENS, + }; } export function buildCopilotModelDefinition(modelId: string): ModelDefinitionConfig { diff --git a/src/providers/github-copilot-sdk.test.ts b/src/providers/github-copilot-sdk.test.ts new file mode 100644 index 000000000..c62635acb --- /dev/null +++ b/src/providers/github-copilot-sdk.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoist mock functions so they're available before module import +const mockGetAuthStatus = vi.hoisted(() => vi.fn()); +const mockListModels = vi.hoisted(() => vi.fn()); +const mockStart = vi.hoisted(() => vi.fn()); +const mockStop = vi.hoisted(() => vi.fn()); +const mockGetState = vi.hoisted(() => vi.fn().mockReturnValue("disconnected")); + +// Create a mock class that vitest can instantiate +const MockCopilotClient = vi.hoisted(() => + vi.fn().mockImplementation(function () { + return { + getAuthStatus: mockGetAuthStatus, + listModels: mockListModels, + start: mockStart, + stop: mockStop, + getState: mockGetState, + }; + }), +); + +// Mock the @github/copilot-sdk module +vi.mock("@github/copilot-sdk", () => ({ + CopilotClient: MockCopilotClient, +})); + +describe("github-copilot-sdk", () => { + beforeEach(async () => { + vi.resetModules(); + mockGetAuthStatus.mockReset(); + mockListModels.mockReset(); + mockStart.mockReset(); + mockStop.mockReset(); + mockGetState.mockReset(); + mockGetState.mockReturnValue("disconnected"); + MockCopilotClient.mockClear(); + }); + + it("deriveCopilotApiBaseUrlFromToken returns null (SDK manages base URL)", async () => { + const { deriveCopilotApiBaseUrlFromToken } = await import("./github-copilot-sdk.js"); + expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBeNull(); + expect(deriveCopilotApiBaseUrlFromToken("anything")).toBeNull(); + }); + + it("returns sdk-managed token when authenticated", async () => { + mockStart.mockResolvedValue(undefined); + mockGetAuthStatus.mockResolvedValue({ isAuthenticated: true }); + + const { resolveCopilotApiToken } = await import("./github-copilot-sdk.js"); + const res = await resolveCopilotApiToken({ githubToken: "gh" }); + + expect(res.token).toBe("sdk-managed"); + expect(res.source).toBe("sdk:copilot-cli"); + expect(res.baseUrl).toBe("https://api.individual.githubcopilot.com"); + expect(res.expiresAt).toBeGreaterThan(Date.now()); + }); + + it("throws error when not authenticated", async () => { + mockStart.mockResolvedValue(undefined); + mockGetAuthStatus.mockResolvedValue({ isAuthenticated: false }); + + const { resolveCopilotApiToken } = await import("./github-copilot-sdk.js"); + await expect(resolveCopilotApiToken({ githubToken: "gh" })).rejects.toThrow( + "GitHub Copilot is not authenticated", + ); + }); + + it("isCopilotSdkReady returns true when SDK is available and authenticated", async () => { + mockStart.mockResolvedValue(undefined); + mockGetAuthStatus.mockResolvedValue({ isAuthenticated: true }); + + const { isCopilotSdkReady } = await import("./github-copilot-sdk.js"); + expect(await isCopilotSdkReady()).toBe(true); + }); + + it("isCopilotSdkReady returns false when not authenticated", async () => { + mockStart.mockResolvedValue(undefined); + mockGetAuthStatus.mockResolvedValue({ isAuthenticated: false }); + + const { isCopilotSdkReady } = await import("./github-copilot-sdk.js"); + expect(await isCopilotSdkReady()).toBe(false); + }); + + it("isCopilotSdkReady returns false when SDK fails to start", async () => { + mockStart.mockRejectedValue(new Error("CLI not found")); + + const { isCopilotSdkReady } = await import("./github-copilot-sdk.js"); + expect(await isCopilotSdkReady()).toBe(false); + }); + + it("listCopilotModels returns models from SDK", async () => { + mockStart.mockResolvedValue(undefined); + mockListModels.mockResolvedValue([ + { id: "gpt-4o", name: "GPT-4o" }, + { id: "o1", name: "O1" }, + ]); + + const { listCopilotModels } = await import("./github-copilot-sdk.js"); + const models = await listCopilotModels(); + expect(models).toHaveLength(2); + expect(models[0].id).toBe("gpt-4o"); + }); +}); diff --git a/src/providers/github-copilot-sdk.ts b/src/providers/github-copilot-sdk.ts new file mode 100644 index 000000000..7590c7c7e --- /dev/null +++ b/src/providers/github-copilot-sdk.ts @@ -0,0 +1,181 @@ +/** + * GitHub Copilot SDK integration. + * + * Uses the official @github/copilot-sdk which manages a Copilot CLI subprocess + * for authentication and API access. + */ + +import { CopilotClient, type ModelInfo } from "@github/copilot-sdk"; + +import { resolveStateDir } from "../config/paths.js"; + +let sharedClient: CopilotClient | null = null; +let clientStartPromise: Promise | null = null; + +export type CopilotAuthStatus = { + isAuthenticated: boolean; + authType?: string; + host?: string; + login?: string; + statusMessage?: string; +}; + +export type CopilotModelInfo = ModelInfo; + +// Legacy type for backward compatibility +export type CachedCopilotToken = { + token: string; + /** milliseconds since epoch */ + expiresAt: number; + /** milliseconds since epoch */ + updatedAt: number; +}; + +// Legacy constant for backward compatibility +export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; + +/** + * Get or create the shared CopilotClient singleton. + * The SDK manages a Copilot CLI subprocess internally. + */ +export function getCopilotClient(): CopilotClient { + if (!sharedClient) { + sharedClient = new CopilotClient({ + logLevel: "error", + autoStart: false, + }); + } + return sharedClient; +} + +/** + * Ensure the Copilot CLI subprocess is started and connected. + */ +export async function ensureCopilotClientStarted(): Promise { + const client = getCopilotClient(); + const state = client.getState(); + + if (state === "connected") { + return client; + } + + if (clientStartPromise) { + await clientStartPromise; + return client; + } + + clientStartPromise = client.start(); + try { + await clientStartPromise; + } finally { + clientStartPromise = null; + } + + return client; +} + +/** + * Stop the shared Copilot client if running. + */ +export async function stopCopilotClient(): Promise { + if (sharedClient) { + const state = sharedClient.getState(); + if (state === "connected") { + await sharedClient.stop(); + } + sharedClient = null; + } +} + +/** + * Get authentication status from the Copilot CLI. + */ +export async function getCopilotAuthStatus(): Promise { + const client = await ensureCopilotClientStarted(); + return client.getAuthStatus(); +} + +/** + * Check if the user is authenticated with GitHub Copilot. + */ +export async function isCopilotAuthenticated(): Promise { + try { + const status = await getCopilotAuthStatus(); + return status.isAuthenticated; + } catch { + return false; + } +} + +/** + * List available models from the Copilot API. + * Requires authentication. + */ +export async function listCopilotModels(): Promise { + const client = await ensureCopilotClientStarted(); + return client.listModels(); +} + +/** + * Get cached token path for legacy compatibility. + * The SDK handles token management internally, but we keep this + * for existing code that checks for token presence. + */ +export function getCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env): string { + return `${resolveStateDir(env)}/credentials/github-copilot.token.json`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Legacy token compatibility layer +// ───────────────────────────────────────────────────────────────────────────── + +/** + * @deprecated The SDK manages base URLs internally. This function always returns null. + */ +export function deriveCopilotApiBaseUrlFromToken(_token: string): string | null { + return null; +} + +/** + * Resolve Copilot API token via the official SDK. + * + * The SDK manages token exchange and caching internally, so this function + * now checks authentication status and returns a marker indicating SDK-managed auth. + * + * @throws Error if Copilot CLI is not authenticated + */ +export async function resolveCopilotApiToken(_params: { + githubToken: string; + env?: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; +}): Promise<{ + token: string; + expiresAt: number; + source: string; + baseUrl: string; +}> { + const isAuthenticated = await isCopilotAuthenticated(); + + if (!isAuthenticated) { + throw new Error("GitHub Copilot is not authenticated. Run 'copilot auth login' first."); + } + + return { + token: "sdk-managed", + expiresAt: Date.now() + 3600 * 1000, + source: "sdk:copilot-cli", + baseUrl: DEFAULT_COPILOT_API_BASE_URL, + }; +} + +/** + * Check if Copilot SDK is available and authenticated. + */ +export async function isCopilotSdkReady(): Promise { + try { + await ensureCopilotClientStarted(); + return await isCopilotAuthenticated(); + } catch { + return false; + } +} diff --git a/src/providers/github-copilot-token.test.ts b/src/providers/github-copilot-token.test.ts deleted file mode 100644 index 5ef8acc5e..000000000 --- a/src/providers/github-copilot-token.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const loadJsonFile = vi.fn(); -const saveJsonFile = vi.fn(); -const resolveStateDir = vi.fn().mockReturnValue("/tmp/moltbot-state"); - -vi.mock("../infra/json-file.js", () => ({ - loadJsonFile, - saveJsonFile, -})); - -vi.mock("../config/paths.js", () => ({ - resolveStateDir, -})); - -describe("github-copilot token", () => { - beforeEach(() => { - vi.resetModules(); - loadJsonFile.mockReset(); - saveJsonFile.mockReset(); - resolveStateDir.mockReset(); - resolveStateDir.mockReturnValue("/tmp/moltbot-state"); - }); - - it("derives baseUrl from token", async () => { - const { deriveCopilotApiBaseUrlFromToken } = await import("./github-copilot-token.js"); - - expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe( - "https://api.example.com", - ); - expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBe( - "https://api.foo.bar", - ); - }); - - it("uses cache when token is still valid", async () => { - const now = Date.now(); - loadJsonFile.mockReturnValue({ - token: "cached;proxy-ep=proxy.example.com;", - expiresAt: now + 60 * 60 * 1000, - updatedAt: now, - }); - - const { resolveCopilotApiToken } = await import("./github-copilot-token.js"); - - const fetchImpl = vi.fn(); - const res = await resolveCopilotApiToken({ - githubToken: "gh", - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - - expect(res.token).toBe("cached;proxy-ep=proxy.example.com;"); - expect(res.baseUrl).toBe("https://api.example.com"); - expect(String(res.source)).toContain("cache:"); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("fetches and stores token when cache is missing", async () => { - loadJsonFile.mockReturnValue(undefined); - - const fetchImpl = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - token: "fresh;proxy-ep=https://proxy.contoso.test;", - expires_at: Math.floor(Date.now() / 1000) + 3600, - }), - }); - - const { resolveCopilotApiToken } = await import("./github-copilot-token.js"); - - const res = await resolveCopilotApiToken({ - githubToken: "gh", - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - - expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;"); - expect(res.baseUrl).toBe("https://api.contoso.test"); - expect(saveJsonFile).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/providers/github-copilot-token.ts b/src/providers/github-copilot-token.ts deleted file mode 100644 index 19efd4a9d..000000000 --- a/src/providers/github-copilot-token.ts +++ /dev/null @@ -1,127 +0,0 @@ -import path from "node:path"; - -import { resolveStateDir } from "../config/paths.js"; -import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; - -const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; - -export type CachedCopilotToken = { - token: string; - /** milliseconds since epoch */ - expiresAt: number; - /** milliseconds since epoch */ - updatedAt: number; -}; - -function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { - return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json"); -} - -function isTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean { - // Keep a small safety margin when checking expiry. - return cache.expiresAt - now > 5 * 60 * 1000; -} - -function parseCopilotTokenResponse(value: unknown): { - token: string; - expiresAt: number; -} { - if (!value || typeof value !== "object") { - throw new Error("Unexpected response from GitHub Copilot token endpoint"); - } - const asRecord = value as Record; - const token = asRecord.token; - const expiresAt = asRecord.expires_at; - if (typeof token !== "string" || token.trim().length === 0) { - throw new Error("Copilot token response missing token"); - } - - // GitHub returns a unix timestamp (seconds), but we defensively accept ms too. - let expiresAtMs: number; - if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { - expiresAtMs = expiresAt > 10_000_000_000 ? expiresAt : expiresAt * 1000; - } else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) { - const parsed = Number.parseInt(expiresAt, 10); - if (!Number.isFinite(parsed)) { - throw new Error("Copilot token response has invalid expires_at"); - } - expiresAtMs = parsed > 10_000_000_000 ? parsed : parsed * 1000; - } else { - throw new Error("Copilot token response missing expires_at"); - } - - return { token, expiresAt: expiresAtMs }; -} - -export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; - -export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { - const trimmed = token.trim(); - if (!trimmed) return null; - - // The token returned from the Copilot token endpoint is a semicolon-delimited - // set of key/value pairs. One of them is `proxy-ep=...`. - const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i); - const proxyEp = match?.[1]?.trim(); - if (!proxyEp) return null; - - // pi-ai expects converting proxy.* -> api.* - // (see upstream getGitHubCopilotBaseUrl). - const host = proxyEp.replace(/^https?:\/\//, "").replace(/^proxy\./i, "api."); - if (!host) return null; - - return `https://${host}`; -} - -export async function resolveCopilotApiToken(params: { - githubToken: string; - env?: NodeJS.ProcessEnv; - fetchImpl?: typeof fetch; -}): Promise<{ - token: string; - expiresAt: number; - source: string; - baseUrl: string; -}> { - const env = params.env ?? process.env; - const cachePath = resolveCopilotTokenCachePath(env); - const cached = loadJsonFile(cachePath) as CachedCopilotToken | undefined; - if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") { - if (isTokenUsable(cached)) { - return { - token: cached.token, - expiresAt: cached.expiresAt, - source: `cache:${cachePath}`, - baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL, - }; - } - } - - const fetchImpl = params.fetchImpl ?? fetch; - const res = await fetchImpl(COPILOT_TOKEN_URL, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${params.githubToken}`, - }, - }); - - if (!res.ok) { - throw new Error(`Copilot token exchange failed: HTTP ${res.status}`); - } - - const json = parseCopilotTokenResponse(await res.json()); - const payload: CachedCopilotToken = { - token: json.token, - expiresAt: json.expiresAt, - updatedAt: Date.now(), - }; - saveJsonFile(cachePath, payload); - - return { - token: payload.token, - expiresAt: payload.expiresAt, - source: `fetched:${COPILOT_TOKEN_URL}`, - baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL, - }; -}