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/gateway/security.md"
"extensions: copilot-proxy":
"provider: github-copilot":
- changed-files:
- any-glob-to-any-file:
- "extensions/copilot-proxy/**"
- "src/providers/github-copilot-*.ts"
- "docs/providers/github-copilot.md"
"extensions: diagnostics-otel":
- changed-files:
- any-glob-to-any-file:

View File

@ -854,15 +854,6 @@
"line_number": 214
}
],
"extensions/copilot-proxy/index.ts": [
{
"type": "Secret Keyword",
"filename": "extensions/copilot-proxy/index.ts",
"hashed_secret": "50f013532a9770a2c2cfdc38b7581dd01df69b70",
"is_verified": false,
"line_number": 4
}
],
"extensions/google-antigravity-auth/index.ts": [
{
"type": "Base64 High Entropy String",

View File

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

View File

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

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

88
pnpm-lock.yaml generated
View File

@ -25,6 +25,9 @@ importers:
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
'@github/copilot-sdk':
specifier: ^0.1.19
version: 0.1.19
'@grammyjs/runner':
specifier: ^2.0.3
version: 2.0.3(grammy@1.39.3)
@ -264,8 +267,6 @@ importers:
extensions/bluebubbles: {}
extensions/copilot-proxy: {}
extensions/diagnostics-otel:
dependencies:
'@opentelemetry/api':
@ -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: {}

View File

@ -51,7 +51,7 @@ describe("models-config", () => {
try {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
vi.doMock("../providers/github-copilot-sdk.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
@ -97,7 +97,7 @@ describe("models-config", () => {
baseUrl: "https://api.copilot.example",
});
vi.doMock("../providers/github-copilot-token.js", () => ({
vi.doMock("../providers/github-copilot-sdk.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));

View File

@ -51,7 +51,7 @@ describe("models-config", () => {
try {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
vi.doMock("../providers/github-copilot-sdk.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
}));
@ -105,7 +105,7 @@ describe("models-config", () => {
),
);
vi.doMock("../providers/github-copilot-token.js", () => ({
vi.doMock("../providers/github-copilot-sdk.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",

View File

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

View File

@ -87,7 +87,7 @@ describe("models-config", () => {
baseUrl: "https://api.copilot.example",
});
vi.doMock("../providers/github-copilot-token.js", () => ({
vi.doMock("../providers/github-copilot-sdk.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));
@ -117,7 +117,7 @@ describe("models-config", () => {
try {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
vi.doMock("../providers/github-copilot-sdk.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",

View File

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

View File

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

View File

@ -348,7 +348,7 @@ export function registerModelsCli(program: Command) {
auth
.command("login-github-copilot")
.description("Login to GitHub Copilot via GitHub device flow (TTY required)")
.description("Verify GitHub Copilot CLI authentication status")
.option("--profile-id <id>", "Auth profile id (default: github-copilot:github)")
.option("--yes", "Overwrite existing profile without prompting", false)
.action(async (opts) => {

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
import { githubCopilotLoginCommand } from "../providers/github-copilot-login.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthProfileConfig } from "./onboard-auth.js";
@ -11,10 +11,11 @@ export async function applyAuthChoiceGitHubCopilot(
await params.prompter.note(
[
"This will open a GitHub device login to authorize Copilot.",
"This will verify your GitHub Copilot CLI authentication.",
"Run 'copilot auth login' first if not already authenticated.",
"Requires an active GitHub Copilot subscription.",
].join("\n"),
"GitHub Copilot",
"GitHub Copilot SDK",
);
if (!process.stdin.isTTY) {

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
import type { AuthChoice } from "./onboard-types.js";
vi.mock("../providers/github-copilot-auth.js", () => ({
vi.mock("../providers/github-copilot-login.js", () => ({
githubCopilotLoginCommand: vi.fn(async () => {}),
}));

View File

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

View File

@ -30,7 +30,6 @@ export type AuthChoice =
| "minimax-api-lightning"
| "opencode-zen"
| "github-copilot"
| "copilot-proxy"
| "qwen-portal"
| "skip";
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"));
});
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 () => {

View File

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

View File

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

View File

@ -1,66 +1,24 @@
import { fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
/**
* GitHub Copilot usage tracking.
*
* Note: The official @github/copilot-sdk does not currently expose usage/quota APIs.
* This module returns a placeholder indicating that usage tracking is not available.
*/
type CopilotUsageResponse = {
quota_snapshots?: {
premium_interactions?: { percent_remaining?: number | null };
chat?: { percent_remaining?: number | null };
};
copilot_plan?: string;
};
import { PROVIDER_LABELS } from "./provider-usage.shared.js";
import type { ProviderUsageSnapshot } from "./provider-usage.types.js";
export async function fetchCopilotUsage(
token: string,
timeoutMs: number,
fetchFn: typeof fetch,
_token: string,
_timeoutMs: number,
_fetchFn: typeof fetch,
): Promise<ProviderUsageSnapshot> {
const res = await fetchJson(
"https://api.github.com/copilot_internal/user",
{
headers: {
Authorization: `token ${token}`,
"Editor-Version": "vscode/1.96.2",
"User-Agent": "GitHubCopilotChat/0.26.7",
"X-Github-Api-Version": "2025-04-01",
},
},
timeoutMs,
fetchFn,
);
if (!res.ok) {
return {
provider: "github-copilot",
displayName: PROVIDER_LABELS["github-copilot"],
windows: [],
error: `HTTP ${res.status}`,
};
}
const data = (await res.json()) as CopilotUsageResponse;
const windows: UsageWindow[] = [];
if (data.quota_snapshots?.premium_interactions) {
const remaining = data.quota_snapshots.premium_interactions.percent_remaining;
windows.push({
label: "Premium",
usedPercent: clampPercent(100 - (remaining ?? 0)),
});
}
if (data.quota_snapshots?.chat) {
const remaining = data.quota_snapshots.chat.percent_remaining;
windows.push({
label: "Chat",
usedPercent: clampPercent(100 - (remaining ?? 0)),
});
}
// The official SDK does not expose usage/quota APIs
// Return a placeholder indicating unavailable status
return {
provider: "github-copilot",
displayName: PROVIDER_LABELS["github-copilot"],
windows,
plan: data.copilot_plan,
windows: [],
error: "Usage tracking not available via official SDK",
};
}

View File

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

View File

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

View File

@ -1,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<string[]> {
try {
const models = await listCopilotModels();
if (models.length > 0) {
return models.map((m) => m.id);
}
} catch {
// Fall through to fallback list
}
return [...FALLBACK_MODEL_IDS];
}
/**
* Get available model IDs synchronously (fallback list only).
* Use getDefaultCopilotModelIds() for SDK-based discovery.
*/
export function getDefaultCopilotModelIdsSync(): string[] {
return [...FALLBACK_MODEL_IDS];
}
/**
* Build a model definition from SDK model info.
*/
export function buildCopilotModelDefinitionFromSdk(model: CopilotModelInfo): ModelDefinitionConfig {
return {
id: model.id,
name: model.name,
// The SDK manages API routing internally
api: "openai-responses",
reasoning: false,
input: model.capabilities?.supports?.vision ? ["text", "image"] : ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: model.capabilities?.limits?.max_context_window_tokens ?? DEFAULT_CONTEXT_WINDOW,
maxTokens: model.capabilities?.limits?.max_prompt_tokens ?? DEFAULT_MAX_TOKENS,
};
}
export function buildCopilotModelDefinition(modelId: string): ModelDefinitionConfig {

View File

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

View File

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

View File

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