Merge 59903758fb into 09be5d45d5
This commit is contained in:
commit
dcdca73275
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
@ -180,10 +180,12 @@
|
||||
- "docs/cli/security.md"
|
||||
- "docs/gateway/security.md"
|
||||
|
||||
"extensions: copilot-proxy":
|
||||
"provider: github-copilot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/copilot-proxy/**"
|
||||
- "src/providers/github-copilot-*.ts"
|
||||
- "docs/providers/github-copilot.md"
|
||||
|
||||
"extensions: diagnostics-otel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@ -854,15 +854,6 @@
|
||||
"line_number": 214
|
||||
}
|
||||
],
|
||||
"extensions/copilot-proxy/index.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "extensions/copilot-proxy/index.ts",
|
||||
"hashed_secret": "50f013532a9770a2c2cfdc38b7581dd01df69b70",
|
||||
"is_verified": false,
|
||||
"line_number": 4
|
||||
}
|
||||
],
|
||||
"extensions/google-antigravity-auth/index.ts": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,41 +4,30 @@ read_when:
|
||||
- You want to use GitHub Copilot as a model provider
|
||||
- You need the `openclaw models auth login-github-copilot` flow
|
||||
---
|
||||
# Github Copilot
|
||||
# GitHub Copilot
|
||||
|
||||
## What is GitHub Copilot?
|
||||
|
||||
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
|
||||
models for your GitHub account and plan. OpenClaw can use Copilot as a model
|
||||
provider in two different ways.
|
||||
|
||||
## Two ways to use Copilot in OpenClaw
|
||||
|
||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||
|
||||
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||
Copilot API tokens when OpenClaw runs. This is the **default** and simplest path
|
||||
because it does not require VS Code.
|
||||
|
||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||
|
||||
Use the **Copilot Proxy** VS Code extension as a local bridge. OpenClaw talks to
|
||||
the proxy’s `/v1` endpoint and uses the model list you configure there. Choose
|
||||
this when you already run Copilot Proxy in VS Code or need to route through it.
|
||||
You must enable the plugin and keep the VS Code extension running.
|
||||
provider via the official GitHub Copilot SDK. OpenClaw uses the GitHub device
|
||||
flow to obtain a GitHub token and exchanges it for Copilot API tokens at runtime.
|
||||
This is the recommended and supported path for integrating Copilot with
|
||||
OpenClaw.
|
||||
|
||||
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
||||
the GitHub device flow, saves an auth profile, and updates your config to use that
|
||||
profile.
|
||||
the GitHub device flow, saves an auth profile, and updates your config to use
|
||||
that profile.
|
||||
|
||||
## CLI setup
|
||||
|
||||
Run the OpenClaw login flow to create an auth profile:
|
||||
|
||||
```bash
|
||||
openclaw models auth login-github-copilot
|
||||
```
|
||||
|
||||
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||
open until it completes.
|
||||
This creates an auth profile and (optionally) updates your config to use it.
|
||||
|
||||
### Optional flags
|
||||
|
||||
|
||||
@ -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`.
|
||||
@ -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;
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "copilot-proxy",
|
||||
"providers": [
|
||||
"copilot-proxy"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.1.29",
|
||||
"type": "module",
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -158,6 +158,7 @@
|
||||
"@aws-sdk/client-bedrock": "^3.975.0",
|
||||
"@buape/carbon": "0.14.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@github/copilot-sdk": "^0.1.19",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@ -25,6 +25,9 @@ importers:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@github/copilot-sdk':
|
||||
specifier: ^0.1.19
|
||||
version: 0.1.19
|
||||
'@grammyjs/runner':
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3(grammy@1.39.3)
|
||||
@ -264,8 +267,6 @@ importers:
|
||||
|
||||
extensions/bluebubbles: {}
|
||||
|
||||
extensions/copilot-proxy: {}
|
||||
|
||||
extensions/diagnostics-otel:
|
||||
dependencies:
|
||||
'@opentelemetry/api':
|
||||
@ -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: {}
|
||||
|
||||
@ -51,7 +51,7 @@ describe("models-config", () => {
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
vi.doMock("../providers/github-copilot-sdk.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
@ -97,7 +97,7 @@ describe("models-config", () => {
|
||||
baseUrl: "https://api.copilot.example",
|
||||
});
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
vi.doMock("../providers/github-copilot-sdk.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
|
||||
@ -51,7 +51,7 @@ describe("models-config", () => {
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
vi.doMock("../providers/github-copilot-sdk.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
|
||||
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
}));
|
||||
@ -105,7 +105,7 @@ describe("models-config", () => {
|
||||
),
|
||||
);
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
vi.doMock("../providers/github-copilot-sdk.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
|
||||
@ -3,7 +3,7 @@ import type { ModelDefinitionConfig } from "../config/types.models.js";
|
||||
import {
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "../providers/github-copilot-token.js";
|
||||
} from "../providers/github-copilot-sdk.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||
import { discoverBedrockModels } from "./bedrock-discovery.js";
|
||||
|
||||
@ -87,7 +87,7 @@ describe("models-config", () => {
|
||||
baseUrl: "https://api.copilot.example",
|
||||
});
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
vi.doMock("../providers/github-copilot-sdk.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
@ -117,7 +117,7 @@ describe("models-config", () => {
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
vi.doMock("../providers/github-copilot-sdk.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
|
||||
@ -144,7 +144,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
);
|
||||
}
|
||||
} else if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js");
|
||||
const { resolveCopilotApiToken } = await import("../../providers/github-copilot-sdk.js");
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
|
||||
@ -229,8 +229,7 @@ export async function runEmbeddedPiAgent(
|
||||
return;
|
||||
}
|
||||
if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } =
|
||||
await import("../../providers/github-copilot-token.js");
|
||||
const { resolveCopilotApiToken } = await import("../../providers/github-copilot-sdk.js");
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
|
||||
@ -348,7 +348,7 @@ export function registerModelsCli(program: Command) {
|
||||
|
||||
auth
|
||||
.command("login-github-copilot")
|
||||
.description("Login to GitHub Copilot via GitHub device flow (TTY required)")
|
||||
.description("Verify GitHub Copilot CLI authentication status")
|
||||
.option("--profile-id <id>", "Auth profile id (default: github-copilot:github)")
|
||||
.option("--yes", "Overwrite existing profile without prompting", false)
|
||||
.action(async (opts) => {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
||||
|
||||
export async function applyAuthChoiceCopilotProxy(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
return await applyAuthChoicePluginProvider(params, {
|
||||
authChoice: "copilot-proxy",
|
||||
pluginId: "copilot-proxy",
|
||||
providerId: "copilot-proxy",
|
||||
methodId: "local",
|
||||
label: "Copilot Proxy",
|
||||
});
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
|
||||
import { githubCopilotLoginCommand } from "../providers/github-copilot-login.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyAuthProfileConfig } from "./onboard-auth.js";
|
||||
|
||||
@ -11,10 +11,11 @@ export async function applyAuthChoiceGitHubCopilot(
|
||||
|
||||
await params.prompter.note(
|
||||
[
|
||||
"This will open a GitHub device login to authorize Copilot.",
|
||||
"This will verify your GitHub Copilot CLI authentication.",
|
||||
"Run 'copilot auth login' first if not already authenticated.",
|
||||
"Requires an active GitHub Copilot subscription.",
|
||||
].join("\n"),
|
||||
"GitHub Copilot",
|
||||
"GitHub Copilot SDK",
|
||||
);
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
|
||||
@ -3,7 +3,6 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
|
||||
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
|
||||
import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js";
|
||||
import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
|
||||
import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js";
|
||||
import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js";
|
||||
@ -44,7 +43,6 @@ export async function applyAuthChoice(
|
||||
applyAuthChoiceGitHubCopilot,
|
||||
applyAuthChoiceGoogleAntigravity,
|
||||
applyAuthChoiceGoogleGeminiCli,
|
||||
applyAuthChoiceCopilotProxy,
|
||||
applyAuthChoiceQwenPortal,
|
||||
];
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -9,7 +9,7 @@ import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
|
||||
import type { AuthChoice } from "./onboard-types.js";
|
||||
|
||||
vi.mock("../providers/github-copilot-auth.js", () => ({
|
||||
vi.mock("../providers/github-copilot-login.js", () => ({
|
||||
githubCopilotLoginCommand: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
|
||||
export { githubCopilotLoginCommand } from "../providers/github-copilot-login.js";
|
||||
export {
|
||||
modelsAliasesAddCommand,
|
||||
modelsAliasesListCommand,
|
||||
|
||||
@ -30,7 +30,6 @@ export type AuthChoice =
|
||||
| "minimax-api-lightning"
|
||||
| "opencode-zen"
|
||||
| "github-copilot"
|
||||
| "copilot-proxy"
|
||||
| "qwen-portal"
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "token" | "password";
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -32,7 +32,6 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
|
||||
{ pluginId: "google-antigravity-auth", providerId: "google-antigravity" },
|
||||
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
|
||||
{ pluginId: "qwen-portal-auth", providerId: "qwen-portal" },
|
||||
{ pluginId: "copilot-proxy", providerId: "copilot-proxy" },
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@ -1,66 +1,24 @@
|
||||
import { fetchJson } from "./provider-usage.fetch.shared.js";
|
||||
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
|
||||
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
|
||||
/**
|
||||
* GitHub Copilot usage tracking.
|
||||
*
|
||||
* Note: The official @github/copilot-sdk does not currently expose usage/quota APIs.
|
||||
* This module returns a placeholder indicating that usage tracking is not available.
|
||||
*/
|
||||
|
||||
type CopilotUsageResponse = {
|
||||
quota_snapshots?: {
|
||||
premium_interactions?: { percent_remaining?: number | null };
|
||||
chat?: { percent_remaining?: number | null };
|
||||
};
|
||||
copilot_plan?: string;
|
||||
};
|
||||
import { PROVIDER_LABELS } from "./provider-usage.shared.js";
|
||||
import type { ProviderUsageSnapshot } from "./provider-usage.types.js";
|
||||
|
||||
export async function fetchCopilotUsage(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
fetchFn: typeof fetch,
|
||||
_token: string,
|
||||
_timeoutMs: number,
|
||||
_fetchFn: typeof fetch,
|
||||
): Promise<ProviderUsageSnapshot> {
|
||||
const res = await fetchJson(
|
||||
"https://api.github.com/copilot_internal/user",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
"Editor-Version": "vscode/1.96.2",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"X-Github-Api-Version": "2025-04-01",
|
||||
},
|
||||
},
|
||||
timeoutMs,
|
||||
fetchFn,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
provider: "github-copilot",
|
||||
displayName: PROVIDER_LABELS["github-copilot"],
|
||||
windows: [],
|
||||
error: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as CopilotUsageResponse;
|
||||
const windows: UsageWindow[] = [];
|
||||
|
||||
if (data.quota_snapshots?.premium_interactions) {
|
||||
const remaining = data.quota_snapshots.premium_interactions.percent_remaining;
|
||||
windows.push({
|
||||
label: "Premium",
|
||||
usedPercent: clampPercent(100 - (remaining ?? 0)),
|
||||
});
|
||||
}
|
||||
|
||||
if (data.quota_snapshots?.chat) {
|
||||
const remaining = data.quota_snapshots.chat.percent_remaining;
|
||||
windows.push({
|
||||
label: "Chat",
|
||||
usedPercent: clampPercent(100 - (remaining ?? 0)),
|
||||
});
|
||||
}
|
||||
|
||||
// The official SDK does not expose usage/quota APIs
|
||||
// Return a placeholder indicating unavailable status
|
||||
return {
|
||||
provider: "github-copilot",
|
||||
displayName: PROVIDER_LABELS["github-copilot"],
|
||||
windows,
|
||||
plan: data.copilot_plan,
|
||||
windows: [],
|
||||
error: "Usage tracking not available via official SDK",
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,185 +0,0 @@
|
||||
import { intro, note, outro, spinner } from "@clack/prompts";
|
||||
|
||||
import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||
import { updateConfig } from "../commands/models/shared.js";
|
||||
import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
||||
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
||||
|
||||
type DeviceCodeResponse = {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
};
|
||||
|
||||
type DeviceTokenResponse =
|
||||
| {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope?: string;
|
||||
}
|
||||
| {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
};
|
||||
|
||||
function parseJsonResponse<T>(value: unknown): T {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Unexpected response from GitHub");
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
scope: params.scope,
|
||||
});
|
||||
|
||||
const res = await fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub device code failed: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json = parseJsonResponse<DeviceCodeResponse>(await res.json());
|
||||
if (!json.device_code || !json.user_code || !json.verification_uri) {
|
||||
throw new Error("GitHub device code response missing fields");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function pollForAccessToken(params: {
|
||||
deviceCode: string;
|
||||
intervalMs: number;
|
||||
expiresAt: number;
|
||||
}): Promise<string> {
|
||||
const bodyBase = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
device_code: params.deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
|
||||
while (Date.now() < params.expiresAt) {
|
||||
const res = await fetch(ACCESS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: bodyBase,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub device token failed: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json = parseJsonResponse<DeviceTokenResponse>(await res.json());
|
||||
if ("access_token" in json && typeof json.access_token === "string") {
|
||||
return json.access_token;
|
||||
}
|
||||
|
||||
const err = "error" in json ? json.error : "unknown";
|
||||
if (err === "authorization_pending") {
|
||||
await new Promise((r) => setTimeout(r, params.intervalMs));
|
||||
continue;
|
||||
}
|
||||
if (err === "slow_down") {
|
||||
await new Promise((r) => setTimeout(r, params.intervalMs + 2000));
|
||||
continue;
|
||||
}
|
||||
if (err === "expired_token") {
|
||||
throw new Error("GitHub device code expired; run login again");
|
||||
}
|
||||
if (err === "access_denied") {
|
||||
throw new Error("GitHub login cancelled");
|
||||
}
|
||||
throw new Error(`GitHub device flow error: ${err}`);
|
||||
}
|
||||
|
||||
throw new Error("GitHub device code expired; run login again");
|
||||
}
|
||||
|
||||
export async function githubCopilotLoginCommand(
|
||||
opts: { profileId?: string; yes?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error("github-copilot login requires an interactive TTY.");
|
||||
}
|
||||
|
||||
intro(stylePromptTitle("GitHub Copilot login"));
|
||||
|
||||
const profileId = opts.profileId?.trim() || "github-copilot:github";
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
|
||||
if (store.profiles[profileId] && !opts.yes) {
|
||||
note(
|
||||
`Auth profile already exists: ${profileId}\nRe-running will overwrite it.`,
|
||||
stylePromptTitle("Existing credentials"),
|
||||
);
|
||||
}
|
||||
|
||||
const spin = spinner();
|
||||
spin.start("Requesting device code from GitHub...");
|
||||
const device = await requestDeviceCode({ scope: "read:user" });
|
||||
spin.stop("Device code ready");
|
||||
|
||||
note(
|
||||
[`Visit: ${device.verification_uri}`, `Code: ${device.user_code}`].join("\n"),
|
||||
stylePromptTitle("Authorize"),
|
||||
);
|
||||
|
||||
const expiresAt = Date.now() + device.expires_in * 1000;
|
||||
const intervalMs = Math.max(1000, device.interval * 1000);
|
||||
|
||||
const polling = spinner();
|
||||
polling.start("Waiting for GitHub authorization...");
|
||||
const accessToken = await pollForAccessToken({
|
||||
deviceCode: device.device_code,
|
||||
intervalMs,
|
||||
expiresAt,
|
||||
});
|
||||
polling.stop("GitHub access token acquired");
|
||||
|
||||
upsertAuthProfile({
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: accessToken,
|
||||
// GitHub device flow token doesn't reliably include expiry here.
|
||||
// Leave expires unset; we'll exchange into Copilot token plus expiry later.
|
||||
},
|
||||
});
|
||||
|
||||
await updateConfig((cfg) =>
|
||||
applyAuthProfileConfig(cfg, {
|
||||
provider: "github-copilot",
|
||||
profileId,
|
||||
mode: "token",
|
||||
}),
|
||||
);
|
||||
|
||||
logConfigUpdated(runtime);
|
||||
runtime.log(`Auth profile: ${profileId} (github-copilot/token)`);
|
||||
|
||||
outro("Done");
|
||||
}
|
||||
95
src/providers/github-copilot-login.ts
Normal file
95
src/providers/github-copilot-login.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { intro, note, outro, spinner } from "@clack/prompts";
|
||||
|
||||
import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||
import { updateConfig } from "../commands/models/shared.js";
|
||||
import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
import {
|
||||
getCopilotAuthStatus,
|
||||
ensureCopilotClientStarted,
|
||||
stopCopilotClient,
|
||||
} from "./github-copilot-sdk.js";
|
||||
|
||||
export async function githubCopilotLoginCommand(
|
||||
opts: { profileId?: string; yes?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error("github-copilot login requires an interactive TTY.");
|
||||
}
|
||||
|
||||
intro(stylePromptTitle("GitHub Copilot login"));
|
||||
|
||||
const profileId = opts.profileId?.trim() || "github-copilot:github";
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
|
||||
if (store.profiles[profileId] && !opts.yes) {
|
||||
note(
|
||||
`Auth profile already exists: ${profileId}\nRe-running will overwrite it.`,
|
||||
stylePromptTitle("Existing credentials"),
|
||||
);
|
||||
}
|
||||
|
||||
const spin = spinner();
|
||||
spin.start("Checking GitHub Copilot authentication status...");
|
||||
|
||||
try {
|
||||
await ensureCopilotClientStarted();
|
||||
const authStatus = await getCopilotAuthStatus();
|
||||
|
||||
if (!authStatus.isAuthenticated) {
|
||||
spin.stop("Not authenticated");
|
||||
note(
|
||||
[
|
||||
"GitHub Copilot CLI is not authenticated.",
|
||||
"Please run 'copilot auth login' in your terminal first,",
|
||||
"then retry this command.",
|
||||
].join("\n"),
|
||||
stylePromptTitle("Authentication required"),
|
||||
);
|
||||
await stopCopilotClient();
|
||||
throw new Error("GitHub Copilot not authenticated. Run 'copilot auth login' first.");
|
||||
}
|
||||
|
||||
spin.stop(`Authenticated as ${authStatus.login ?? authStatus.authType ?? "user"}`);
|
||||
|
||||
// Store a marker profile indicating SDK-managed authentication
|
||||
upsertAuthProfile({
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
// The SDK manages tokens internally, so we store a marker token
|
||||
token: "sdk-managed",
|
||||
},
|
||||
});
|
||||
|
||||
await updateConfig((cfg) =>
|
||||
applyAuthProfileConfig(cfg, {
|
||||
provider: "github-copilot",
|
||||
profileId,
|
||||
mode: "token",
|
||||
}),
|
||||
);
|
||||
|
||||
logConfigUpdated(runtime);
|
||||
runtime.log(`Auth profile: ${profileId} (github-copilot/sdk-managed)`);
|
||||
if (authStatus.login) {
|
||||
runtime.log(`Logged in as: ${authStatus.login}`);
|
||||
}
|
||||
if (authStatus.statusMessage) {
|
||||
note(authStatus.statusMessage, stylePromptTitle("Status"));
|
||||
}
|
||||
} catch (err) {
|
||||
spin.stop("Error");
|
||||
await stopCopilotClient();
|
||||
throw err;
|
||||
}
|
||||
|
||||
await stopCopilotClient();
|
||||
outro("Done");
|
||||
}
|
||||
@ -1,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 {
|
||||
|
||||
104
src/providers/github-copilot-sdk.test.ts
Normal file
104
src/providers/github-copilot-sdk.test.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Hoist mock functions so they're available before module import
|
||||
const mockGetAuthStatus = vi.hoisted(() => vi.fn());
|
||||
const mockListModels = vi.hoisted(() => vi.fn());
|
||||
const mockStart = vi.hoisted(() => vi.fn());
|
||||
const mockStop = vi.hoisted(() => vi.fn());
|
||||
const mockGetState = vi.hoisted(() => vi.fn().mockReturnValue("disconnected"));
|
||||
|
||||
// Create a mock class that vitest can instantiate
|
||||
const MockCopilotClient = vi.hoisted(() =>
|
||||
vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
getAuthStatus: mockGetAuthStatus,
|
||||
listModels: mockListModels,
|
||||
start: mockStart,
|
||||
stop: mockStop,
|
||||
getState: mockGetState,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock the @github/copilot-sdk module
|
||||
vi.mock("@github/copilot-sdk", () => ({
|
||||
CopilotClient: MockCopilotClient,
|
||||
}));
|
||||
|
||||
describe("github-copilot-sdk", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
mockGetAuthStatus.mockReset();
|
||||
mockListModels.mockReset();
|
||||
mockStart.mockReset();
|
||||
mockStop.mockReset();
|
||||
mockGetState.mockReset();
|
||||
mockGetState.mockReturnValue("disconnected");
|
||||
MockCopilotClient.mockClear();
|
||||
});
|
||||
|
||||
it("deriveCopilotApiBaseUrlFromToken returns null (SDK manages base URL)", async () => {
|
||||
const { deriveCopilotApiBaseUrlFromToken } = await import("./github-copilot-sdk.js");
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBeNull();
|
||||
expect(deriveCopilotApiBaseUrlFromToken("anything")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns sdk-managed token when authenticated", async () => {
|
||||
mockStart.mockResolvedValue(undefined);
|
||||
mockGetAuthStatus.mockResolvedValue({ isAuthenticated: true });
|
||||
|
||||
const { resolveCopilotApiToken } = await import("./github-copilot-sdk.js");
|
||||
const res = await resolveCopilotApiToken({ githubToken: "gh" });
|
||||
|
||||
expect(res.token).toBe("sdk-managed");
|
||||
expect(res.source).toBe("sdk:copilot-cli");
|
||||
expect(res.baseUrl).toBe("https://api.individual.githubcopilot.com");
|
||||
expect(res.expiresAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("throws error when not authenticated", async () => {
|
||||
mockStart.mockResolvedValue(undefined);
|
||||
mockGetAuthStatus.mockResolvedValue({ isAuthenticated: false });
|
||||
|
||||
const { resolveCopilotApiToken } = await import("./github-copilot-sdk.js");
|
||||
await expect(resolveCopilotApiToken({ githubToken: "gh" })).rejects.toThrow(
|
||||
"GitHub Copilot is not authenticated",
|
||||
);
|
||||
});
|
||||
|
||||
it("isCopilotSdkReady returns true when SDK is available and authenticated", async () => {
|
||||
mockStart.mockResolvedValue(undefined);
|
||||
mockGetAuthStatus.mockResolvedValue({ isAuthenticated: true });
|
||||
|
||||
const { isCopilotSdkReady } = await import("./github-copilot-sdk.js");
|
||||
expect(await isCopilotSdkReady()).toBe(true);
|
||||
});
|
||||
|
||||
it("isCopilotSdkReady returns false when not authenticated", async () => {
|
||||
mockStart.mockResolvedValue(undefined);
|
||||
mockGetAuthStatus.mockResolvedValue({ isAuthenticated: false });
|
||||
|
||||
const { isCopilotSdkReady } = await import("./github-copilot-sdk.js");
|
||||
expect(await isCopilotSdkReady()).toBe(false);
|
||||
});
|
||||
|
||||
it("isCopilotSdkReady returns false when SDK fails to start", async () => {
|
||||
mockStart.mockRejectedValue(new Error("CLI not found"));
|
||||
|
||||
const { isCopilotSdkReady } = await import("./github-copilot-sdk.js");
|
||||
expect(await isCopilotSdkReady()).toBe(false);
|
||||
});
|
||||
|
||||
it("listCopilotModels returns models from SDK", async () => {
|
||||
mockStart.mockResolvedValue(undefined);
|
||||
mockListModels.mockResolvedValue([
|
||||
{ id: "gpt-4o", name: "GPT-4o" },
|
||||
{ id: "o1", name: "O1" },
|
||||
]);
|
||||
|
||||
const { listCopilotModels } = await import("./github-copilot-sdk.js");
|
||||
const models = await listCopilotModels();
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models[0].id).toBe("gpt-4o");
|
||||
});
|
||||
});
|
||||
181
src/providers/github-copilot-sdk.ts
Normal file
181
src/providers/github-copilot-sdk.ts
Normal file
@ -0,0 +1,181 @@
|
||||
/**
|
||||
* GitHub Copilot SDK integration.
|
||||
*
|
||||
* Uses the official @github/copilot-sdk which manages a Copilot CLI subprocess
|
||||
* for authentication and API access.
|
||||
*/
|
||||
|
||||
import { CopilotClient, type ModelInfo } from "@github/copilot-sdk";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
|
||||
let sharedClient: CopilotClient | null = null;
|
||||
let clientStartPromise: Promise<void> | null = null;
|
||||
|
||||
export type CopilotAuthStatus = {
|
||||
isAuthenticated: boolean;
|
||||
authType?: string;
|
||||
host?: string;
|
||||
login?: string;
|
||||
statusMessage?: string;
|
||||
};
|
||||
|
||||
export type CopilotModelInfo = ModelInfo;
|
||||
|
||||
// Legacy type for backward compatibility
|
||||
export type CachedCopilotToken = {
|
||||
token: string;
|
||||
/** milliseconds since epoch */
|
||||
expiresAt: number;
|
||||
/** milliseconds since epoch */
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
// Legacy constant for backward compatibility
|
||||
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
||||
|
||||
/**
|
||||
* Get or create the shared CopilotClient singleton.
|
||||
* The SDK manages a Copilot CLI subprocess internally.
|
||||
*/
|
||||
export function getCopilotClient(): CopilotClient {
|
||||
if (!sharedClient) {
|
||||
sharedClient = new CopilotClient({
|
||||
logLevel: "error",
|
||||
autoStart: false,
|
||||
});
|
||||
}
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Copilot CLI subprocess is started and connected.
|
||||
*/
|
||||
export async function ensureCopilotClientStarted(): Promise<CopilotClient> {
|
||||
const client = getCopilotClient();
|
||||
const state = client.getState();
|
||||
|
||||
if (state === "connected") {
|
||||
return client;
|
||||
}
|
||||
|
||||
if (clientStartPromise) {
|
||||
await clientStartPromise;
|
||||
return client;
|
||||
}
|
||||
|
||||
clientStartPromise = client.start();
|
||||
try {
|
||||
await clientStartPromise;
|
||||
} finally {
|
||||
clientStartPromise = null;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the shared Copilot client if running.
|
||||
*/
|
||||
export async function stopCopilotClient(): Promise<void> {
|
||||
if (sharedClient) {
|
||||
const state = sharedClient.getState();
|
||||
if (state === "connected") {
|
||||
await sharedClient.stop();
|
||||
}
|
||||
sharedClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication status from the Copilot CLI.
|
||||
*/
|
||||
export async function getCopilotAuthStatus(): Promise<CopilotAuthStatus> {
|
||||
const client = await ensureCopilotClientStarted();
|
||||
return client.getAuthStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is authenticated with GitHub Copilot.
|
||||
*/
|
||||
export async function isCopilotAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
const status = await getCopilotAuthStatus();
|
||||
return status.isAuthenticated;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available models from the Copilot API.
|
||||
* Requires authentication.
|
||||
*/
|
||||
export async function listCopilotModels(): Promise<CopilotModelInfo[]> {
|
||||
const client = await ensureCopilotClientStarted();
|
||||
return client.listModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached token path for legacy compatibility.
|
||||
* The SDK handles token management internally, but we keep this
|
||||
* for existing code that checks for token presence.
|
||||
*/
|
||||
export function getCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return `${resolveStateDir(env)}/credentials/github-copilot.token.json`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Legacy token compatibility layer
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @deprecated The SDK manages base URLs internally. This function always returns null.
|
||||
*/
|
||||
export function deriveCopilotApiBaseUrlFromToken(_token: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Copilot API token via the official SDK.
|
||||
*
|
||||
* The SDK manages token exchange and caching internally, so this function
|
||||
* now checks authentication status and returns a marker indicating SDK-managed auth.
|
||||
*
|
||||
* @throws Error if Copilot CLI is not authenticated
|
||||
*/
|
||||
export async function resolveCopilotApiToken(_params: {
|
||||
githubToken: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<{
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
source: string;
|
||||
baseUrl: string;
|
||||
}> {
|
||||
const isAuthenticated = await isCopilotAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw new Error("GitHub Copilot is not authenticated. Run 'copilot auth login' first.");
|
||||
}
|
||||
|
||||
return {
|
||||
token: "sdk-managed",
|
||||
expiresAt: Date.now() + 3600 * 1000,
|
||||
source: "sdk:copilot-cli",
|
||||
baseUrl: DEFAULT_COPILOT_API_BASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Copilot SDK is available and authenticated.
|
||||
*/
|
||||
export async function isCopilotSdkReady(): Promise<boolean> {
|
||||
try {
|
||||
await ensureCopilotClientStarted();
|
||||
return await isCopilotAuthenticated();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user