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:
parent
4ac7aa4a48
commit
2ad19f4fd5
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`.
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "copilot-proxy",
|
||||
"providers": [
|
||||
"copilot-proxy"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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
177
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 () => {}),
|
||||
}));
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
|
||||
export { githubCopilotLoginCommand } from "../providers/github-copilot-login.js";
|
||||
export {
|
||||
modelsAliasesAddCommand,
|
||||
modelsAliasesListCommand,
|
||||
|
||||
@ -29,7 +29,6 @@ export type AuthChoice =
|
||||
| "minimax-api-lightning"
|
||||
| "opencode-zen"
|
||||
| "github-copilot"
|
||||
| "copilot-proxy"
|
||||
| "qwen-portal"
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "token" | "password";
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
95
src/providers/github-copilot-login.ts
Normal file
95
src/providers/github-copilot-login.ts
Normal 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");
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
104
src/providers/github-copilot-sdk.test.ts
Normal file
104
src/providers/github-copilot-sdk.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
181
src/providers/github-copilot-sdk.ts
Normal file
181
src/providers/github-copilot-sdk.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user