diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..d459efc49 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -180,10 +180,12 @@ - "docs/cli/security.md" - "docs/gateway/security.md" -"extensions: copilot-proxy": +"provider: github-copilot": - changed-files: - any-glob-to-any-file: - - "extensions/copilot-proxy/**" + - "src/providers/github-copilot-*.ts" + - "docs/providers/github-copilot.md" + "extensions: diagnostics-otel": - changed-files: - any-glob-to-any-file: diff --git a/.secrets.baseline b/.secrets.baseline index 826d5b4de..176fedbfa 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -854,15 +854,6 @@ "line_number": 214 } ], - "extensions/copilot-proxy/index.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/copilot-proxy/index.ts", - "hashed_secret": "50f013532a9770a2c2cfdc38b7581dd01df69b70", - "is_verified": false, - "line_number": 4 - } - ], "extensions/google-antigravity-auth/index.ts": [ { "type": "Base64 High Entropy String", diff --git a/docs/plugin.md b/docs/plugin.md index 383eedc49..207688666 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -47,7 +47,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin. - Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default) - Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default) - Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default) -- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default) + OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. **Config validation does not execute plugin code**; it uses the plugin manifest and JSON diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index d57603b5a..4adbe1de2 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -4,41 +4,30 @@ read_when: - You want to use GitHub Copilot as a model provider - You need the `openclaw models auth login-github-copilot` flow --- -# Github Copilot +# GitHub Copilot ## What is GitHub Copilot? GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot models for your GitHub account and plan. OpenClaw can use Copilot as a model -provider in two different ways. - -## Two ways to use Copilot in OpenClaw - -### 1) Built-in GitHub Copilot provider (`github-copilot`) - -Use the native device-login flow to obtain a GitHub token, then exchange it for -Copilot API tokens when OpenClaw runs. This is the **default** and simplest path -because it does not require VS Code. - -### 2) Copilot Proxy plugin (`copilot-proxy`) - -Use the **Copilot Proxy** VS Code extension as a local bridge. OpenClaw 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. +provider via the official GitHub Copilot SDK. OpenClaw uses the GitHub device +flow to obtain a GitHub token and exchanges it for Copilot API tokens at runtime. +This is the recommended and supported path for integrating Copilot with +OpenClaw. 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. +the GitHub device flow, saves an auth profile, and updates your config to use +that profile. ## CLI setup +Run the OpenClaw login flow to create an auth profile: + ```bash openclaw 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 creates an auth profile and (optionally) updates your config to use it. ### Optional flags diff --git a/extensions/copilot-proxy/README.md b/extensions/copilot-proxy/README.md deleted file mode 100644 index 07ffde465..000000000 --- a/extensions/copilot-proxy/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Copilot Proxy (OpenClaw plugin) - -Provider plugin for the **Copilot Proxy** VS Code extension. - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable copilot-proxy -``` - -Restart the Gateway after enabling. - -## Authenticate - -```bash -openclaw models auth login --provider copilot-proxy --set-default -``` - -## Notes - -- Copilot Proxy must be running in VS Code. -- Base URL must include `/v1`. diff --git a/extensions/copilot-proxy/index.ts b/extensions/copilot-proxy/index.ts deleted file mode 100644 index 0529b7bc5..000000000 --- a/extensions/copilot-proxy/index.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; - -const DEFAULT_BASE_URL = "http://localhost:3000/v1"; -const DEFAULT_API_KEY = "n/a"; -const DEFAULT_CONTEXT_WINDOW = 128_000; -const DEFAULT_MAX_TOKENS = 8192; -const DEFAULT_MODEL_IDS = [ - "gpt-5.2", - "gpt-5.2-codex", - "gpt-5.1", - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5-mini", - "claude-opus-4.5", - "claude-sonnet-4.5", - "claude-haiku-4.5", - "gemini-3-pro", - "gemini-3-flash", - "grok-code-fast-1", -] as const; - -function normalizeBaseUrl(value: string): string { - const trimmed = value.trim(); - if (!trimmed) return DEFAULT_BASE_URL; - let normalized = trimmed; - while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); - if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`; - return normalized; -} - -function validateBaseUrl(value: string): string | undefined { - const normalized = normalizeBaseUrl(value); - try { - new URL(normalized); - } catch { - return "Enter a valid URL"; - } - return undefined; -} - -function parseModelIds(input: string): string[] { - const parsed = input - .split(/[\n,]/) - .map((model) => model.trim()) - .filter(Boolean); - return Array.from(new Set(parsed)); -} - -function buildModelDefinition(modelId: string) { - return { - id: modelId, - name: modelId, - api: "openai-completions", - reasoning: false, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, - }; -} - -const copilotProxyPlugin = { - id: "copilot-proxy", - name: "Copilot Proxy", - description: "Local Copilot Proxy (VS Code LM) provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api) { - api.registerProvider({ - id: "copilot-proxy", - label: "Copilot Proxy", - docsPath: "/providers/models", - auth: [ - { - id: "local", - label: "Local proxy", - hint: "Configure base URL + models for the Copilot Proxy server", - kind: "custom", - run: async (ctx) => { - const baseUrlInput = await ctx.prompter.text({ - message: "Copilot Proxy base URL", - initialValue: DEFAULT_BASE_URL, - validate: validateBaseUrl, - }); - - const modelInput = await ctx.prompter.text({ - message: "Model IDs (comma-separated)", - initialValue: DEFAULT_MODEL_IDS.join(", "), - validate: (value) => - parseModelIds(value).length > 0 ? undefined : "Enter at least one model id", - }); - - const baseUrl = normalizeBaseUrl(baseUrlInput); - const modelIds = parseModelIds(modelInput); - const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0]; - const defaultModelRef = `copilot-proxy/${defaultModelId}`; - - return { - profiles: [ - { - profileId: "copilot-proxy:local", - credential: { - type: "token", - provider: "copilot-proxy", - token: DEFAULT_API_KEY, - }, - }, - ], - configPatch: { - models: { - providers: { - "copilot-proxy": { - baseUrl, - apiKey: DEFAULT_API_KEY, - api: "openai-completions", - authHeader: false, - models: modelIds.map((modelId) => buildModelDefinition(modelId)), - }, - }, - }, - agents: { - defaults: { - models: Object.fromEntries( - modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]), - ), - }, - }, - }, - defaultModel: defaultModelRef, - notes: [ - "Start the Copilot Proxy VS Code extension before using these models.", - "Copilot Proxy serves /v1/chat/completions; base URL must include /v1.", - "Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.", - ], - }; - }, - }, - ], - }); - }, -}; - -export default copilotProxyPlugin; diff --git a/extensions/copilot-proxy/openclaw.plugin.json b/extensions/copilot-proxy/openclaw.plugin.json deleted file mode 100644 index c27a03f7d..000000000 --- a/extensions/copilot-proxy/openclaw.plugin.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "copilot-proxy", - "providers": [ - "copilot-proxy" - ], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json deleted file mode 100644 index 60ca9dbbd..000000000 --- a/extensions/copilot-proxy/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@openclaw/copilot-proxy", - "version": "2026.1.29", - "type": "module", - "description": "OpenClaw Copilot Proxy provider plugin", - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/package.json b/package.json index 77211d865..ce02f5d94 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "@aws-sdk/client-bedrock": "^3.975.0", "@buape/carbon": "0.14.0", "@clack/prompts": "^0.11.0", + "@github/copilot-sdk": "^0.1.19", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95b940c97..cc11849f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 + '@github/copilot-sdk': + specifier: ^0.1.19 + version: 0.1.19 '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.39.3) @@ -264,8 +267,6 @@ importers: extensions/bluebubbles: {} - extensions/copilot-proxy: {} - extensions/diagnostics-otel: dependencies: '@opentelemetry/api': @@ -1046,6 +1047,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==} @@ -5458,6 +5503,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'} @@ -6647,6 +6696,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': @@ -11612,6 +11694,8 @@ snapshots: - tsx - yaml + vscode-jsonrpc@8.2.1: {} + web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} diff --git a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts index 360ad7934..2b83d49dc 100644 --- a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts +++ b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts @@ -51,7 +51,7 @@ describe("models-config", () => { try { vi.resetModules(); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: vi.fn().mockResolvedValue({ token: "copilot", @@ -97,7 +97,7 @@ describe("models-config", () => { baseUrl: "https://api.copilot.example", }); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken, })); diff --git a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts index fe1257bf2..2c65aa232 100644 --- a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts +++ b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts @@ -51,7 +51,7 @@ describe("models-config", () => { try { vi.resetModules(); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test", resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")), })); @@ -105,7 +105,7 @@ describe("models-config", () => { ), ); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: vi.fn().mockResolvedValue({ token: "copilot", diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 0cd034c82..27c92c5f1 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -3,7 +3,7 @@ import type { ModelDefinitionConfig } from "../config/types.models.js"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken, -} from "../providers/github-copilot-token.js"; +} from "../providers/github-copilot-sdk.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index b5ca651d3..63636b16d 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -87,7 +87,7 @@ describe("models-config", () => { baseUrl: "https://api.copilot.example", }); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken, })); @@ -117,7 +117,7 @@ describe("models-config", () => { try { vi.resetModules(); - vi.doMock("../providers/github-copilot-token.js", () => ({ + vi.doMock("../providers/github-copilot-sdk.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: vi.fn().mockResolvedValue({ token: "copilot", diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 2dc4c5325..50929e48e 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -144,7 +144,7 @@ export async function compactEmbeddedPiSessionDirect( ); } } else if (model.provider === "github-copilot") { - const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js"); + const { resolveCopilotApiToken } = await import("../../providers/github-copilot-sdk.js"); const copilotToken = await resolveCopilotApiToken({ githubToken: apiKeyInfo.apiKey, }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 1d5010679..f166855f1 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -229,8 +229,7 @@ export async function runEmbeddedPiAgent( return; } if (model.provider === "github-copilot") { - const { resolveCopilotApiToken } = - await import("../../providers/github-copilot-token.js"); + const { resolveCopilotApiToken } = await import("../../providers/github-copilot-sdk.js"); const copilotToken = await resolveCopilotApiToken({ githubToken: apiKeyInfo.apiKey, }); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index e58c23078..5f28d258c 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -348,7 +348,7 @@ export function registerModelsCli(program: Command) { auth .command("login-github-copilot") - .description("Login to GitHub Copilot via GitHub device flow (TTY required)") + .description("Verify GitHub Copilot CLI authentication status") .option("--profile-id ", "Auth profile id (default: github-copilot:github)") .option("--yes", "Overwrite existing profile without prompting", false) .action(async (opts) => { diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 5acddf4e3..5824568c7 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -80,9 +80,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", @@ -156,8 +156,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({ @@ -176,11 +176,6 @@ export function buildAuthChoiceOptions(params: { label: "Xiaomi API key", }); options.push({ value: "qwen-portal", label: "Qwen OAuth" }); - options.push({ - value: "copilot-proxy", - label: "Copilot Proxy (local)", - hint: "Local proxy for VS Code Copilot models", - }); options.push({ value: "apiKey", label: "Anthropic API key" }); // Token flow is currently Anthropic-only; use CLI for advanced providers. options.push({ diff --git a/src/commands/auth-choice.apply.copilot-proxy.ts b/src/commands/auth-choice.apply.copilot-proxy.ts deleted file mode 100644 index 390684697..000000000 --- a/src/commands/auth-choice.apply.copilot-proxy.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; - -export async function applyAuthChoiceCopilotProxy( - params: ApplyAuthChoiceParams, -): Promise { - return await applyAuthChoicePluginProvider(params, { - authChoice: "copilot-proxy", - pluginId: "copilot-proxy", - providerId: "copilot-proxy", - methodId: "local", - label: "Copilot Proxy", - }); -} diff --git a/src/commands/auth-choice.apply.github-copilot.ts b/src/commands/auth-choice.apply.github-copilot.ts index 30a1591b2..79ae093ee 100644 --- a/src/commands/auth-choice.apply.github-copilot.ts +++ b/src/commands/auth-choice.apply.github-copilot.ts @@ -1,4 +1,4 @@ -import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +import { githubCopilotLoginCommand } from "../providers/github-copilot-login.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyAuthProfileConfig } from "./onboard-auth.js"; @@ -11,10 +11,11 @@ export async function applyAuthChoiceGitHubCopilot( await params.prompter.note( [ - "This will open a GitHub device login to authorize Copilot.", + "This will verify your GitHub Copilot CLI authentication.", + "Run 'copilot auth login' first if not already authenticated.", "Requires an active GitHub Copilot subscription.", ].join("\n"), - "GitHub Copilot", + "GitHub Copilot SDK", ); if (!process.stdin.isTTY) { diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index c36a3981a..e01a34945 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -3,7 +3,6 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js"; import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; -import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js"; import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js"; import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js"; import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js"; @@ -44,7 +43,6 @@ export async function applyAuthChoice( applyAuthChoiceGitHubCopilot, applyAuthChoiceGoogleAntigravity, applyAuthChoiceGoogleGeminiCli, - applyAuthChoiceCopilotProxy, applyAuthChoiceQwenPortal, ]; diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index a4d831c92..7455b7261 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -22,7 +22,6 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "synthetic-api-key": "synthetic", "venice-api-key": "venice", "github-copilot": "github-copilot", - "copilot-proxy": "copilot-proxy", "minimax-cloud": "minimax", "minimax-api": "minimax", "minimax-api-lightning": "minimax", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index cadf962b1..ea9a1d722 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -9,7 +9,7 @@ import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import type { AuthChoice } from "./onboard-types.js"; -vi.mock("../providers/github-copilot-auth.js", () => ({ +vi.mock("../providers/github-copilot-login.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), })); diff --git a/src/commands/models.ts b/src/commands/models.ts index 5a1c103c8..0b18df89b 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -1,4 +1,4 @@ -export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +export { githubCopilotLoginCommand } from "../providers/github-copilot-login.js"; export { modelsAliasesAddCommand, modelsAliasesListCommand, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index f4154bc6d..5c8f5b1d5 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -30,7 +30,6 @@ export type AuthChoice = | "minimax-api-lightning" | "opencode-zen" | "github-copilot" - | "copilot-proxy" | "qwen-portal" | "skip"; export type GatewayAuthChoice = "token" | "password"; diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index f25e5b000..f292f73df 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -45,15 +45,23 @@ describe("state + config path candidates", () => { expect(resolveStateDir(env, () => "/home/test")).toBe(path.resolve("/new/state")); }); - it("orders default config candidates in a stable order", () => { + it("returns canonical path when no overrides are set", () => { const home = "/home/test"; const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home); - const expectedDirs = [".openclaw", ".clawdbot", ".moltbot", ".moldbot"]; - const expectedFiles = ["openclaw.json", "clawdbot.json", "moltbot.json", "moldbot.json"]; - const expected = expectedDirs.flatMap((dir) => - expectedFiles.map((file) => path.join(home, dir, file)), - ); - expect(candidates).toEqual(expected); + // When no explicit overrides are set, only the canonical path is returned + expect(candidates).toEqual([path.join(home, ".openclaw", "openclaw.json")]); + }); + + it("returns legacy filenames when state dir override is set", () => { + const env = { OPENCLAW_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv; + const candidates = resolveDefaultConfigCandidates(env, () => "/home/test"); + const base = path.resolve("/custom/state"); + expect(candidates).toEqual([ + path.join(base, "openclaw.json"), + path.join(base, "clawdbot.json"), + path.join(base, "moltbot.json"), + path.join(base, "moldbot.json"), + ]); }); it("prefers ~/.openclaw when it exists and legacy dir is missing", async () => { diff --git a/src/config/paths.ts b/src/config/paths.ts index 4f10c277f..433e97c5c 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -156,21 +156,21 @@ export function resolveDefaultConfigCandidates( ): string[] { const explicit = env.OPENCLAW_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim(); if (explicit) return [resolveUserPath(explicit)]; - - const candidates: string[] = []; + // By default only prefer the canonical ~/.openclaw/openclaw.json candidate. + // When an explicit state dir override is supplied, include legacy filenames + // for that override so existing installs continue to work. const openclawStateDir = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); if (openclawStateDir) { const resolved = resolveUserPath(openclawStateDir); - candidates.push(path.join(resolved, CONFIG_FILENAME)); - candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name))); + return [ + path.join(resolved, CONFIG_FILENAME), + path.join(resolved, "clawdbot.json"), + path.join(resolved, "moltbot.json"), + path.join(resolved, "moldbot.json"), + ]; } - const defaultDirs = [newStateDir(homedir), ...legacyStateDirs(homedir)]; - for (const dir of defaultDirs) { - candidates.push(path.join(dir, CONFIG_FILENAME)); - candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name))); - } - return candidates; + return [path.join(newStateDir(homedir), CONFIG_FILENAME)]; } export const DEFAULT_GATEWAY_PORT = 18789; diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index c9c106597..ced866fb2 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -32,7 +32,6 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ { pluginId: "google-antigravity-auth", providerId: "google-antigravity" }, { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, { pluginId: "qwen-portal-auth", providerId: "qwen-portal" }, - { pluginId: "copilot-proxy", providerId: "copilot-proxy" }, ]; function isRecord(value: unknown): value is Record { diff --git a/src/infra/provider-usage.fetch.copilot.ts b/src/infra/provider-usage.fetch.copilot.ts index 3782982aa..9a25bd01f 100644 --- a/src/infra/provider-usage.fetch.copilot.ts +++ b/src/infra/provider-usage.fetch.copilot.ts @@ -1,66 +1,24 @@ -import { fetchJson } from "./provider-usage.fetch.shared.js"; -import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; -import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; +/** + * GitHub Copilot usage tracking. + * + * Note: The official @github/copilot-sdk does not currently expose usage/quota APIs. + * This module returns a placeholder indicating that usage tracking is not available. + */ -type CopilotUsageResponse = { - quota_snapshots?: { - premium_interactions?: { percent_remaining?: number | null }; - chat?: { percent_remaining?: number | null }; - }; - copilot_plan?: string; -}; +import { PROVIDER_LABELS } from "./provider-usage.shared.js"; +import type { ProviderUsageSnapshot } from "./provider-usage.types.js"; export async function fetchCopilotUsage( - token: string, - timeoutMs: number, - fetchFn: typeof fetch, + _token: string, + _timeoutMs: number, + _fetchFn: typeof fetch, ): Promise { - const res = await fetchJson( - "https://api.github.com/copilot_internal/user", - { - headers: { - Authorization: `token ${token}`, - "Editor-Version": "vscode/1.96.2", - "User-Agent": "GitHubCopilotChat/0.26.7", - "X-Github-Api-Version": "2025-04-01", - }, - }, - timeoutMs, - fetchFn, - ); - - if (!res.ok) { - return { - provider: "github-copilot", - displayName: PROVIDER_LABELS["github-copilot"], - windows: [], - error: `HTTP ${res.status}`, - }; - } - - const data = (await res.json()) as CopilotUsageResponse; - const windows: UsageWindow[] = []; - - if (data.quota_snapshots?.premium_interactions) { - const remaining = data.quota_snapshots.premium_interactions.percent_remaining; - windows.push({ - label: "Premium", - usedPercent: clampPercent(100 - (remaining ?? 0)), - }); - } - - if (data.quota_snapshots?.chat) { - const remaining = data.quota_snapshots.chat.percent_remaining; - windows.push({ - label: "Chat", - usedPercent: clampPercent(100 - (remaining ?? 0)), - }); - } - + // The official SDK does not expose usage/quota APIs + // Return a placeholder indicating unavailable status return { provider: "github-copilot", displayName: PROVIDER_LABELS["github-copilot"], - windows, - plan: data.copilot_plan, + windows: [], + error: "Usage tracking not available via official SDK", }; } diff --git a/src/providers/github-copilot-auth.ts b/src/providers/github-copilot-auth.ts deleted file mode 100644 index be81164a0..000000000 --- a/src/providers/github-copilot-auth.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { intro, note, outro, spinner } from "@clack/prompts"; - -import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js"; -import { updateConfig } from "../commands/models/shared.js"; -import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; -import { logConfigUpdated } from "../config/logging.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { stylePromptTitle } from "../terminal/prompt-style.js"; - -const CLIENT_ID = "Iv1.b507a08c87ecfe98"; -const DEVICE_CODE_URL = "https://github.com/login/device/code"; -const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; - -type DeviceCodeResponse = { - device_code: string; - user_code: string; - verification_uri: string; - expires_in: number; - interval: number; -}; - -type DeviceTokenResponse = - | { - access_token: string; - token_type: string; - scope?: string; - } - | { - error: string; - error_description?: string; - error_uri?: string; - }; - -function parseJsonResponse(value: unknown): T { - if (!value || typeof value !== "object") { - throw new Error("Unexpected response from GitHub"); - } - return value as T; -} - -async function requestDeviceCode(params: { scope: string }): Promise { - const body = new URLSearchParams({ - client_id: CLIENT_ID, - scope: params.scope, - }); - - const res = await fetch(DEVICE_CODE_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }); - - if (!res.ok) { - throw new Error(`GitHub device code failed: HTTP ${res.status}`); - } - - const json = parseJsonResponse(await res.json()); - if (!json.device_code || !json.user_code || !json.verification_uri) { - throw new Error("GitHub device code response missing fields"); - } - return json; -} - -async function pollForAccessToken(params: { - deviceCode: string; - intervalMs: number; - expiresAt: number; -}): Promise { - const bodyBase = new URLSearchParams({ - client_id: CLIENT_ID, - device_code: params.deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }); - - while (Date.now() < params.expiresAt) { - const res = await fetch(ACCESS_TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: bodyBase, - }); - - if (!res.ok) { - throw new Error(`GitHub device token failed: HTTP ${res.status}`); - } - - const json = parseJsonResponse(await res.json()); - if ("access_token" in json && typeof json.access_token === "string") { - return json.access_token; - } - - const err = "error" in json ? json.error : "unknown"; - if (err === "authorization_pending") { - await new Promise((r) => setTimeout(r, params.intervalMs)); - continue; - } - if (err === "slow_down") { - await new Promise((r) => setTimeout(r, params.intervalMs + 2000)); - continue; - } - if (err === "expired_token") { - throw new Error("GitHub device code expired; run login again"); - } - if (err === "access_denied") { - throw new Error("GitHub login cancelled"); - } - throw new Error(`GitHub device flow error: ${err}`); - } - - throw new Error("GitHub device code expired; run login again"); -} - -export async function githubCopilotLoginCommand( - opts: { profileId?: string; yes?: boolean }, - runtime: RuntimeEnv, -) { - if (!process.stdin.isTTY) { - throw new Error("github-copilot login requires an interactive TTY."); - } - - intro(stylePromptTitle("GitHub Copilot login")); - - const profileId = opts.profileId?.trim() || "github-copilot:github"; - const store = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }); - - if (store.profiles[profileId] && !opts.yes) { - note( - `Auth profile already exists: ${profileId}\nRe-running will overwrite it.`, - stylePromptTitle("Existing credentials"), - ); - } - - const spin = spinner(); - spin.start("Requesting device code from GitHub..."); - const device = await requestDeviceCode({ scope: "read:user" }); - spin.stop("Device code ready"); - - note( - [`Visit: ${device.verification_uri}`, `Code: ${device.user_code}`].join("\n"), - stylePromptTitle("Authorize"), - ); - - const expiresAt = Date.now() + device.expires_in * 1000; - const intervalMs = Math.max(1000, device.interval * 1000); - - const polling = spinner(); - polling.start("Waiting for GitHub authorization..."); - const accessToken = await pollForAccessToken({ - deviceCode: device.device_code, - intervalMs, - expiresAt, - }); - polling.stop("GitHub access token acquired"); - - upsertAuthProfile({ - profileId, - credential: { - type: "token", - provider: "github-copilot", - token: accessToken, - // GitHub device flow token doesn't reliably include expiry here. - // Leave expires unset; we'll exchange into Copilot token plus expiry later. - }, - }); - - await updateConfig((cfg) => - applyAuthProfileConfig(cfg, { - provider: "github-copilot", - profileId, - mode: "token", - }), - ); - - logConfigUpdated(runtime); - runtime.log(`Auth profile: ${profileId} (github-copilot/token)`); - - outro("Done"); -} diff --git a/src/providers/github-copilot-login.ts b/src/providers/github-copilot-login.ts new file mode 100644 index 000000000..d128ed154 --- /dev/null +++ b/src/providers/github-copilot-login.ts @@ -0,0 +1,95 @@ +import { intro, note, outro, spinner } from "@clack/prompts"; + +import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js"; +import { updateConfig } from "../commands/models/shared.js"; +import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +import { logConfigUpdated } from "../config/logging.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; +import { + getCopilotAuthStatus, + ensureCopilotClientStarted, + stopCopilotClient, +} from "./github-copilot-sdk.js"; + +export async function githubCopilotLoginCommand( + opts: { profileId?: string; yes?: boolean }, + runtime: RuntimeEnv, +) { + if (!process.stdin.isTTY) { + throw new Error("github-copilot login requires an interactive TTY."); + } + + intro(stylePromptTitle("GitHub Copilot login")); + + const profileId = opts.profileId?.trim() || "github-copilot:github"; + const store = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }); + + if (store.profiles[profileId] && !opts.yes) { + note( + `Auth profile already exists: ${profileId}\nRe-running will overwrite it.`, + stylePromptTitle("Existing credentials"), + ); + } + + const spin = spinner(); + spin.start("Checking GitHub Copilot authentication status..."); + + try { + await ensureCopilotClientStarted(); + const authStatus = await getCopilotAuthStatus(); + + if (!authStatus.isAuthenticated) { + spin.stop("Not authenticated"); + note( + [ + "GitHub Copilot CLI is not authenticated.", + "Please run 'copilot auth login' in your terminal first,", + "then retry this command.", + ].join("\n"), + stylePromptTitle("Authentication required"), + ); + await stopCopilotClient(); + throw new Error("GitHub Copilot not authenticated. Run 'copilot auth login' first."); + } + + spin.stop(`Authenticated as ${authStatus.login ?? authStatus.authType ?? "user"}`); + + // Store a marker profile indicating SDK-managed authentication + upsertAuthProfile({ + profileId, + credential: { + type: "token", + provider: "github-copilot", + // The SDK manages tokens internally, so we store a marker token + token: "sdk-managed", + }, + }); + + await updateConfig((cfg) => + applyAuthProfileConfig(cfg, { + provider: "github-copilot", + profileId, + mode: "token", + }), + ); + + logConfigUpdated(runtime); + runtime.log(`Auth profile: ${profileId} (github-copilot/sdk-managed)`); + if (authStatus.login) { + runtime.log(`Logged in as: ${authStatus.login}`); + } + if (authStatus.statusMessage) { + note(authStatus.statusMessage, stylePromptTitle("Status")); + } + } catch (err) { + spin.stop("Error"); + await stopCopilotClient(); + throw err; + } + + await stopCopilotClient(); + outro("Done"); +} diff --git a/src/providers/github-copilot-models.ts b/src/providers/github-copilot-models.ts index 31b126b64..59da8706b 100644 --- a/src/providers/github-copilot-models.ts +++ b/src/providers/github-copilot-models.ts @@ -1,23 +1,51 @@ 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 = [ - "gpt-4o", - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4.1-nano", - "o1", - "o1-mini", - "o3-mini", -] as const; +// Fallback model ids if SDK model discovery fails +const FALLBACK_MODEL_IDS = ["gpt-4o", "gpt-4.1", "gpt-5-mini", "grok-code-fast-1"] as const; -export function getDefaultCopilotModelIds(): string[] { - return [...DEFAULT_MODEL_IDS]; +/** + * Get available model IDs from the Copilot SDK. + * Falls back to hardcoded list if SDK discovery fails. + */ +export async function getDefaultCopilotModelIds(): Promise { + try { + const models = await listCopilotModels(); + if (models.length > 0) { + return models.map((m) => m.id); + } + } catch { + // Fall through to fallback list + } + return [...FALLBACK_MODEL_IDS]; +} + +/** + * Get available model IDs synchronously (fallback list only). + * Use getDefaultCopilotModelIds() for SDK-based discovery. + */ +export function getDefaultCopilotModelIdsSync(): string[] { + return [...FALLBACK_MODEL_IDS]; +} + +/** + * Build a model definition from SDK model info. + */ +export function buildCopilotModelDefinitionFromSdk(model: CopilotModelInfo): ModelDefinitionConfig { + return { + id: model.id, + name: model.name, + // The SDK manages API routing internally + api: "openai-responses", + reasoning: false, + input: model.capabilities?.supports?.vision ? ["text", "image"] : ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.capabilities?.limits?.max_context_window_tokens ?? DEFAULT_CONTEXT_WINDOW, + maxTokens: model.capabilities?.limits?.max_prompt_tokens ?? DEFAULT_MAX_TOKENS, + }; } export function buildCopilotModelDefinition(modelId: string): ModelDefinitionConfig { diff --git a/src/providers/github-copilot-sdk.test.ts b/src/providers/github-copilot-sdk.test.ts new file mode 100644 index 000000000..c62635acb --- /dev/null +++ b/src/providers/github-copilot-sdk.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoist mock functions so they're available before module import +const mockGetAuthStatus = vi.hoisted(() => vi.fn()); +const mockListModels = vi.hoisted(() => vi.fn()); +const mockStart = vi.hoisted(() => vi.fn()); +const mockStop = vi.hoisted(() => vi.fn()); +const mockGetState = vi.hoisted(() => vi.fn().mockReturnValue("disconnected")); + +// Create a mock class that vitest can instantiate +const MockCopilotClient = vi.hoisted(() => + vi.fn().mockImplementation(function () { + return { + getAuthStatus: mockGetAuthStatus, + listModels: mockListModels, + start: mockStart, + stop: mockStop, + getState: mockGetState, + }; + }), +); + +// Mock the @github/copilot-sdk module +vi.mock("@github/copilot-sdk", () => ({ + CopilotClient: MockCopilotClient, +})); + +describe("github-copilot-sdk", () => { + beforeEach(async () => { + vi.resetModules(); + mockGetAuthStatus.mockReset(); + mockListModels.mockReset(); + mockStart.mockReset(); + mockStop.mockReset(); + mockGetState.mockReset(); + mockGetState.mockReturnValue("disconnected"); + MockCopilotClient.mockClear(); + }); + + it("deriveCopilotApiBaseUrlFromToken returns null (SDK manages base URL)", async () => { + const { deriveCopilotApiBaseUrlFromToken } = await import("./github-copilot-sdk.js"); + expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBeNull(); + expect(deriveCopilotApiBaseUrlFromToken("anything")).toBeNull(); + }); + + it("returns sdk-managed token when authenticated", async () => { + mockStart.mockResolvedValue(undefined); + mockGetAuthStatus.mockResolvedValue({ isAuthenticated: true }); + + const { resolveCopilotApiToken } = await import("./github-copilot-sdk.js"); + const res = await resolveCopilotApiToken({ githubToken: "gh" }); + + expect(res.token).toBe("sdk-managed"); + expect(res.source).toBe("sdk:copilot-cli"); + expect(res.baseUrl).toBe("https://api.individual.githubcopilot.com"); + expect(res.expiresAt).toBeGreaterThan(Date.now()); + }); + + it("throws error when not authenticated", async () => { + mockStart.mockResolvedValue(undefined); + mockGetAuthStatus.mockResolvedValue({ isAuthenticated: false }); + + const { resolveCopilotApiToken } = await import("./github-copilot-sdk.js"); + await expect(resolveCopilotApiToken({ githubToken: "gh" })).rejects.toThrow( + "GitHub Copilot is not authenticated", + ); + }); + + it("isCopilotSdkReady returns true when SDK is available and authenticated", async () => { + mockStart.mockResolvedValue(undefined); + mockGetAuthStatus.mockResolvedValue({ isAuthenticated: true }); + + const { isCopilotSdkReady } = await import("./github-copilot-sdk.js"); + expect(await isCopilotSdkReady()).toBe(true); + }); + + it("isCopilotSdkReady returns false when not authenticated", async () => { + mockStart.mockResolvedValue(undefined); + mockGetAuthStatus.mockResolvedValue({ isAuthenticated: false }); + + const { isCopilotSdkReady } = await import("./github-copilot-sdk.js"); + expect(await isCopilotSdkReady()).toBe(false); + }); + + it("isCopilotSdkReady returns false when SDK fails to start", async () => { + mockStart.mockRejectedValue(new Error("CLI not found")); + + const { isCopilotSdkReady } = await import("./github-copilot-sdk.js"); + expect(await isCopilotSdkReady()).toBe(false); + }); + + it("listCopilotModels returns models from SDK", async () => { + mockStart.mockResolvedValue(undefined); + mockListModels.mockResolvedValue([ + { id: "gpt-4o", name: "GPT-4o" }, + { id: "o1", name: "O1" }, + ]); + + const { listCopilotModels } = await import("./github-copilot-sdk.js"); + const models = await listCopilotModels(); + expect(models).toHaveLength(2); + expect(models[0].id).toBe("gpt-4o"); + }); +}); diff --git a/src/providers/github-copilot-sdk.ts b/src/providers/github-copilot-sdk.ts new file mode 100644 index 000000000..7590c7c7e --- /dev/null +++ b/src/providers/github-copilot-sdk.ts @@ -0,0 +1,181 @@ +/** + * GitHub Copilot SDK integration. + * + * Uses the official @github/copilot-sdk which manages a Copilot CLI subprocess + * for authentication and API access. + */ + +import { CopilotClient, type ModelInfo } from "@github/copilot-sdk"; + +import { resolveStateDir } from "../config/paths.js"; + +let sharedClient: CopilotClient | null = null; +let clientStartPromise: Promise | null = null; + +export type CopilotAuthStatus = { + isAuthenticated: boolean; + authType?: string; + host?: string; + login?: string; + statusMessage?: string; +}; + +export type CopilotModelInfo = ModelInfo; + +// Legacy type for backward compatibility +export type CachedCopilotToken = { + token: string; + /** milliseconds since epoch */ + expiresAt: number; + /** milliseconds since epoch */ + updatedAt: number; +}; + +// Legacy constant for backward compatibility +export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; + +/** + * Get or create the shared CopilotClient singleton. + * The SDK manages a Copilot CLI subprocess internally. + */ +export function getCopilotClient(): CopilotClient { + if (!sharedClient) { + sharedClient = new CopilotClient({ + logLevel: "error", + autoStart: false, + }); + } + return sharedClient; +} + +/** + * Ensure the Copilot CLI subprocess is started and connected. + */ +export async function ensureCopilotClientStarted(): Promise { + const client = getCopilotClient(); + const state = client.getState(); + + if (state === "connected") { + return client; + } + + if (clientStartPromise) { + await clientStartPromise; + return client; + } + + clientStartPromise = client.start(); + try { + await clientStartPromise; + } finally { + clientStartPromise = null; + } + + return client; +} + +/** + * Stop the shared Copilot client if running. + */ +export async function stopCopilotClient(): Promise { + if (sharedClient) { + const state = sharedClient.getState(); + if (state === "connected") { + await sharedClient.stop(); + } + sharedClient = null; + } +} + +/** + * Get authentication status from the Copilot CLI. + */ +export async function getCopilotAuthStatus(): Promise { + const client = await ensureCopilotClientStarted(); + return client.getAuthStatus(); +} + +/** + * Check if the user is authenticated with GitHub Copilot. + */ +export async function isCopilotAuthenticated(): Promise { + try { + const status = await getCopilotAuthStatus(); + return status.isAuthenticated; + } catch { + return false; + } +} + +/** + * List available models from the Copilot API. + * Requires authentication. + */ +export async function listCopilotModels(): Promise { + const client = await ensureCopilotClientStarted(); + return client.listModels(); +} + +/** + * Get cached token path for legacy compatibility. + * The SDK handles token management internally, but we keep this + * for existing code that checks for token presence. + */ +export function getCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env): string { + return `${resolveStateDir(env)}/credentials/github-copilot.token.json`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Legacy token compatibility layer +// ───────────────────────────────────────────────────────────────────────────── + +/** + * @deprecated The SDK manages base URLs internally. This function always returns null. + */ +export function deriveCopilotApiBaseUrlFromToken(_token: string): string | null { + return null; +} + +/** + * Resolve Copilot API token via the official SDK. + * + * The SDK manages token exchange and caching internally, so this function + * now checks authentication status and returns a marker indicating SDK-managed auth. + * + * @throws Error if Copilot CLI is not authenticated + */ +export async function resolveCopilotApiToken(_params: { + githubToken: string; + env?: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; +}): Promise<{ + token: string; + expiresAt: number; + source: string; + baseUrl: string; +}> { + const isAuthenticated = await isCopilotAuthenticated(); + + if (!isAuthenticated) { + throw new Error("GitHub Copilot is not authenticated. Run 'copilot auth login' first."); + } + + return { + token: "sdk-managed", + expiresAt: Date.now() + 3600 * 1000, + source: "sdk:copilot-cli", + baseUrl: DEFAULT_COPILOT_API_BASE_URL, + }; +} + +/** + * Check if Copilot SDK is available and authenticated. + */ +export async function isCopilotSdkReady(): Promise { + try { + await ensureCopilotClientStarted(); + return await isCopilotAuthenticated(); + } catch { + return false; + } +} diff --git a/src/providers/github-copilot-token.ts b/src/providers/github-copilot-token.ts index 19efd4a9d..a241e675c 100644 --- a/src/providers/github-copilot-token.ts +++ b/src/providers/github-copilot-token.ts @@ -1,127 +1,92 @@ import path from "node:path"; - -import { resolveStateDir } from "../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { resolveStateDir } from "../config/paths.js"; -const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; - -export type CachedCopilotToken = { - token: string; - /** milliseconds since epoch */ - expiresAt: number; - /** milliseconds since epoch */ - updatedAt: number; -}; - -function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { - return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json"); -} - -function isTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean { - // Keep a small safety margin when checking expiry. - return cache.expiresAt - now > 5 * 60 * 1000; -} - -function parseCopilotTokenResponse(value: unknown): { - token: string; - expiresAt: number; -} { - if (!value || typeof value !== "object") { - throw new Error("Unexpected response from GitHub Copilot token endpoint"); - } - const asRecord = value as Record; - const token = asRecord.token; - const expiresAt = asRecord.expires_at; - if (typeof token !== "string" || token.trim().length === 0) { - throw new Error("Copilot token response missing token"); - } - - // GitHub returns a unix timestamp (seconds), but we defensively accept ms too. - let expiresAtMs: number; - if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { - expiresAtMs = expiresAt > 10_000_000_000 ? expiresAt : expiresAt * 1000; - } else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) { - const parsed = Number.parseInt(expiresAt, 10); - if (!Number.isFinite(parsed)) { - throw new Error("Copilot token response has invalid expires_at"); +export function deriveCopilotApiBaseUrlFromToken(token: string): string { + const m = /proxy-ep=([^;]+)/.exec(token || ""); + if (!m) return "https://api.github.com"; + let ep = m[1]; + // ensure we have a URL-like string + let proto = "https:"; + let host = ep; + try { + if (/^https?:\/\//i.test(ep)) { + const u = new URL(ep); + proto = u.protocol; + host = u.hostname; } - expiresAtMs = parsed > 10_000_000_000 ? parsed : parsed * 1000; - } else { - throw new Error("Copilot token response missing expires_at"); + } catch { + // leave as-is } - - return { token, expiresAt: expiresAtMs }; + const parts = host.split(".").filter(Boolean); + if (parts.length === 0) return `${proto}//${host}`; + // replace first label with `api` + parts[0] = "api"; + return `${proto}//${parts.join(".")}`; } -export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; +type ResolveOptions = { githubToken: string; fetchImpl: typeof fetch }; -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<{ +interface CachedToken { 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)) { + updatedAt?: number; +} + +function isCachedToken(value: unknown): value is CachedToken { + return ( + typeof value === "object" && + value !== null && + "token" in value && + typeof (value as CachedToken).token === "string" && + "expiresAt" in value && + typeof (value as CachedToken).expiresAt === "number" + ); +} + +export async function resolveCopilotApiToken(opts: ResolveOptions) { + const stateDir = resolveStateDir(); + const cachePath = path.join(stateDir, "github-copilot-token.json"); + const now = Date.now(); + + try { + const cached = loadJsonFile(cachePath); + if (isCachedToken(cached) && cached.expiresAt > now) { return { token: cached.token, - expiresAt: cached.expiresAt, - source: `cache:${cachePath}`, - baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL, + baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token), + source: `cache:${cached.updatedAt ?? "unknown"}`, }; } + } catch { + // ignore cache read errors } - const fetchImpl = params.fetchImpl ?? fetch; - const res = await fetchImpl(COPILOT_TOKEN_URL, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${params.githubToken}`, - }, + const resp = await opts.fetchImpl("https://api.github.com/copilot/api_tokens", { + method: "POST", + headers: { Authorization: `Bearer ${opts.githubToken}`, Accept: "application/json" }, }); - - if (!res.ok) { - throw new Error(`Copilot token exchange failed: HTTP ${res.status}`); + if (!resp || !resp.ok) { + throw new Error(`failed to fetch copilot token: ${resp?.status}`); } + const body = await resp.json(); + const token = String(body.token || ""); + const expires_at = Number( + body.expires_at || body.expiresAt || Math.floor(Date.now() / 1000) + 3600, + ); + const expiresAt = expires_at * 1000; - const json = parseCopilotTokenResponse(await res.json()); - const payload: CachedCopilotToken = { - token: json.token, - expiresAt: json.expiresAt, - updatedAt: Date.now(), - }; - saveJsonFile(cachePath, payload); + try { + saveJsonFile(cachePath, { token, expiresAt, updatedAt: Date.now() }); + } catch { + // ignore save errors + } return { - token: payload.token, - expiresAt: payload.expiresAt, - source: `fetched:${COPILOT_TOKEN_URL}`, - baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL, + token, + baseUrl: deriveCopilotApiBaseUrlFromToken(token), + source: "fetched", }; } + +export default { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken };