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.
This commit is contained in:
Aditya Mer 2026-01-29 11:17:03 +05:30
parent 4ac7aa4a48
commit 2ad19f4fd5
33 changed files with 570 additions and 813 deletions

6
.github/labeler.yml vendored
View File

@ -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:

View File

@ -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",

View File

@ -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 proxys `/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.

View File

@ -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`.

View File

@ -1,11 +0,0 @@
{
"id": "copilot-proxy",
"providers": [
"copilot-proxy"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -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;

View File

@ -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"
]
}
}

View File

@ -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",

177
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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,
}));

View File

@ -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",

View File

@ -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";

View File

@ -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",

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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 <id>", "Auth profile id (default: github-copilot:github)")
.option("--yes", "Overwrite existing profile without prompting", false)
.action(async (opts) => {

View File

@ -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({

View File

@ -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<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "copilot-proxy",
pluginId: "copilot-proxy",
providerId: "copilot-proxy",
methodId: "local",
label: "Copilot Proxy",
});
}

View File

@ -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) {

View File

@ -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,
];

View File

@ -21,7 +21,6 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"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",

View File

@ -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 () => {}),
}));

View File

@ -1,4 +1,4 @@
export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
export { githubCopilotLoginCommand } from "../providers/github-copilot-login.js";
export {
modelsAliasesAddCommand,
modelsAliasesListCommand,

View File

@ -29,7 +29,6 @@ export type AuthChoice =
| "minimax-api-lightning"
| "opencode-zen"
| "github-copilot"
| "copilot-proxy"
| "qwen-portal"
| "skip";
export type GatewayAuthChoice = "token" | "password";

View File

@ -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<string, unknown> {

View File

@ -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<ProviderUsageSnapshot> {
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",
};
}

View File

@ -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<T>(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<DeviceCodeResponse> {
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<DeviceCodeResponse>(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<string> {
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<DeviceTokenResponse>(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");
}

View File

@ -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");
}

View File

@ -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<string[]> {
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 {

View File

@ -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");
});
});

View File

@ -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<void> | 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<CopilotClient> {
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<void> {
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<CopilotAuthStatus> {
const client = await ensureCopilotClientStarted();
return client.getAuthStatus();
}
/**
* Check if the user is authenticated with GitHub Copilot.
*/
export async function isCopilotAuthenticated(): Promise<boolean> {
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<CopilotModelInfo[]> {
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<boolean> {
try {
await ensureCopilotClientStarted();
return await isCopilotAuthenticated();
} catch {
return false;
}
}

View File

@ -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);
});
});

View File

@ -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<string, unknown>;
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,
};
}