This commit is contained in:
Aditya Mer 2026-01-30 11:15:57 -05:00 committed by GitHub
commit dcdca73275
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 654 additions and 645 deletions

6
.github/labeler.yml vendored
View File

@ -180,10 +180,12 @@
- "docs/cli/security.md" - "docs/cli/security.md"
- "docs/gateway/security.md" - "docs/gateway/security.md"
"extensions: copilot-proxy": "provider: github-copilot":
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
- "extensions/copilot-proxy/**" - "src/providers/github-copilot-*.ts"
- "docs/providers/github-copilot.md"
"extensions: diagnostics-otel": "extensions: diagnostics-otel":
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:

View File

@ -854,15 +854,6 @@
"line_number": 214 "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": [ "extensions/google-antigravity-auth/index.ts": [
{ {
"type": "Base64 High Entropy String", "type": "Base64 High Entropy String",

View File

@ -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) - 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) - 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) - 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 OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. **Config
validation does not execute plugin code**; it uses the plugin manifest and JSON validation does not execute plugin code**; it uses the plugin manifest and JSON

View File

@ -4,41 +4,30 @@ read_when:
- You want to use GitHub Copilot as a model provider - You want to use GitHub Copilot as a model provider
- You need the `openclaw models auth login-github-copilot` flow - You need the `openclaw models auth login-github-copilot` flow
--- ---
# Github Copilot # GitHub Copilot
## What is GitHub Copilot? ## What is GitHub Copilot?
GitHub Copilot is GitHub's AI coding assistant. It provides access to 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 models for your GitHub account and plan. OpenClaw can use Copilot as a model
provider in two different ways. 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.
## Two ways to use Copilot in OpenClaw This is the recommended and supported path for integrating Copilot with
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 proxys `/v1` endpoint and uses the model list you configure there. Choose
this when you already run Copilot Proxy in VS Code or need to route through it.
You must enable the plugin and keep the VS Code extension running.
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs 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 the GitHub device flow, saves an auth profile, and updates your config to use
profile. that profile.
## CLI setup ## CLI setup
Run the OpenClaw login flow to create an auth profile:
```bash ```bash
openclaw models auth login-github-copilot openclaw models auth login-github-copilot
``` ```
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal This creates an auth profile and (optionally) updates your config to use it.
open until it completes.
### Optional flags ### Optional flags

View File

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

View File

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

View File

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

View File

@ -1,11 +0,0 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.1.29",
"type": "module",
"description": "OpenClaw Copilot Proxy provider plugin",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -158,6 +158,7 @@
"@aws-sdk/client-bedrock": "^3.975.0", "@aws-sdk/client-bedrock": "^3.975.0",
"@buape/carbon": "0.14.0", "@buape/carbon": "0.14.0",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@github/copilot-sdk": "^0.1.19",
"@grammyjs/runner": "^2.0.3", "@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1", "@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4", "@homebridge/ciao": "^1.3.4",

88
pnpm-lock.yaml generated
View File

@ -25,6 +25,9 @@ importers:
'@clack/prompts': '@clack/prompts':
specifier: ^0.11.0 specifier: ^0.11.0
version: 0.11.0 version: 0.11.0
'@github/copilot-sdk':
specifier: ^0.1.19
version: 0.1.19
'@grammyjs/runner': '@grammyjs/runner':
specifier: ^2.0.3 specifier: ^2.0.3
version: 2.0.3(grammy@1.39.3) version: 2.0.3(grammy@1.39.3)
@ -264,8 +267,6 @@ importers:
extensions/bluebubbles: {} extensions/bluebubbles: {}
extensions/copilot-proxy: {}
extensions/diagnostics-otel: extensions/diagnostics-otel:
dependencies: dependencies:
'@opentelemetry/api': '@opentelemetry/api':
@ -1046,6 +1047,50 @@ packages:
'@eshaz/web-worker@1.2.2': '@eshaz/web-worker@1.2.2':
resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==} 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': '@glideapps/ts-necessities@2.2.3':
resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==} resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==}
@ -5458,6 +5503,10 @@ packages:
jsdom: jsdom:
optional: true optional: true
vscode-jsonrpc@8.2.1:
resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==}
engines: {node: '>=14.0.0'}
web-streams-polyfill@3.3.3: web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -6647,6 +6696,39 @@ snapshots:
'@eshaz/web-worker@1.2.2': '@eshaz/web-worker@1.2.2':
optional: true 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': {} '@glideapps/ts-necessities@2.2.3': {}
'@google/genai@1.34.0': '@google/genai@1.34.0':
@ -11612,6 +11694,8 @@ snapshots:
- tsx - tsx
- yaml - yaml
vscode-jsonrpc@8.2.1: {}
web-streams-polyfill@3.3.3: {} web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}

View File

@ -51,7 +51,7 @@ describe("models-config", () => {
try { try {
vi.resetModules(); 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", DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({ resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot", token: "copilot",
@ -97,7 +97,7 @@ describe("models-config", () => {
baseUrl: "https://api.copilot.example", 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", DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken, resolveCopilotApiToken,
})); }));

View File

@ -51,7 +51,7 @@ describe("models-config", () => {
try { try {
vi.resetModules(); 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", DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")), 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", DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({ resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot", token: "copilot",

View File

@ -3,7 +3,7 @@ import type { ModelDefinitionConfig } from "../config/types.models.js";
import { import {
DEFAULT_COPILOT_API_BASE_URL, DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken, resolveCopilotApiToken,
} from "../providers/github-copilot-token.js"; } from "../providers/github-copilot-sdk.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import { discoverBedrockModels } from "./bedrock-discovery.js"; import { discoverBedrockModels } from "./bedrock-discovery.js";

View File

@ -87,7 +87,7 @@ describe("models-config", () => {
baseUrl: "https://api.copilot.example", 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", DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken, resolveCopilotApiToken,
})); }));
@ -117,7 +117,7 @@ describe("models-config", () => {
try { try {
vi.resetModules(); 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", DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({ resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot", token: "copilot",

View File

@ -144,7 +144,7 @@ export async function compactEmbeddedPiSessionDirect(
); );
} }
} else if (model.provider === "github-copilot") { } 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({ const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey, githubToken: apiKeyInfo.apiKey,
}); });

View File

@ -229,8 +229,7 @@ export async function runEmbeddedPiAgent(
return; return;
} }
if (model.provider === "github-copilot") { if (model.provider === "github-copilot") {
const { resolveCopilotApiToken } = const { resolveCopilotApiToken } = await import("../../providers/github-copilot-sdk.js");
await import("../../providers/github-copilot-token.js");
const copilotToken = await resolveCopilotApiToken({ const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey, githubToken: apiKeyInfo.apiKey,
}); });

View File

@ -348,7 +348,7 @@ export function registerModelsCli(program: Command) {
auth auth
.command("login-github-copilot") .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("--profile-id <id>", "Auth profile id (default: github-copilot:github)")
.option("--yes", "Overwrite existing profile without prompting", false) .option("--yes", "Overwrite existing profile without prompting", false)
.action(async (opts) => { .action(async (opts) => {

View File

@ -80,9 +80,9 @@ const AUTH_CHOICE_GROUP_DEFS: {
}, },
{ {
value: "copilot", value: "copilot",
label: "Copilot", label: "GitHub Copilot",
hint: "GitHub + local proxy", hint: "Official SDK (via Copilot CLI)",
choices: ["github-copilot", "copilot-proxy"], choices: ["github-copilot"],
}, },
{ {
value: "openrouter", value: "openrouter",
@ -156,8 +156,8 @@ export function buildAuthChoiceOptions(params: {
}); });
options.push({ options.push({
value: "github-copilot", value: "github-copilot",
label: "GitHub Copilot (GitHub device login)", label: "GitHub Copilot (SDK)",
hint: "Uses GitHub device flow", hint: "Uses official GitHub Copilot SDK",
}); });
options.push({ value: "gemini-api-key", label: "Google Gemini API key" }); options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({ options.push({
@ -176,11 +176,6 @@ export function buildAuthChoiceOptions(params: {
label: "Xiaomi API key", label: "Xiaomi API key",
}); });
options.push({ value: "qwen-portal", label: "Qwen OAuth" }); 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" }); options.push({ value: "apiKey", label: "Anthropic API key" });
// Token flow is currently Anthropic-only; use CLI for advanced providers. // Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({ options.push({

View File

@ -1,14 +0,0 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
export async function applyAuthChoiceCopilotProxy(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "copilot-proxy",
pluginId: "copilot-proxy",
providerId: "copilot-proxy",
methodId: "local",
label: "Copilot Proxy",
});
}

View File

@ -1,4 +1,4 @@
import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; import { githubCopilotLoginCommand } from "../providers/github-copilot-login.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthProfileConfig } from "./onboard-auth.js"; import { applyAuthProfileConfig } from "./onboard-auth.js";
@ -11,10 +11,11 @@ export async function applyAuthChoiceGitHubCopilot(
await params.prompter.note( 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.", "Requires an active GitHub Copilot subscription.",
].join("\n"), ].join("\n"),
"GitHub Copilot", "GitHub Copilot SDK",
); );
if (!process.stdin.isTTY) { if (!process.stdin.isTTY) {

View File

@ -3,7 +3,6 @@ import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js"; import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js"; import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.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 { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js"; import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js";
import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js"; import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js";
@ -44,7 +43,6 @@ export async function applyAuthChoice(
applyAuthChoiceGitHubCopilot, applyAuthChoiceGitHubCopilot,
applyAuthChoiceGoogleAntigravity, applyAuthChoiceGoogleAntigravity,
applyAuthChoiceGoogleGeminiCli, applyAuthChoiceGoogleGeminiCli,
applyAuthChoiceCopilotProxy,
applyAuthChoiceQwenPortal, applyAuthChoiceQwenPortal,
]; ];

View File

@ -22,7 +22,6 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"synthetic-api-key": "synthetic", "synthetic-api-key": "synthetic",
"venice-api-key": "venice", "venice-api-key": "venice",
"github-copilot": "github-copilot", "github-copilot": "github-copilot",
"copilot-proxy": "copilot-proxy",
"minimax-cloud": "minimax", "minimax-cloud": "minimax",
"minimax-api": "minimax", "minimax-api": "minimax",
"minimax-api-lightning": "minimax", "minimax-api-lightning": "minimax",

View File

@ -9,7 +9,7 @@ import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
import type { AuthChoice } from "./onboard-types.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 () => {}), githubCopilotLoginCommand: vi.fn(async () => {}),
})); }));

View File

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

View File

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

View File

@ -45,15 +45,23 @@ describe("state + config path candidates", () => {
expect(resolveStateDir(env, () => "/home/test")).toBe(path.resolve("/new/state")); 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 home = "/home/test";
const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home); const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home);
const expectedDirs = [".openclaw", ".clawdbot", ".moltbot", ".moldbot"]; // When no explicit overrides are set, only the canonical path is returned
const expectedFiles = ["openclaw.json", "clawdbot.json", "moltbot.json", "moldbot.json"]; expect(candidates).toEqual([path.join(home, ".openclaw", "openclaw.json")]);
const expected = expectedDirs.flatMap((dir) => });
expectedFiles.map((file) => path.join(home, dir, file)),
); it("returns legacy filenames when state dir override is set", () => {
expect(candidates).toEqual(expected); 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 () => { it("prefers ~/.openclaw when it exists and legacy dir is missing", async () => {

View File

@ -156,21 +156,21 @@ export function resolveDefaultConfigCandidates(
): string[] { ): string[] {
const explicit = env.OPENCLAW_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim(); const explicit = env.OPENCLAW_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim();
if (explicit) return [resolveUserPath(explicit)]; if (explicit) return [resolveUserPath(explicit)];
// By default only prefer the canonical ~/.openclaw/openclaw.json candidate.
const candidates: string[] = []; // 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(); const openclawStateDir = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (openclawStateDir) { if (openclawStateDir) {
const resolved = resolveUserPath(openclawStateDir); const resolved = resolveUserPath(openclawStateDir);
candidates.push(path.join(resolved, CONFIG_FILENAME)); return [
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name))); 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)]; return [path.join(newStateDir(homedir), CONFIG_FILENAME)];
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;
} }
export const DEFAULT_GATEWAY_PORT = 18789; export const DEFAULT_GATEWAY_PORT = 18789;

View File

@ -32,7 +32,6 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
{ pluginId: "google-antigravity-auth", providerId: "google-antigravity" }, { pluginId: "google-antigravity-auth", providerId: "google-antigravity" },
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
{ pluginId: "qwen-portal-auth", providerId: "qwen-portal" }, { pluginId: "qwen-portal-auth", providerId: "qwen-portal" },
{ pluginId: "copilot-proxy", providerId: "copilot-proxy" },
]; ];
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {

View File

@ -1,66 +1,24 @@
import { fetchJson } from "./provider-usage.fetch.shared.js"; /**
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; * GitHub Copilot usage tracking.
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; *
* 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 = { import { PROVIDER_LABELS } from "./provider-usage.shared.js";
quota_snapshots?: { import type { ProviderUsageSnapshot } from "./provider-usage.types.js";
premium_interactions?: { percent_remaining?: number | null };
chat?: { percent_remaining?: number | null };
};
copilot_plan?: string;
};
export async function fetchCopilotUsage( export async function fetchCopilotUsage(
token: string, _token: string,
timeoutMs: number, _timeoutMs: number,
fetchFn: typeof fetch, _fetchFn: typeof fetch,
): Promise<ProviderUsageSnapshot> { ): Promise<ProviderUsageSnapshot> {
const res = await fetchJson( // The official SDK does not expose usage/quota APIs
"https://api.github.com/copilot_internal/user", // Return a placeholder indicating unavailable status
{
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)),
});
}
return { return {
provider: "github-copilot", provider: "github-copilot",
displayName: PROVIDER_LABELS["github-copilot"], displayName: PROVIDER_LABELS["github-copilot"],
windows, windows: [],
plan: data.copilot_plan, error: "Usage tracking not available via official SDK",
}; };
} }

View File

@ -1,185 +0,0 @@
import { intro, note, outro, spinner } from "@clack/prompts";
import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js";
import { updateConfig } from "../commands/models/shared.js";
import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
import { logConfigUpdated } from "../config/logging.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
type DeviceCodeResponse = {
device_code: string;
user_code: string;
verification_uri: string;
expires_in: number;
interval: number;
};
type DeviceTokenResponse =
| {
access_token: string;
token_type: string;
scope?: string;
}
| {
error: string;
error_description?: string;
error_uri?: string;
};
function parseJsonResponse<T>(value: unknown): T {
if (!value || typeof value !== "object") {
throw new Error("Unexpected response from GitHub");
}
return value as T;
}
async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> {
const body = new URLSearchParams({
client_id: CLIENT_ID,
scope: params.scope,
});
const res = await fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!res.ok) {
throw new Error(`GitHub device code failed: HTTP ${res.status}`);
}
const json = parseJsonResponse<DeviceCodeResponse>(await res.json());
if (!json.device_code || !json.user_code || !json.verification_uri) {
throw new Error("GitHub device code response missing fields");
}
return json;
}
async function pollForAccessToken(params: {
deviceCode: string;
intervalMs: number;
expiresAt: number;
}): Promise<string> {
const bodyBase = new URLSearchParams({
client_id: CLIENT_ID,
device_code: params.deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
while (Date.now() < params.expiresAt) {
const res = await fetch(ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: bodyBase,
});
if (!res.ok) {
throw new Error(`GitHub device token failed: HTTP ${res.status}`);
}
const json = parseJsonResponse<DeviceTokenResponse>(await res.json());
if ("access_token" in json && typeof json.access_token === "string") {
return json.access_token;
}
const err = "error" in json ? json.error : "unknown";
if (err === "authorization_pending") {
await new Promise((r) => setTimeout(r, params.intervalMs));
continue;
}
if (err === "slow_down") {
await new Promise((r) => setTimeout(r, params.intervalMs + 2000));
continue;
}
if (err === "expired_token") {
throw new Error("GitHub device code expired; run login again");
}
if (err === "access_denied") {
throw new Error("GitHub login cancelled");
}
throw new Error(`GitHub device flow error: ${err}`);
}
throw new Error("GitHub device code expired; run login again");
}
export async function githubCopilotLoginCommand(
opts: { profileId?: string; yes?: boolean },
runtime: RuntimeEnv,
) {
if (!process.stdin.isTTY) {
throw new Error("github-copilot login requires an interactive TTY.");
}
intro(stylePromptTitle("GitHub Copilot login"));
const profileId = opts.profileId?.trim() || "github-copilot:github";
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (store.profiles[profileId] && !opts.yes) {
note(
`Auth profile already exists: ${profileId}\nRe-running will overwrite it.`,
stylePromptTitle("Existing credentials"),
);
}
const spin = spinner();
spin.start("Requesting device code from GitHub...");
const device = await requestDeviceCode({ scope: "read:user" });
spin.stop("Device code ready");
note(
[`Visit: ${device.verification_uri}`, `Code: ${device.user_code}`].join("\n"),
stylePromptTitle("Authorize"),
);
const expiresAt = Date.now() + device.expires_in * 1000;
const intervalMs = Math.max(1000, device.interval * 1000);
const polling = spinner();
polling.start("Waiting for GitHub authorization...");
const accessToken = await pollForAccessToken({
deviceCode: device.device_code,
intervalMs,
expiresAt,
});
polling.stop("GitHub access token acquired");
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider: "github-copilot",
token: accessToken,
// GitHub device flow token doesn't reliably include expiry here.
// Leave expires unset; we'll exchange into Copilot token plus expiry later.
},
});
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, {
provider: "github-copilot",
profileId,
mode: "token",
}),
);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (github-copilot/token)`);
outro("Done");
}

View File

@ -0,0 +1,95 @@
import { intro, note, outro, spinner } from "@clack/prompts";
import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js";
import { updateConfig } from "../commands/models/shared.js";
import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
import { logConfigUpdated } from "../config/logging.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import {
getCopilotAuthStatus,
ensureCopilotClientStarted,
stopCopilotClient,
} from "./github-copilot-sdk.js";
export async function githubCopilotLoginCommand(
opts: { profileId?: string; yes?: boolean },
runtime: RuntimeEnv,
) {
if (!process.stdin.isTTY) {
throw new Error("github-copilot login requires an interactive TTY.");
}
intro(stylePromptTitle("GitHub Copilot login"));
const profileId = opts.profileId?.trim() || "github-copilot:github";
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (store.profiles[profileId] && !opts.yes) {
note(
`Auth profile already exists: ${profileId}\nRe-running will overwrite it.`,
stylePromptTitle("Existing credentials"),
);
}
const spin = spinner();
spin.start("Checking GitHub Copilot authentication status...");
try {
await ensureCopilotClientStarted();
const authStatus = await getCopilotAuthStatus();
if (!authStatus.isAuthenticated) {
spin.stop("Not authenticated");
note(
[
"GitHub Copilot CLI is not authenticated.",
"Please run 'copilot auth login' in your terminal first,",
"then retry this command.",
].join("\n"),
stylePromptTitle("Authentication required"),
);
await stopCopilotClient();
throw new Error("GitHub Copilot not authenticated. Run 'copilot auth login' first.");
}
spin.stop(`Authenticated as ${authStatus.login ?? authStatus.authType ?? "user"}`);
// Store a marker profile indicating SDK-managed authentication
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider: "github-copilot",
// The SDK manages tokens internally, so we store a marker token
token: "sdk-managed",
},
});
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, {
provider: "github-copilot",
profileId,
mode: "token",
}),
);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (github-copilot/sdk-managed)`);
if (authStatus.login) {
runtime.log(`Logged in as: ${authStatus.login}`);
}
if (authStatus.statusMessage) {
note(authStatus.statusMessage, stylePromptTitle("Status"));
}
} catch (err) {
spin.stop("Error");
await stopCopilotClient();
throw err;
}
await stopCopilotClient();
outro("Done");
}

View File

@ -1,23 +1,51 @@
import type { ModelDefinitionConfig } from "../config/types.js"; import type { ModelDefinitionConfig } from "../config/types.js";
import { listCopilotModels, type CopilotModelInfo } from "./github-copilot-sdk.js";
const DEFAULT_CONTEXT_WINDOW = 128_000; const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 8192; const DEFAULT_MAX_TOKENS = 8192;
// Copilot model ids vary by plan/org and can change. // Fallback model ids if SDK model discovery fails
// We keep this list intentionally broad; if a model isn't available Copilot will const FALLBACK_MODEL_IDS = ["gpt-4o", "gpt-4.1", "gpt-5-mini", "grok-code-fast-1"] as const;
// 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;
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 { export function buildCopilotModelDefinition(modelId: string): ModelDefinitionConfig {

View File

@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Hoist mock functions so they're available before module import
const mockGetAuthStatus = vi.hoisted(() => vi.fn());
const mockListModels = vi.hoisted(() => vi.fn());
const mockStart = vi.hoisted(() => vi.fn());
const mockStop = vi.hoisted(() => vi.fn());
const mockGetState = vi.hoisted(() => vi.fn().mockReturnValue("disconnected"));
// Create a mock class that vitest can instantiate
const MockCopilotClient = vi.hoisted(() =>
vi.fn().mockImplementation(function () {
return {
getAuthStatus: mockGetAuthStatus,
listModels: mockListModels,
start: mockStart,
stop: mockStop,
getState: mockGetState,
};
}),
);
// Mock the @github/copilot-sdk module
vi.mock("@github/copilot-sdk", () => ({
CopilotClient: MockCopilotClient,
}));
describe("github-copilot-sdk", () => {
beforeEach(async () => {
vi.resetModules();
mockGetAuthStatus.mockReset();
mockListModels.mockReset();
mockStart.mockReset();
mockStop.mockReset();
mockGetState.mockReset();
mockGetState.mockReturnValue("disconnected");
MockCopilotClient.mockClear();
});
it("deriveCopilotApiBaseUrlFromToken returns null (SDK manages base URL)", async () => {
const { deriveCopilotApiBaseUrlFromToken } = await import("./github-copilot-sdk.js");
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBeNull();
expect(deriveCopilotApiBaseUrlFromToken("anything")).toBeNull();
});
it("returns sdk-managed token when authenticated", async () => {
mockStart.mockResolvedValue(undefined);
mockGetAuthStatus.mockResolvedValue({ isAuthenticated: true });
const { resolveCopilotApiToken } = await import("./github-copilot-sdk.js");
const res = await resolveCopilotApiToken({ githubToken: "gh" });
expect(res.token).toBe("sdk-managed");
expect(res.source).toBe("sdk:copilot-cli");
expect(res.baseUrl).toBe("https://api.individual.githubcopilot.com");
expect(res.expiresAt).toBeGreaterThan(Date.now());
});
it("throws error when not authenticated", async () => {
mockStart.mockResolvedValue(undefined);
mockGetAuthStatus.mockResolvedValue({ isAuthenticated: false });
const { resolveCopilotApiToken } = await import("./github-copilot-sdk.js");
await expect(resolveCopilotApiToken({ githubToken: "gh" })).rejects.toThrow(
"GitHub Copilot is not authenticated",
);
});
it("isCopilotSdkReady returns true when SDK is available and authenticated", async () => {
mockStart.mockResolvedValue(undefined);
mockGetAuthStatus.mockResolvedValue({ isAuthenticated: true });
const { isCopilotSdkReady } = await import("./github-copilot-sdk.js");
expect(await isCopilotSdkReady()).toBe(true);
});
it("isCopilotSdkReady returns false when not authenticated", async () => {
mockStart.mockResolvedValue(undefined);
mockGetAuthStatus.mockResolvedValue({ isAuthenticated: false });
const { isCopilotSdkReady } = await import("./github-copilot-sdk.js");
expect(await isCopilotSdkReady()).toBe(false);
});
it("isCopilotSdkReady returns false when SDK fails to start", async () => {
mockStart.mockRejectedValue(new Error("CLI not found"));
const { isCopilotSdkReady } = await import("./github-copilot-sdk.js");
expect(await isCopilotSdkReady()).toBe(false);
});
it("listCopilotModels returns models from SDK", async () => {
mockStart.mockResolvedValue(undefined);
mockListModels.mockResolvedValue([
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "o1", name: "O1" },
]);
const { listCopilotModels } = await import("./github-copilot-sdk.js");
const models = await listCopilotModels();
expect(models).toHaveLength(2);
expect(models[0].id).toBe("gpt-4o");
});
});

View File

@ -0,0 +1,181 @@
/**
* GitHub Copilot SDK integration.
*
* Uses the official @github/copilot-sdk which manages a Copilot CLI subprocess
* for authentication and API access.
*/
import { CopilotClient, type ModelInfo } from "@github/copilot-sdk";
import { resolveStateDir } from "../config/paths.js";
let sharedClient: CopilotClient | null = null;
let clientStartPromise: Promise<void> | null = null;
export type CopilotAuthStatus = {
isAuthenticated: boolean;
authType?: string;
host?: string;
login?: string;
statusMessage?: string;
};
export type CopilotModelInfo = ModelInfo;
// Legacy type for backward compatibility
export type CachedCopilotToken = {
token: string;
/** milliseconds since epoch */
expiresAt: number;
/** milliseconds since epoch */
updatedAt: number;
};
// Legacy constant for backward compatibility
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
/**
* Get or create the shared CopilotClient singleton.
* The SDK manages a Copilot CLI subprocess internally.
*/
export function getCopilotClient(): CopilotClient {
if (!sharedClient) {
sharedClient = new CopilotClient({
logLevel: "error",
autoStart: false,
});
}
return sharedClient;
}
/**
* Ensure the Copilot CLI subprocess is started and connected.
*/
export async function ensureCopilotClientStarted(): Promise<CopilotClient> {
const client = getCopilotClient();
const state = client.getState();
if (state === "connected") {
return client;
}
if (clientStartPromise) {
await clientStartPromise;
return client;
}
clientStartPromise = client.start();
try {
await clientStartPromise;
} finally {
clientStartPromise = null;
}
return client;
}
/**
* Stop the shared Copilot client if running.
*/
export async function stopCopilotClient(): Promise<void> {
if (sharedClient) {
const state = sharedClient.getState();
if (state === "connected") {
await sharedClient.stop();
}
sharedClient = null;
}
}
/**
* Get authentication status from the Copilot CLI.
*/
export async function getCopilotAuthStatus(): Promise<CopilotAuthStatus> {
const client = await ensureCopilotClientStarted();
return client.getAuthStatus();
}
/**
* Check if the user is authenticated with GitHub Copilot.
*/
export async function isCopilotAuthenticated(): Promise<boolean> {
try {
const status = await getCopilotAuthStatus();
return status.isAuthenticated;
} catch {
return false;
}
}
/**
* List available models from the Copilot API.
* Requires authentication.
*/
export async function listCopilotModels(): Promise<CopilotModelInfo[]> {
const client = await ensureCopilotClientStarted();
return client.listModels();
}
/**
* Get cached token path for legacy compatibility.
* The SDK handles token management internally, but we keep this
* for existing code that checks for token presence.
*/
export function getCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env): string {
return `${resolveStateDir(env)}/credentials/github-copilot.token.json`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Legacy token compatibility layer
// ─────────────────────────────────────────────────────────────────────────────
/**
* @deprecated The SDK manages base URLs internally. This function always returns null.
*/
export function deriveCopilotApiBaseUrlFromToken(_token: string): string | null {
return null;
}
/**
* Resolve Copilot API token via the official SDK.
*
* The SDK manages token exchange and caching internally, so this function
* now checks authentication status and returns a marker indicating SDK-managed auth.
*
* @throws Error if Copilot CLI is not authenticated
*/
export async function resolveCopilotApiToken(_params: {
githubToken: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
}): Promise<{
token: string;
expiresAt: number;
source: string;
baseUrl: string;
}> {
const isAuthenticated = await isCopilotAuthenticated();
if (!isAuthenticated) {
throw new Error("GitHub Copilot is not authenticated. Run 'copilot auth login' first.");
}
return {
token: "sdk-managed",
expiresAt: Date.now() + 3600 * 1000,
source: "sdk:copilot-cli",
baseUrl: DEFAULT_COPILOT_API_BASE_URL,
};
}
/**
* Check if Copilot SDK is available and authenticated.
*/
export async function isCopilotSdkReady(): Promise<boolean> {
try {
await ensureCopilotClientStarted();
return await isCopilotAuthenticated();
} catch {
return false;
}
}

View File

@ -1,127 +1,92 @@
import path from "node:path"; import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.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 function deriveCopilotApiBaseUrlFromToken(token: string): string {
const m = /proxy-ep=([^;]+)/.exec(token || "");
export type CachedCopilotToken = { if (!m) return "https://api.github.com";
token: string; let ep = m[1];
/** milliseconds since epoch */ // ensure we have a URL-like string
expiresAt: number; let proto = "https:";
/** milliseconds since epoch */ let host = ep;
updatedAt: number; try {
}; if (/^https?:\/\//i.test(ep)) {
const u = new URL(ep);
function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { proto = u.protocol;
return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json"); host = u.hostname;
}
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; } catch {
} else { // leave as-is
throw new Error("Copilot token response missing expires_at");
} }
const parts = host.split(".").filter(Boolean);
return { token, expiresAt: expiresAtMs }; 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 { interface CachedToken {
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; token: string;
expiresAt: number; expiresAt: number;
source: string; updatedAt?: number;
baseUrl: string; }
}> {
const env = params.env ?? process.env; function isCachedToken(value: unknown): value is CachedToken {
const cachePath = resolveCopilotTokenCachePath(env); return (
const cached = loadJsonFile(cachePath) as CachedCopilotToken | undefined; typeof value === "object" &&
if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") { value !== null &&
if (isTokenUsable(cached)) { "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 { return {
token: cached.token, token: cached.token,
expiresAt: cached.expiresAt, baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token),
source: `cache:${cachePath}`, source: `cache:${cached.updatedAt ?? "unknown"}`,
baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL,
}; };
} }
} catch {
// ignore cache read errors
} }
const fetchImpl = params.fetchImpl ?? fetch; const resp = await opts.fetchImpl("https://api.github.com/copilot/api_tokens", {
const res = await fetchImpl(COPILOT_TOKEN_URL, { method: "POST",
method: "GET", headers: { Authorization: `Bearer ${opts.githubToken}`, Accept: "application/json" },
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.githubToken}`,
},
}); });
if (!resp || !resp.ok) {
if (!res.ok) { throw new Error(`failed to fetch copilot token: ${resp?.status}`);
throw new Error(`Copilot token exchange failed: HTTP ${res.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()); try {
const payload: CachedCopilotToken = { saveJsonFile(cachePath, { token, expiresAt, updatedAt: Date.now() });
token: json.token, } catch {
expiresAt: json.expiresAt, // ignore save errors
updatedAt: Date.now(), }
};
saveJsonFile(cachePath, payload);
return { return {
token: payload.token, token,
expiresAt: payload.expiresAt, baseUrl: deriveCopilotApiBaseUrlFromToken(token),
source: `fetched:${COPILOT_TOKEN_URL}`, source: "fetched",
baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL,
}; };
} }
export default { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken };