diff --git a/server/plugins/build-in/copilot-auth/README.md b/server/plugins/build-in/copilot-auth/README.md
new file mode 100644
index 000000000..32b02f281
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/README.md
@@ -0,0 +1,44 @@
+# GitHub Copilot (copilot-auth)
+
+在 cursor-byok 里使用自己的 GitHub Copilot 订阅:设备码登录 GitHub → 自动同步该账号可用的 Copilot
+模型(GPT / Claude / Gemini 等)→ 在 Cursor 中对话、Agent、工具调用。
+
+## ⚠️ 风险声明
+
+- 本插件使用 **VS Code Copilot Chat 的 OAuth Client ID `Iv1.b507a08c87ecfe98`**,并以 VS Code
+ 的请求头访问 Copilot API。这与 [copilot-api](https://github.com/caozhiyuan/copilot-api) 的默认
+ 行为相同,属于**非官方用法**,可能违反 GitHub
+ 服务条款,存在账号被限制或封禁的风险。请自行评估后使用。
+- Client ID 与所有「伪装身份」常量集中在 `constants.ts`。如有自有 OAuth App(需加入 GitHub Copilot
+ Partner Program),只需替换该文件中的常量。
+- 本插件**不会**使用 OpenCode 的 Client ID——那等于冒充另一个已获官方合作的产品。
+- GitHub token 与 Copilot token 只保存在本机资源记录中,不会出现在日志或账号卡片里。
+
+## 使用
+
+1. 在 cursor-byok 桌面端打开 GitHub Copilot 插件,点击「添加账号」→「使用 GitHub 登录」。
+2. 打开验证页面,输入设备码并授权。账号卡片会显示 GitHub 用户名、套餐与 Premium 请求剩余百分比。
+3. 点击「同步模型」,然后在 Cursor 中选择 GitHub Copilot 下的模型即可。
+
+发布构建会自动预装本插件;手动安装时把整个 `copilot-auth/` 目录(测试与 `deno.json` 可省略) 拷到
+`~/.cursor-byok-v3/plugins/installed/` 后重启 cursor-byok。
+
+## 行为说明
+
+| 项目 | 行为 |
+| ------------- | ------------------------------------------------------------------------------------------------------------- |
+| Copilot token | 约 30 分钟有效,剩余不足 5 分钟时在调用前自动续期并写回账号 |
+| 端点路由 | GPT 系走 `/responses`;Claude、Gemini 与旧模型走 `/chat/completions`;仅支持 `/v1/messages` 的模型暂不提供 |
+| 计费 | 最后一条消息是用户输入时 `x-initiator: user`(消耗 premium request);工具结果/助手续跑回合为 `agent`,不额外计费 |
+| 重试 | 408 / 425 / 429 / 5xx 与边缘节点的裸 403 最多重试 3 次;401 先重新换取 token 再重试一次 |
+| 额度耗尽 | 429 且提示额度不足时账号进入冷却,直到额度重置日(未知时 1 小时) |
+| 上下文 | 档位由 Cursor/宿主统一提供;超出 Copilot 上限(`model_max_prompt_tokens_exceeded`)时宿主自动压缩历史后重试 |
+| 不支持 | GitHub Enterprise Server / ghe.com、Anthropic `/v1/messages` 原生协议、自动启用被策略禁用的模型 |
+
+## 开发
+
+```bash
+deno check main.ts
+deno test --allow-read
+deno fmt --check && deno lint
+```
diff --git a/server/plugins/build-in/copilot-auth/assets/copilot.svg b/server/plugins/build-in/copilot-auth/assets/copilot.svg
new file mode 100644
index 000000000..6128346c5
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/assets/copilot.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/server/plugins/build-in/copilot-auth/constants.ts b/server/plugins/build-in/copilot-auth/constants.ts
new file mode 100644
index 000000000..7299b1f78
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/constants.ts
@@ -0,0 +1,92 @@
+/**
+ * 所有「以 VS Code Copilot Chat 身份访问 GitHub」的常量集中在此,便于随上游
+ * (caozhiyuan/copilot-api `src/lib/api-config.ts`,MIT)同步版本号,或整体换成自有 OAuth App。
+ */
+export const CLIENT_ID = "Iv1.b507a08c87ecfe98";
+export const SCOPE = "read:user";
+
+export const DEVICE_CODE_URL = "https://github.com/login/device/code";
+export const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
+export const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
+export const COPILOT_USER_URL = "https://api.github.com/copilot_internal/user";
+export const DEFAULT_COPILOT_BASE = "https://api.githubcopilot.com";
+
+export const COPILOT_CHAT_VERSION = "0.68.0";
+export const VSCODE_VERSION = "1.140.0";
+export const COPILOT_API_VERSION = "2026-08-01";
+export const GITHUB_API_VERSION = "2025-04-01";
+
+/** token 交换可能返回的 Copilot API 主机;必须与 plugin.json 的网络白名单一致。 */
+export const COPILOT_API_HOSTS: readonly string[] = [
+ "api.githubcopilot.com",
+ "api.individual.githubcopilot.com",
+ "api.business.githubcopilot.com",
+ "api.enterprise.githubcopilot.com",
+];
+
+export type Initiator = "user" | "agent";
+
+/** 头 A:github.com 设备码 OAuth。 */
+export function oauthHeaders(): Record {
+ return {
+ accept: "application/json",
+ "content-type": "application/json",
+ };
+}
+
+/** 头 B:api.github.com 的 token 交换与账号/额度查询;前缀是 token 而非 Bearer。 */
+export function githubHeaders(githubToken: string): Record {
+ return {
+ authorization: `token ${githubToken}`,
+ accept: "application/json",
+ "user-agent": `GitHubCopilotChat/${COPILOT_CHAT_VERSION}`,
+ "x-github-api-version": GITHUB_API_VERSION,
+ "x-vscode-user-agent-library-version": "electron-fetch",
+ };
+}
+
+function copilotBaseHeaders(
+ copilotToken: string,
+ deviceId: string,
+ intent: string,
+): Record {
+ const requestId = crypto.randomUUID();
+ return {
+ authorization: `Bearer ${copilotToken}`,
+ "copilot-integration-id": "vscode-chat",
+ "editor-version": `vscode/${VSCODE_VERSION}`,
+ "editor-plugin-version": `copilot-chat/${COPILOT_CHAT_VERSION}`,
+ "user-agent": `GitHubCopilotChat/${COPILOT_CHAT_VERSION}`,
+ "editor-device-id": deviceId,
+ "x-github-api-version": COPILOT_API_VERSION,
+ "x-vscode-user-agent-library-version": "electron-fetch",
+ "x-request-id": requestId,
+ "x-agent-task-id": requestId,
+ "openai-intent": intent,
+ "x-interaction-type": intent,
+ };
+}
+
+/** 头 C(模型发现):不带 content-type、x-interaction-id、x-initiator。 */
+export function copilotModelHeaders(
+ copilotToken: string,
+ deviceId: string,
+): Record {
+ return copilotBaseHeaders(copilotToken, deviceId, "model-access");
+}
+
+/** 头 C(对话):x-initiator 决定是否消耗 premium request。 */
+export function copilotChatHeaders(options: {
+ copilotToken: string;
+ deviceId: string;
+ cacheKey: string | null;
+ initiator: Initiator;
+ vision: boolean;
+}): Record {
+ const headers = copilotBaseHeaders(options.copilotToken, options.deviceId, "conversation-agent");
+ headers["content-type"] = "application/json";
+ if (options.cacheKey !== null) headers["x-interaction-id"] = options.cacheKey;
+ headers["x-initiator"] = options.initiator;
+ if (options.vision) headers["copilot-vision-request"] = "true";
+ return headers;
+}
diff --git a/server/plugins/build-in/copilot-auth/copilot_test.ts b/server/plugins/build-in/copilot-auth/copilot_test.ts
new file mode 100644
index 000000000..af713ded1
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/copilot_test.ts
@@ -0,0 +1,706 @@
+import type {
+ JsonValue,
+ NetworkEventStream,
+ NetworkResponse,
+ PluginContext,
+} from "cursor-byok:plugin";
+import type { ModelSnapshot } from "cursor-byok:model";
+import type { LlmMessage, LlmRequest, ModelEvent } from "cursor-byok:provider";
+import type { ResourceSnapshot } from "cursor-byok:resource";
+import {
+ COPILOT_API_HOSTS,
+ COPILOT_TOKEN_URL,
+ COPILOT_USER_URL,
+ copilotChatHeaders,
+ copilotModelHeaders,
+} from "./constants.ts";
+import { copilotModels, parseCopilotModels, routeFor } from "./models.ts";
+import { githubDeviceOAuth } from "./oauth.ts";
+import { copilotProvider, initiator, isBareForbidden } from "./provider.ts";
+import {
+ type AccountData,
+ parseCopilotUser,
+ presentAccount,
+ refreshAccount,
+ RESOURCE_TYPE,
+} from "./resources.ts";
+import { exchangeCopilotToken, isFresh, NO_COPILOT_MESSAGE } from "./token.ts";
+
+function assert(condition: unknown, message = "assertion failed"): asserts condition {
+ if (!condition) throw new Error(message);
+}
+
+function assertEquals(actual: unknown, expected: unknown): void {
+ const left = JSON.stringify(actual);
+ const right = JSON.stringify(expected);
+ if (left !== right) throw new Error(`expected ${right}, received ${left}`);
+}
+
+async function assertRejects(promise: Promise, includes: string): Promise {
+ try {
+ await promise;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ assert(message.includes(includes), `expected error containing "${includes}", got "${message}"`);
+ return;
+ }
+ throw new Error(`expected rejection containing "${includes}"`);
+}
+
+type RequestInit = { method?: string; body?: string; headers?: Record };
+type FetchHandler = (url: string, init?: RequestInit) => NetworkResponse;
+type StreamHandler = (url: string, init?: RequestInit) => NetworkEventStream;
+
+function context(handlers: { fetch?: FetchHandler; stream?: StreamHandler }): PluginContext {
+ return {
+ network: {
+ fetch: (url, init) => {
+ if (!handlers.fetch) throw new Error(`fetch was not expected: ${url}`);
+ return Promise.resolve(handlers.fetch(url, init));
+ },
+ stream: (url, init) => {
+ if (!handlers.stream) throw new Error(`stream was not expected: ${url}`);
+ return Promise.resolve(handlers.stream(url, init));
+ },
+ },
+ signal: new AbortController().signal,
+ };
+}
+
+function json(status: number, body: unknown): NetworkResponse {
+ return { status, headers: {}, body: typeof body === "string" ? body : JSON.stringify(body) };
+}
+
+async function* sse(lines: string[]): AsyncGenerator {
+ for (const line of lines) yield line;
+}
+
+function streamOf(status: number, lines: string[]): NetworkEventStream {
+ return { status, headers: {}, lines: sse(lines) };
+}
+
+const CHAT_DONE = [
+ 'data: {"choices":[{"delta":{"content":"ok"}}]}',
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
+ "data: [DONE]",
+];
+const RESPONSES_DONE = [
+ 'data: {"type":"response.output_text.delta","delta":"ok"}',
+ 'data: {"type":"response.completed","response":{}}',
+];
+
+const API_BASE = "https://api.individual.githubcopilot.com";
+
+function tokenResponse(token = "copilot-new"): NetworkResponse {
+ return json(200, {
+ token,
+ expires_at: Math.floor(Date.now() / 1000) + 1800,
+ refresh_in: 1500,
+ endpoints: { api: API_BASE },
+ });
+}
+
+function userResponse(): NetworkResponse {
+ return json(200, {
+ login: "Octo-Cat",
+ copilot_plan: "individual",
+ quota_reset_date: "2026-11-01",
+ quota_snapshots: {
+ premium_interactions: { percent_remaining: 72.5, unlimited: false },
+ },
+ });
+}
+
+function account(overrides: Partial = {}): AccountData {
+ return {
+ githubToken: "gho_secret",
+ deviceId: "device-1",
+ login: "Octo-Cat",
+ plan: "individual",
+ copilotToken: "copilot-cached",
+ copilotTokenExpiresAtMs: Date.now() + 20 * 60 * 1000,
+ apiBase: API_BASE,
+ quota: { percentRemaining: 50, unlimited: false, resetAtMs: null },
+ ...overrides,
+ };
+}
+
+function snapshot(data: AccountData): ResourceSnapshot {
+ return {
+ id: "resource-1",
+ type: RESOURCE_TYPE,
+ key: data.login.toLowerCase(),
+ privateData: data as unknown as JsonValue,
+ state: { status: "ready" },
+ };
+}
+
+const chatModel: ModelSnapshot = {
+ id: "claude-sonnet-4.5",
+ displayName: "Claude Sonnet 4.5",
+ privateData: { route: "chat", vendor: "Anthropic", reasoningEfforts: [] },
+};
+
+const responsesModel: ModelSnapshot = {
+ id: "gpt-5.2",
+ displayName: "GPT-5.2",
+ privateData: {
+ route: "responses",
+ vendor: "OpenAI",
+ reasoningEfforts: ["low", "medium", "high"],
+ },
+};
+
+function request(overrides: Partial = {}): LlmRequest {
+ return {
+ instructions: "You are a coding assistant.",
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
+ tools: [],
+ reasoning: { enabled: true, effort: "xhigh" },
+ latency: "fast",
+ maxOutputTokens: 32_000,
+ cacheKey: "conversation-1",
+ ...overrides,
+ };
+}
+
+function output(events: ModelEvent[] = []) {
+ return { emit: (event: ModelEvent) => void events.push(event) };
+}
+
+const assistantTurn: LlmMessage = {
+ role: "assistant",
+ text: "",
+ thinking: "",
+ replayState: null,
+ toolCalls: [],
+};
+
+Deno.test("device-code begin posts JSON with the VS Code client id", async () => {
+ let sent: RequestInit | undefined;
+ const begin = await githubDeviceOAuth.begin(context({
+ fetch: (url, init) => {
+ assertEquals(url, "https://github.com/login/device/code");
+ sent = init;
+ return json(200, {
+ device_code: "device-code",
+ user_code: "ABCD-1234",
+ verification_uri: "https://github.com/login/device",
+ expires_in: 900,
+ interval: 0,
+ });
+ },
+ }));
+ assertEquals(JSON.parse(sent?.body ?? "{}"), {
+ client_id: "Iv1.b507a08c87ecfe98",
+ scope: "read:user",
+ });
+ assertEquals(sent?.headers?.["content-type"], "application/json");
+ assertEquals(begin.session, { deviceCode: "device-code" });
+ assertEquals(begin.userCode, "ABCD-1234");
+ assertEquals(begin.verificationUrl, "https://github.com/login/device");
+ assertEquals(begin.pollIntervalMs, 1000);
+});
+
+Deno.test("device-code poll maps GitHub 200 error bodies to host states", async () => {
+ const pollWith = (response: NetworkResponse) =>
+ githubDeviceOAuth.poll({ deviceCode: "device-code" }, context({ fetch: () => response }));
+ assertEquals(await pollWith(json(200, { error: "authorization_pending" })), {
+ status: "pending",
+ });
+ assertEquals(await pollWith(json(200, { error: "slow_down" })), { status: "slow-down" });
+ assertEquals(await pollWith(json(200, { error: "access_denied" })), { status: "denied" });
+ assertEquals((await pollWith(json(200, { error: "expired_token" }))).status, "failed");
+ assertEquals(
+ await pollWith(json(200, { error: "unsupported_grant_type", error_description: "bad grant" })),
+ { status: "failed", message: "bad grant" },
+ );
+ assertEquals(await pollWith(json(502, "bad gateway")), { status: "pending" });
+});
+
+Deno.test("device-code poll completes with a full account draft keyed by lowercase login", async () => {
+ const result = await githubDeviceOAuth.poll(
+ { deviceCode: "device-code" },
+ context({
+ fetch: (url, init) => {
+ if (url === "https://github.com/login/oauth/access_token") {
+ assertEquals(JSON.parse(init?.body ?? "{}").device_code, "device-code");
+ return json(200, { access_token: "gho_secret", token_type: "bearer" });
+ }
+ assertEquals(init?.headers?.authorization, "token gho_secret");
+ if (url === COPILOT_USER_URL) return userResponse();
+ if (url === COPILOT_TOKEN_URL) return tokenResponse();
+ throw new Error(`unexpected ${url}`);
+ },
+ }),
+ );
+ assert(result.status === "completed");
+ assertEquals(result.resources.length, 1);
+ const draft = result.resources[0];
+ assertEquals(draft.key, "octo-cat");
+ const data = draft.privateData as unknown as AccountData;
+ assertEquals(data.githubToken, "gho_secret");
+ assertEquals(data.login, "Octo-Cat");
+ assertEquals(data.plan, "individual");
+ assertEquals(data.copilotToken, "copilot-new");
+ assertEquals(data.apiBase, API_BASE);
+ assertEquals(data.quota, {
+ percentRemaining: 72.5,
+ unlimited: false,
+ resetAtMs: Date.parse("2026-11-01"),
+ });
+ assert(typeof data.deviceId === "string" && data.deviceId.length > 0);
+ assert(typeof data.copilotTokenExpiresAtMs === "number");
+});
+
+Deno.test("device-code poll fails clearly when the account has no Copilot seat", async () => {
+ const result = await githubDeviceOAuth.poll(
+ { deviceCode: "device-code" },
+ context({
+ fetch: (url) =>
+ url === COPILOT_USER_URL
+ ? json(404, { message: "Not Found" })
+ : json(200, { access_token: "gho_secret" }),
+ }),
+ );
+ assertEquals(result, { status: "failed", message: NO_COPILOT_MESSAGE });
+});
+
+Deno.test("copilot token renews when fewer than five minutes remain", () => {
+ const now = 1_700_000_000_000;
+ assert(!isFresh("token", now + 4 * 60 * 1000, now));
+ assert(isFresh("token", now + 6 * 60 * 1000, now));
+ assert(!isFresh(null, now + 60 * 60 * 1000, now));
+});
+
+Deno.test("token exchange rejects Copilot API hosts outside the plugin allow-list", async () => {
+ await assertRejects(
+ exchangeCopilotToken(
+ "gho_secret",
+ context({
+ fetch: () =>
+ json(200, {
+ token: "copilot",
+ expires_at: 1,
+ endpoints: { api: "https://api.unknown.githubcopilot.com" },
+ }),
+ }),
+ ),
+ "api.unknown.githubcopilot.com",
+ );
+ const fallback = await exchangeCopilotToken(
+ "gho_secret",
+ context({ fetch: () => json(200, { token: "copilot", expires_at: 2 }) }),
+ );
+ assertEquals(fallback, {
+ token: "copilot",
+ expiresAtMs: 2000,
+ apiBase: "https://api.githubcopilot.com",
+ });
+});
+
+Deno.test("Copilot API hosts stay in sync with plugin.json network permissions", async () => {
+ const manifest = JSON.parse(await Deno.readTextFile(new URL("./plugin.json", import.meta.url)));
+ for (const host of COPILOT_API_HOSTS) {
+ assert(manifest.permissions.network.includes(host), `${host} missing from plugin.json`);
+ }
+});
+
+Deno.test("model routing follows supported endpoints and vendor", () => {
+ assertEquals(routeFor(undefined, "OpenAI"), "chat");
+ assertEquals(routeFor(["/responses"], "OpenAI"), "responses");
+ assertEquals(routeFor(["/chat/completions", "/responses"], "OpenAI"), "responses");
+ assertEquals(routeFor(["/chat/completions", "/v1/messages"], "Anthropic"), "chat");
+ assertEquals(routeFor(["/chat/completions", "/responses"], "Anthropic"), "chat");
+ assertEquals(routeFor(["/v1/messages"], "Anthropic"), null);
+});
+
+Deno.test("model parsing keeps enabled picker chat models only", () => {
+ const chat = (id: string, extra: Record = {}) => ({
+ id,
+ name: id.toUpperCase(),
+ vendor: "OpenAI",
+ model_picker_enabled: true,
+ capabilities: {
+ type: "chat",
+ limits: { max_output_tokens: 64_000 },
+ supports: { vision: true, reasoning_effort: ["low", "high"] },
+ },
+ supported_endpoints: ["/chat/completions", "/responses"],
+ ...extra,
+ });
+ const models = parseCopilotModels({
+ data: [
+ chat("gpt-5.2"),
+ chat("gpt-5.2"),
+ chat("hidden", { model_picker_enabled: false }),
+ chat("disabled", { policy: { state: "disabled" } }),
+ chat("enabled", { policy: { state: "enabled" } }),
+ chat("embedding", { capabilities: { type: "embeddings" } }),
+ chat("claude-opus", { vendor: "Anthropic", supported_endpoints: ["/v1/messages"] }),
+ chat("legacy", { supported_endpoints: undefined }),
+ ],
+ });
+ assertEquals(models.map((model) => model.id), ["gpt-5.2", "enabled", "legacy"]);
+ assertEquals(models[0], {
+ id: "gpt-5.2",
+ displayName: "GPT-5.2",
+ maxOutputTokens: 64_000,
+ capabilities: { images: true },
+ privateData: {
+ route: "responses",
+ vendor: "OpenAI",
+ reasoningEfforts: ["low", "high"],
+ },
+ });
+ assertEquals((models[2].privateData as { route: string }).route, "chat");
+});
+
+Deno.test("model sync uses model-access headers and refuses an empty catalog", async () => {
+ let headers: Record | undefined;
+ const ctx = (data: unknown[]) =>
+ context({
+ fetch: (url, init) => {
+ assertEquals(url, `${API_BASE}/models`);
+ headers = init?.headers;
+ return json(200, { data });
+ },
+ });
+ const models = await copilotModels.list(
+ { resource: snapshot(account()) },
+ ctx([{
+ id: "gpt-4.1",
+ name: "GPT-4.1",
+ model_picker_enabled: true,
+ capabilities: { type: "chat" },
+ }]),
+ );
+ assertEquals(models.map((model) => model.id), ["gpt-4.1"]);
+ assertEquals(headers?.authorization, "Bearer copilot-cached");
+ assertEquals(headers?.["openai-intent"], "model-access");
+ assertEquals(headers?.["x-interaction-type"], "model-access");
+ assert(!("x-initiator" in (headers ?? {})), "/models must not send x-initiator");
+ assert(!("content-type" in (headers ?? {})), "/models must not send content-type");
+ assert(!("x-interaction-id" in (headers ?? {})), "/models must not send x-interaction-id");
+ await assertRejects(copilotModels.list({ resource: snapshot(account()) }, ctx([])), "no chat");
+ await assertRejects(copilotModels.list({ resource: null }, ctx([])), "add a GitHub Copilot");
+});
+
+Deno.test("x-initiator charges only user-initiated turns", () => {
+ const user: LlmMessage = { role: "user", content: [{ type: "text", text: "hi" }] };
+ const tool: LlmMessage = {
+ role: "tool",
+ callId: "call-1",
+ name: "read_file",
+ content: "ok",
+ isError: false,
+ parts: [],
+ };
+ assertEquals(initiator([]), "user");
+ assertEquals(initiator([user]), "user");
+ assertEquals(initiator([user, assistantTurn, tool]), "agent");
+ assertEquals(initiator([user, assistantTurn]), "agent");
+ assertEquals(initiator([user, assistantTurn, tool, user]), "user");
+});
+
+Deno.test("chat headers carry the VS Code identity and optional vision flag", () => {
+ const plain = copilotChatHeaders({
+ copilotToken: "copilot",
+ deviceId: "device-1",
+ cacheKey: "conversation-1",
+ initiator: "agent",
+ vision: false,
+ });
+ assertEquals(plain.authorization, "Bearer copilot");
+ assertEquals(plain["copilot-integration-id"], "vscode-chat");
+ assertEquals(plain["editor-device-id"], "device-1");
+ assertEquals(plain["openai-intent"], "conversation-agent");
+ assertEquals(plain["x-interaction-id"], "conversation-1");
+ assertEquals(plain["x-initiator"], "agent");
+ assertEquals(plain["x-request-id"], plain["x-agent-task-id"]);
+ assert(!("copilot-vision-request" in plain));
+ const vision = copilotChatHeaders({
+ copilotToken: "copilot",
+ deviceId: "device-1",
+ cacheKey: null,
+ initiator: "user",
+ vision: true,
+ });
+ assertEquals(vision["copilot-vision-request"], "true");
+ assert(!("x-interaction-id" in vision));
+ assert(
+ copilotModelHeaders("copilot", "device-1")["x-request-id"] !== vision["x-request-id"],
+ "every request needs a fresh request id",
+ );
+});
+
+Deno.test("chat route sends max_tokens, supported effort only and no service tier", async () => {
+ let body: Record = {};
+ let headers: Record = {};
+ const events: ModelEvent[] = [];
+ const result = await copilotProvider.invoke(
+ {
+ model: chatModel,
+ resource: snapshot(account()),
+ request: request({
+ messages: [{
+ role: "user",
+ content: [{ type: "image", mediaType: "image/png", dataBase64: "AAAA" }],
+ }],
+ }),
+ },
+ output(events),
+ context({
+ stream: (url, init) => {
+ assertEquals(url, `${API_BASE}/chat/completions`);
+ body = JSON.parse(init?.body ?? "{}");
+ headers = init?.headers ?? {};
+ return streamOf(200, CHAT_DONE);
+ },
+ }),
+ );
+ assertEquals(result, { status: "completed" });
+ assertEquals(body.max_tokens, 32_000);
+ assert(!("max_completion_tokens" in body), "chat must not send max_completion_tokens");
+ assert(!("prompt_cache_key" in body), "chat must not send prompt_cache_key yet");
+ assert(!("service_tier" in body), "Copilot rejects service_tier");
+ assert(!("reasoning_effort" in body), "unsupported effort must be dropped");
+ assertEquals(headers["x-initiator"], "user");
+ assertEquals(headers["copilot-vision-request"], "true");
+ assertEquals(headers["x-interaction-id"], "conversation-1");
+ assertEquals(events.at(-1), { type: "done", reason: "stop" });
+});
+
+Deno.test("responses route keeps max_output_tokens and cache key and disables storage", async () => {
+ let body: Record = {};
+ const result = await copilotProvider.invoke(
+ {
+ model: responsesModel,
+ resource: snapshot(account()),
+ request: request({ reasoning: { enabled: true, effort: "high" } }),
+ },
+ output(),
+ context({
+ stream: (url, init) => {
+ assertEquals(url, `${API_BASE}/responses`);
+ body = JSON.parse(init?.body ?? "{}");
+ return streamOf(200, RESPONSES_DONE);
+ },
+ }),
+ );
+ assertEquals(result, { status: "completed" });
+ assertEquals(body.max_output_tokens, 32_000);
+ assertEquals(body.prompt_cache_key, "conversation-1");
+ assertEquals(body.store, false);
+ assertEquals(body.reasoning, { summary: "auto", effort: "high" });
+ assert(!("service_tier" in body), "Copilot rejects service_tier");
+});
+
+Deno.test("expired Copilot token is exchanged and written back on completion", async () => {
+ let authorization = "";
+ const result = await copilotProvider.invoke(
+ {
+ model: chatModel,
+ resource: snapshot(account({ copilotTokenExpiresAtMs: Date.now() + 4 * 60 * 1000 })),
+ request: request(),
+ },
+ output(),
+ context({
+ fetch: (url) => {
+ assertEquals(url, COPILOT_TOKEN_URL);
+ return tokenResponse("copilot-new");
+ },
+ stream: (_url, init) => {
+ authorization = init?.headers?.authorization ?? "";
+ return streamOf(200, CHAT_DONE);
+ },
+ }),
+ );
+ assertEquals(authorization, "Bearer copilot-new");
+ assert(result.status === "completed" && result.patch !== undefined);
+ assertEquals((result.patch.privateData as unknown as AccountData).copilotToken, "copilot-new");
+ assertEquals(result.patch.state, undefined);
+});
+
+Deno.test("HTTP 401 forces one token re-exchange and retries", async () => {
+ const tokens: string[] = [];
+ let exchanges = 0;
+ const result = await copilotProvider.invoke(
+ { model: chatModel, resource: snapshot(account()), request: request() },
+ output(),
+ context({
+ fetch: () => {
+ exchanges++;
+ return tokenResponse("copilot-renewed");
+ },
+ stream: (_url, init) => {
+ tokens.push(init?.headers?.authorization ?? "");
+ return tokens.length === 1
+ ? streamOf(401, ['{"message":"unauthorized"}'])
+ : streamOf(200, CHAT_DONE);
+ },
+ }),
+ );
+ assertEquals(exchanges, 1);
+ assertEquals(tokens, ["Bearer copilot-cached", "Bearer copilot-renewed"]);
+ assert(result.status === "completed" && result.patch !== undefined);
+ assertEquals(
+ (result.patch.privateData as unknown as AccountData).copilotToken,
+ "copilot-renewed",
+ );
+});
+
+Deno.test("persistent HTTP 401 marks the account invalid", async () => {
+ const result = await copilotProvider.invoke(
+ { model: chatModel, resource: snapshot(account()), request: request() },
+ output(),
+ context({
+ fetch: () => tokenResponse("copilot-renewed"),
+ stream: () => streamOf(401, ['{"message":"unauthorized"}']),
+ }),
+ );
+ assert(result.status === "resource-error");
+ assertEquals(result.patch.state, {
+ status: "invalid",
+ message: "GitHub authorization expired; sign in again",
+ });
+});
+
+Deno.test("bare 403 from the Copilot edge is retried", async () => {
+ assert(isBareForbidden(""));
+ assert(isBareForbidden("Forbidden."));
+ assert(isBareForbidden('{"error":{"message":"forbidden"}}'));
+ assert(!isBareForbidden('{"error":{"message":"Model is not enabled by policy"}}'));
+ let calls = 0;
+ const result = await copilotProvider.invoke(
+ { model: chatModel, resource: snapshot(account()), request: request() },
+ output(),
+ context({
+ stream: () => {
+ calls++;
+ return calls === 1 ? streamOf(403, ["forbidden"]) : streamOf(200, CHAT_DONE);
+ },
+ }),
+ );
+ assertEquals(calls, 2);
+ assertEquals(result, { status: "completed" });
+});
+
+Deno.test("403 with a concrete reason is a request error and keeps the account usable", async () => {
+ let calls = 0;
+ const result = await copilotProvider.invoke(
+ { model: chatModel, resource: snapshot(account()), request: request() },
+ output(),
+ context({
+ stream: () => {
+ calls++;
+ return streamOf(403, ['{"error":{"message":"Model is not enabled by policy"}}']);
+ },
+ }),
+ );
+ assertEquals(calls, 1);
+ assert(result.status === "request-error");
+ assert(result.message.includes("not enabled by policy"));
+ assertEquals(result.patch, undefined);
+});
+
+Deno.test("premium quota 429 cools the account after retries", async () => {
+ let calls = 0;
+ const resetAtMs = Date.now() + 7 * 24 * 60 * 60 * 1000;
+ const result = await copilotProvider.invoke(
+ {
+ model: chatModel,
+ resource: snapshot(account({
+ quota: { percentRemaining: 0, unlimited: false, resetAtMs },
+ })),
+ request: request(),
+ },
+ output(),
+ context({
+ stream: () => {
+ calls++;
+ return streamOf(429, ['{"error":{"message":"premium request quota exceeded"}}']);
+ },
+ }),
+ );
+ assertEquals(calls, 3);
+ assert(result.status === "resource-error");
+ assertEquals(result.patch.state, {
+ status: "cooling",
+ retryAtMs: resetAtMs,
+ message: "Copilot premium requests are exhausted",
+ });
+});
+
+Deno.test("in-stream errors are not retried", async () => {
+ let calls = 0;
+ const result = await copilotProvider.invoke(
+ { model: chatModel, resource: snapshot(account()), request: request() },
+ output(),
+ context({
+ stream: () => {
+ calls++;
+ return streamOf(200, ['data: {"error":{"message":"boom"}}']);
+ },
+ }),
+ );
+ assertEquals(calls, 1);
+ assertEquals(result.status, "request-error");
+});
+
+Deno.test("account view shows plan and premium quota without credentials", () => {
+ const view = presentAccount(snapshot(account({
+ quota: { percentRemaining: 40, unlimited: false, resetAtMs: 1_800_000_000_000 },
+ })));
+ assertEquals(view, {
+ displayName: "Octo-Cat",
+ description: "Copilot individual",
+ metrics: [{
+ id: "premium",
+ label: { "zh-CN": "Premium 请求剩余", "en-US": "Premium requests left" },
+ unit: "percent",
+ value: 40,
+ resetAtMs: 1_800_000_000_000,
+ }],
+ });
+ const serialized = JSON.stringify(view);
+ assert(!serialized.includes("gho_secret") && !serialized.includes("copilot-cached"));
+ const unlimited = presentAccount(snapshot(account({
+ plan: null,
+ quota: { percentRemaining: 100, unlimited: true, resetAtMs: null },
+ })));
+ assertEquals(unlimited, { displayName: "Octo-Cat", description: "GitHub Copilot" });
+});
+
+Deno.test("refresh updates quota and token, and invalidates revoked GitHub tokens", async () => {
+ const patch = await refreshAccount(
+ snapshot(account()),
+ context({
+ fetch: (url) => url === COPILOT_USER_URL ? userResponse() : tokenResponse("copilot-fresh"),
+ }),
+ );
+ assertEquals(patch.state, { status: "ready" });
+ const data = patch.privateData as unknown as AccountData;
+ assertEquals(data.copilotToken, "copilot-fresh");
+ assertEquals(data.quota?.percentRemaining, 72.5);
+ assertEquals(data.deviceId, "device-1");
+
+ const revoked = await refreshAccount(
+ snapshot(account()),
+ context({ fetch: () => json(401, { message: "Bad credentials" }) }),
+ );
+ assertEquals(revoked, {
+ state: { status: "invalid", message: "GitHub authorization expired; sign in again" },
+ });
+});
+
+Deno.test("unlimited premium quota without a percentage is treated as full", () => {
+ assertEquals(
+ parseCopilotUser({
+ login: "octo",
+ quota_snapshots: { premium_interactions: { unlimited: true } },
+ }).quota,
+ { percentRemaining: 100, unlimited: true, resetAtMs: null },
+ );
+ assertEquals(parseCopilotUser({ login: "octo" }).quota, null);
+});
diff --git a/server/plugins/build-in/copilot-auth/deno.json b/server/plugins/build-in/copilot-auth/deno.json
new file mode 100644
index 000000000..004de191e
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/deno.json
@@ -0,0 +1,14 @@
+{
+ "imports": {
+ "cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts",
+ "cursor-byok:provider": "../../../src/plugin/sdk/provider.ts",
+ "cursor-byok:model": "../../../src/plugin/sdk/model.ts",
+ "cursor-byok:resource": "../../../src/plugin/sdk/resource.ts",
+ "cursor-byok:protocol/openai-chat": "../../../src/plugin/sdk/protocol/openai_chat.ts",
+ "cursor-byok:protocol/openai-responses": "../../../src/plugin/sdk/protocol/openai_responses.ts"
+ },
+ "fmt": {
+ "lineWidth": 100,
+ "exclude": ["assets"]
+ }
+}
diff --git a/server/plugins/build-in/copilot-auth/main.ts b/server/plugins/build-in/copilot-auth/main.ts
new file mode 100644
index 000000000..f33ffecea
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/main.ts
@@ -0,0 +1,15 @@
+import { defineProviderPlugin } from "cursor-byok:plugin";
+import { githubDeviceOAuth } from "./oauth.ts";
+import { copilotProvider } from "./provider.ts";
+import { presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts";
+
+export default defineProviderPlugin({
+ providers: [copilotProvider],
+ resources: [{
+ type: RESOURCE_TYPE,
+ displayName: { "en-US": "GitHub Copilot accounts", "zh-CN": "GitHub Copilot 账号" },
+ add: [githubDeviceOAuth],
+ present: presentAccount,
+ refresh: refreshAccount,
+ }],
+});
diff --git a/server/plugins/build-in/copilot-auth/models.ts b/server/plugins/build-in/copilot-auth/models.ts
new file mode 100644
index 000000000..3285c6164
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/models.ts
@@ -0,0 +1,120 @@
+import type { ModelDefinition, ModelSnapshot, ModelSupport } from "cursor-byok:model";
+import { copilotModelHeaders } from "./constants.ts";
+import { accountData, ensureToken } from "./resources.ts";
+
+/** 模型走哪个 Copilot 端点;v1 不支持仅 /v1/messages 的模型。 */
+export type ModelRoute = "chat" | "responses";
+
+/** 存进 ModelDefinition.privateData,invoke 时原样传回。 */
+export type CopilotModelData = {
+ route: ModelRoute;
+ vendor: string | null;
+ reasoningEfforts: string[];
+};
+
+function object(value: unknown): Record | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : null;
+}
+
+function text(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function number(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function strings(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.flatMap((item) => (typeof item === "string" ? [item] : []))
+ : [];
+}
+
+/**
+ * 缺少 supported_endpoints 的旧模型走 chat;GPT 系(含仅支持 responses 的 codex)走 responses;
+ * Claude 等走 chat;只剩 /v1/messages 的模型返回 null 并跳过。
+ */
+export function routeFor(supportedEndpoints: unknown, vendor: string | null): ModelRoute | null {
+ if (!Array.isArray(supportedEndpoints)) return "chat";
+ const endpoints = strings(supportedEndpoints);
+ if (endpoints.includes("/responses") && vendor?.toLowerCase() !== "anthropic") {
+ return "responses";
+ }
+ if (endpoints.includes("/chat/completions")) return "chat";
+ return null;
+}
+
+/** 解析 `GET {apiBase}/models`,只保留模型选择器中已启用的对话模型。 */
+export function parseCopilotModels(body: unknown): ModelDefinition[] {
+ const source = object(body)?.data;
+ if (!Array.isArray(source)) {
+ throw new Error("Copilot model list response does not contain a data array");
+ }
+ const seen = new Set();
+ const models: ModelDefinition[] = [];
+ for (const raw of source) {
+ const model = object(raw);
+ const id = text(model?.id);
+ if (!model || !id || seen.has(id)) continue;
+ const capabilities = object(model.capabilities);
+ const policy = object(model.policy);
+ if (capabilities?.type !== "chat" || model.model_picker_enabled !== true) continue;
+ if (policy && policy.state !== "enabled") continue;
+ const vendor = text(model.vendor);
+ const route = routeFor(model.supported_endpoints, vendor);
+ if (!route) continue;
+ seen.add(id);
+ const limits = object(capabilities.limits);
+ const supports = object(capabilities.supports);
+ const maxOutputTokens = number(limits?.max_output_tokens);
+ const privateData: CopilotModelData = {
+ route,
+ vendor,
+ reasoningEfforts: strings(supports?.reasoning_effort),
+ };
+ models.push({
+ id,
+ displayName: text(model.name) ?? id,
+ ...(maxOutputTokens !== null ? { maxOutputTokens } : {}),
+ capabilities: { images: supports?.vision === true },
+ privateData,
+ });
+ }
+ return models;
+}
+
+export function modelRoute(model: ModelSnapshot): ModelRoute | null {
+ const route = object(model.privateData)?.route;
+ return route === "chat" || route === "responses" ? route : null;
+}
+
+export function reasoningEfforts(model: ModelSnapshot): string[] {
+ return strings(object(model.privateData)?.reasoningEfforts);
+}
+
+export const copilotModels: ModelSupport = {
+ list: async ({ resource }, context): Promise => {
+ if (!resource) throw new Error("add a GitHub Copilot account before syncing models");
+ // list 无法写回资源补丁,续期得到的 token 只在本次使用。
+ const { data, token, apiBase } = await ensureToken(accountData(resource), context);
+ const response = await context.network.fetch(`${apiBase}/models`, {
+ method: "GET",
+ headers: copilotModelHeaders(token, data.deviceId),
+ });
+ if (response.status < 200 || response.status >= 300) {
+ throw new Error(`Copilot model discovery failed (HTTP ${response.status}): ${response.body}`);
+ }
+ let body: unknown;
+ try {
+ body = JSON.parse(response.body);
+ } catch {
+ throw new Error("Copilot model discovery returned invalid JSON");
+ }
+ const models = parseCopilotModels(body);
+ // 空列表会清空模型目录,宁可报错保留旧目录。
+ if (models.length === 0) throw new Error("Copilot returned no chat models for this account");
+ return models;
+ },
+};
diff --git a/server/plugins/build-in/copilot-auth/oauth.ts b/server/plugins/build-in/copilot-auth/oauth.ts
new file mode 100644
index 000000000..ce0663ed4
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/oauth.ts
@@ -0,0 +1,124 @@
+import type { JsonValue, PluginContext } from "cursor-byok:plugin";
+import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
+import { ACCESS_TOKEN_URL, CLIENT_ID, DEVICE_CODE_URL, oauthHeaders, SCOPE } from "./constants.ts";
+import { accountDraft } from "./resources.ts";
+import { CopilotAuthError, NO_COPILOT_MESSAGE } from "./token.ts";
+
+type Session = {
+ deviceCode: string;
+};
+
+function object(value: unknown): Record | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : null;
+}
+
+function text(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function number(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function parseBody(body: string): Record | null {
+ try {
+ return object(JSON.parse(body));
+ } catch {
+ return null;
+ }
+}
+
+function parseSession(value: JsonValue): Session {
+ const deviceCode = text(object(value)?.deviceCode);
+ if (!deviceCode) throw new Error("GitHub OAuth session is invalid");
+ return { deviceCode };
+}
+
+async function begin(context: PluginContext): Promise {
+ const response = await context.network.fetch(DEVICE_CODE_URL, {
+ method: "POST",
+ headers: oauthHeaders(),
+ body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
+ });
+ if (response.status < 200 || response.status >= 300) {
+ throw new Error(
+ `Failed to request a GitHub device code (HTTP ${response.status}): ${response.body}`,
+ );
+ }
+ const body = parseBody(response.body);
+ const deviceCode = text(body?.device_code);
+ const userCode = text(body?.user_code);
+ const verificationUrl = text(body?.verification_uri);
+ if (!deviceCode || !userCode || !verificationUrl) {
+ throw new Error("GitHub device authorization response is incomplete");
+ }
+ const session: Session = { deviceCode };
+ return {
+ session: session as unknown as JsonValue,
+ userCode,
+ verificationUrl,
+ expiresAtMs: Date.now() + Math.max(1, number(body?.expires_in) ?? 900) * 1000,
+ pollIntervalMs: Math.max(1, number(body?.interval) ?? 5) * 1000,
+ };
+}
+
+async function poll(sessionValue: JsonValue, context: PluginContext): Promise {
+ const session = parseSession(sessionValue);
+ const response = await context.network.fetch(ACCESS_TOKEN_URL, {
+ method: "POST",
+ headers: oauthHeaders(),
+ body: JSON.stringify({
+ client_id: CLIENT_ID,
+ device_code: session.deviceCode,
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+ }),
+ });
+ // GitHub 在待授权时也返回 HTTP 200,状态写在 body.error 里,所以先看 body。
+ const body = parseBody(response.body);
+ const githubToken = text(body?.access_token);
+ if (githubToken) {
+ try {
+ return { status: "completed", resources: [await accountDraft(githubToken, context)] };
+ } catch (error) {
+ if (error instanceof CopilotAuthError) {
+ return { status: "failed", message: NO_COPILOT_MESSAGE };
+ }
+ throw error;
+ }
+ }
+ const code = text(body?.error);
+ const description = text(body?.error_description);
+ switch (code) {
+ case "authorization_pending":
+ return { status: "pending" };
+ case "slow_down":
+ return { status: "slow-down" };
+ case "expired_token":
+ return { status: "failed", message: "Device code expired; sign in again" };
+ case "access_denied":
+ return { status: "denied", ...(description ? { message: description } : {}) };
+ case null:
+ // 非 2xx 且没有可识别的 body 视为瞬时故障,交给宿主继续轮询。
+ if (response.status < 200 || response.status >= 300) return { status: "pending" };
+ return { status: "failed", message: "GitHub token response is missing access_token" };
+ default:
+ return { status: "failed", message: description ?? code };
+ }
+}
+
+export const githubDeviceOAuth: OAuth2AddMethod = {
+ type: "oauth2.0",
+ id: "github-device",
+ displayName: {
+ "en-US": "Sign in with GitHub",
+ "zh-CN": "使用 GitHub 登录",
+ },
+ description: {
+ "en-US": "Authorize this device on GitHub, then add the Copilot subscription of that account.",
+ "zh-CN": "在 GitHub 完成设备授权后,自动添加该账号的 Copilot 订阅。",
+ },
+ begin,
+ poll,
+};
diff --git a/server/plugins/build-in/copilot-auth/plugin.json b/server/plugins/build-in/copilot-auth/plugin.json
new file mode 100644
index 000000000..ea9ed838f
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/plugin.json
@@ -0,0 +1,20 @@
+{
+ "apiVersion": 1,
+ "id": "dev.cursorbyok.community.copilot-auth",
+ "name": "GitHub Copilot",
+ "version": "0.1.0",
+ "author": "@CharlesYWL",
+ "minAppVersion": "0.1.0",
+ "icon": "assets/copilot.svg",
+ "entry": "main.ts",
+ "permissions": {
+ "network": [
+ "github.com",
+ "api.github.com",
+ "api.githubcopilot.com",
+ "api.individual.githubcopilot.com",
+ "api.business.githubcopilot.com",
+ "api.enterprise.githubcopilot.com"
+ ]
+ }
+}
diff --git a/server/plugins/build-in/copilot-auth/provider.ts b/server/plugins/build-in/copilot-auth/provider.ts
new file mode 100644
index 000000000..33c0b5f3c
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/provider.ts
@@ -0,0 +1,273 @@
+import type { JsonValue, PluginContext } from "cursor-byok:plugin";
+import type {
+ LlmMessage,
+ LlmRequest,
+ ProviderInvokeInput,
+ ProviderOutput,
+ ProviderResult,
+ ProviderSupport,
+} from "cursor-byok:provider";
+import type { ResourcePatch } from "cursor-byok:resource";
+import { HttpError as ChatHttpError, streamOpenAiChat } from "cursor-byok:protocol/openai-chat";
+import {
+ HttpError as ResponsesHttpError,
+ streamOpenAiResponses,
+} from "cursor-byok:protocol/openai-responses";
+import { copilotChatHeaders, type Initiator } from "./constants.ts";
+import { copilotModels, type ModelRoute, modelRoute, reasoningEfforts } from "./models.ts";
+import {
+ type AccountData,
+ accountData,
+ type ActiveToken,
+ ensureToken,
+ RESOURCE_TYPE,
+} from "./resources.ts";
+import { AUTH_EXPIRED_MESSAGE, CopilotAuthError } from "./token.ts";
+
+const MAX_ATTEMPTS = 3;
+const RETRY_BASE_DELAY_MS = 500;
+const ONE_HOUR_MS = 60 * 60 * 1000;
+
+type HttpError = ChatHttpError | ResponsesHttpError;
+
+function object(value: unknown): Record | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : null;
+}
+
+function text(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function errorText(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+/**
+ * 计费关键:最后一条是 user(或没有消息)时由用户发起,消耗 premium request;
+ * 最后一条是 tool / assistant 时是 Agent 自动续跑的回合,标记为 agent 不额外计费。
+ */
+export function initiator(messages: LlmMessage[]): Initiator {
+ const last = messages.at(-1);
+ return last?.role === "tool" || last?.role === "assistant" ? "agent" : "user";
+}
+
+export function hasImages(messages: LlmMessage[]): boolean {
+ return messages.some((message) => {
+ if (message.role === "assistant") return false;
+ const parts = message.role === "tool" ? message.parts : message.content;
+ return parts.some((part) => part.type === "image");
+ });
+}
+
+/**
+ * Copilot 不支持 service_tier,latency 一律降为 standard;只保留模型声明的推理档位。
+ * Chat 路径沿用 max_tokens,且暂不发送 prompt_cache_key;Responses 路径与 VS Code 一致发送 store: false。
+ */
+export function copilotRequest(
+ request: LlmRequest,
+ route: ModelRoute,
+ efforts: string[],
+): { request: LlmRequest; extraBody: Record } {
+ const effort = request.reasoning.effort !== null && efforts.includes(request.reasoning.effort)
+ ? request.reasoning.effort
+ : null;
+ const adjusted: LlmRequest = {
+ ...request,
+ reasoning: { enabled: request.reasoning.enabled, effort },
+ latency: "standard",
+ };
+ if (route === "responses") return { request: adjusted, extraBody: { store: false } };
+ return {
+ request: { ...adjusted, maxOutputTokens: null, cacheKey: null },
+ extraBody: request.maxOutputTokens !== null ? { max_tokens: request.maxOutputTokens } : {},
+ };
+}
+
+/** Copilot 边缘限流会返回空 body 或只有 "forbidden" 的 403,可以重试。 */
+export function isBareForbidden(body: string): boolean {
+ const trimmed = body.trim();
+ let message = trimmed;
+ try {
+ const root = object(JSON.parse(trimmed));
+ message = text(object(root?.error)?.message) ?? text(root?.error) ?? text(root?.message) ??
+ trimmed;
+ } catch {
+ message = trimmed;
+ }
+ return message === "" || /^forbidden[.!]?$/i.test(message);
+}
+
+function isRetryable(error: HttpError): boolean {
+ const status = error.status;
+ return status === 408 || status === 425 || status === 429 || status >= 500 ||
+ (status === 403 && isBareForbidden(error.body));
+}
+
+function isQuotaError(error: HttpError): boolean {
+ if (error.status !== 429) return false;
+ const body = error.body.toLowerCase();
+ return body.includes("quota") || body.includes("premium") || body.includes("exceeded");
+}
+
+function httpError(error: unknown): HttpError | null {
+ return error instanceof ChatHttpError || error instanceof ResponsesHttpError ? error : null;
+}
+
+function sleep(ms: number, signal: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(signal.reason);
+ return;
+ }
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(signal.reason);
+ };
+ const timer = setTimeout(() => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ signal.addEventListener("abort", onAbort, { once: true });
+ });
+}
+
+function invalidResult(message: string, stateMessage: string): ProviderResult {
+ return {
+ status: "resource-error",
+ message,
+ patch: { state: { status: "invalid", message: stateMessage } },
+ };
+}
+
+function tokenFailure(error: unknown): ProviderResult {
+ if (error instanceof CopilotAuthError) return invalidResult(error.message, error.message);
+ return { status: "request-error", message: errorText(error) };
+}
+
+function classify(error: HttpError, data: AccountData): ProviderResult {
+ if (error.status === 401) return invalidResult(error.message, AUTH_EXPIRED_MESSAGE);
+ if (isQuotaError(error)) {
+ const now = Date.now();
+ const resetAtMs = data.quota?.resetAtMs ?? null;
+ return {
+ status: "resource-error",
+ message: error.message,
+ patch: {
+ state: {
+ status: "cooling",
+ retryAtMs: resetAtMs !== null && resetAtMs > now ? resetAtMs : now + ONE_HOUR_MS,
+ message: "Copilot premium requests are exhausted",
+ },
+ },
+ };
+ }
+ // 带具体原因的 403(模型策略未启用等)只影响本次请求,不能把账号标为 invalid。
+ return { status: "request-error", message: error.message };
+}
+
+async function invoke(
+ input: ProviderInvokeInput,
+ output: ProviderOutput,
+ context: PluginContext,
+): Promise {
+ if (!input.resource) {
+ return {
+ status: "request-error",
+ message: "add a GitHub Copilot account before calling Copilot",
+ };
+ }
+ let data: AccountData;
+ try {
+ data = accountData(input.resource);
+ } catch (error) {
+ const message = errorText(error);
+ return invalidResult(message, message);
+ }
+ const route = modelRoute(input.model);
+ if (!route) {
+ return { status: "request-error", message: "Copilot model metadata is outdated; sync models" };
+ }
+
+ let active: ActiveToken;
+ try {
+ active = await ensureToken(data, context);
+ } catch (error) {
+ return tokenFailure(error);
+ }
+ data = active.data;
+ let dirty = active.refreshed;
+ // 续期后的 token 随每个返回分支写回资源,避免下次调用重复交换。
+ const finish = (result: ProviderResult): ProviderResult => {
+ if (!dirty) return result;
+ const patch: ResourcePatch = { ...result.patch, privateData: data as unknown as JsonValue };
+ return { ...result, patch } as ProviderResult;
+ };
+
+ const { request, extraBody } = copilotRequest(
+ input.request,
+ route,
+ reasoningEfforts(input.model),
+ );
+ const messages = input.request.messages;
+ let reexchanged = false;
+ for (let attempt = 1;; attempt++) {
+ const call = {
+ url: `${active.apiBase}/${route === "responses" ? "responses" : "chat/completions"}`,
+ model: input.model.id,
+ request,
+ headers: copilotChatHeaders({
+ copilotToken: active.token,
+ deviceId: data.deviceId,
+ cacheKey: input.request.cacheKey,
+ initiator: initiator(messages),
+ vision: hasImages(messages),
+ }),
+ extraBody,
+ };
+ try {
+ if (route === "responses") await streamOpenAiResponses(call, output, context);
+ else await streamOpenAiChat(call, output, context);
+ return finish({ status: "completed" });
+ } catch (error) {
+ const failure = httpError(error);
+ // 流内错误可能已发出部分事件,不能重试。
+ if (!failure) return finish({ status: "request-error", message: errorText(error) });
+ // HttpError 一定发生在任何事件之前,重试是安全的。
+ if (failure.status === 401 && !reexchanged) {
+ reexchanged = true;
+ try {
+ active = await ensureToken(data, context, true);
+ } catch (renewError) {
+ return tokenFailure(renewError);
+ }
+ data = active.data;
+ dirty = true;
+ continue;
+ }
+ if (attempt < MAX_ATTEMPTS && isRetryable(failure)) {
+ try {
+ await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), context.signal);
+ } catch {
+ return finish({ status: "request-error", message: failure.message });
+ }
+ continue;
+ }
+ return finish(classify(failure, data));
+ }
+ }
+}
+
+export const copilotProvider: ProviderSupport = {
+ id: "copilot",
+ displayName: "GitHub Copilot",
+ description: {
+ "en-US": "GitHub Copilot subscription access through the Copilot Chat API.",
+ "zh-CN": "通过 Copilot Chat API 使用 GitHub Copilot 订阅。",
+ },
+ providerType: "github",
+ resourceType: RESOURCE_TYPE,
+ models: copilotModels,
+ invoke,
+};
diff --git a/server/plugins/build-in/copilot-auth/resources.ts b/server/plugins/build-in/copilot-auth/resources.ts
new file mode 100644
index 000000000..3674a7bee
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/resources.ts
@@ -0,0 +1,239 @@
+import type { JsonValue, PluginContext } from "cursor-byok:plugin";
+import type {
+ ResourceDraft,
+ ResourceMetric,
+ ResourcePatch,
+ ResourceSnapshot,
+ ResourceView,
+} from "cursor-byok:resource";
+import { COPILOT_USER_URL, githubHeaders } from "./constants.ts";
+import {
+ authError,
+ copilotApiBase,
+ CopilotAuthError,
+ exchangeCopilotToken,
+ isFresh,
+} from "./token.ts";
+
+export const RESOURCE_TYPE = "github-copilot-account";
+
+/** premium_interactions 额度快照;percentRemaining 为 0..100。 */
+export type AccountQuota = {
+ percentRemaining: number;
+ unlimited: boolean;
+ resetAtMs: number | null;
+};
+
+/** 单条 github-copilot-account 资源的 privateData 形状。 */
+export type AccountData = {
+ githubToken: string;
+ /** 登录时生成,作为 editor-device-id 固定发送。 */
+ deviceId: string;
+ login: string;
+ plan: string | null;
+ copilotToken: string | null;
+ copilotTokenExpiresAtMs: number | null;
+ apiBase: string | null;
+ quota: AccountQuota | null;
+};
+
+export type CopilotUser = {
+ login: string;
+ plan: string | null;
+ quota: AccountQuota | null;
+};
+
+/** invoke / 模型发现使用的可用 token;refreshed 表示需要把 data 写回资源。 */
+export type ActiveToken = {
+ data: AccountData;
+ token: string;
+ apiBase: string;
+ refreshed: boolean;
+};
+
+function object(value: unknown): Record | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : null;
+}
+
+function text(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function number(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function clampPercent(value: number): number {
+ return Math.max(0, Math.min(100, value));
+}
+
+function storedQuota(value: unknown): AccountQuota | null {
+ const quota = object(value);
+ const percentRemaining = number(quota?.percentRemaining);
+ if (!quota || percentRemaining === null) return null;
+ return {
+ percentRemaining,
+ unlimited: quota.unlimited === true,
+ resetAtMs: number(quota.resetAtMs),
+ };
+}
+
+export function accountData(resource: ResourceSnapshot): AccountData {
+ const data = object(resource.privateData);
+ const githubToken = text(data?.githubToken);
+ const deviceId = text(data?.deviceId);
+ const login = text(data?.login);
+ if (!githubToken || !deviceId || !login) {
+ throw new Error("GitHub Copilot account resource is incomplete; sign in again");
+ }
+ return {
+ githubToken,
+ deviceId,
+ login,
+ plan: text(data?.plan),
+ copilotToken: text(data?.copilotToken),
+ copilotTokenExpiresAtMs: number(data?.copilotTokenExpiresAtMs),
+ apiBase: text(data?.apiBase),
+ quota: storedQuota(data?.quota),
+ };
+}
+
+/** 解析 `GET /copilot_internal/user`:登录名、套餐与 premium 请求额度。 */
+export function parseCopilotUser(body: unknown): CopilotUser {
+ const root = object(body);
+ const login = text(root?.login);
+ if (!login) throw new Error("Copilot user response is missing the GitHub login");
+ const premium = object(object(root?.quota_snapshots)?.premium_interactions);
+ const unlimited = premium?.unlimited === true;
+ const percent = number(premium?.percent_remaining) ?? (unlimited ? 100 : null);
+ const resetAtMs = typeof root?.quota_reset_date === "string"
+ ? Date.parse(root.quota_reset_date)
+ : NaN;
+ return {
+ login,
+ plan: text(root?.copilot_plan),
+ quota: premium && percent !== null
+ ? {
+ percentRemaining: clampPercent(percent),
+ unlimited,
+ resetAtMs: Number.isFinite(resetAtMs) ? resetAtMs : null,
+ }
+ : null,
+ };
+}
+
+export async function fetchCopilotUser(
+ githubToken: string,
+ context: PluginContext,
+): Promise {
+ const response = await context.network.fetch(COPILOT_USER_URL, {
+ method: "GET",
+ headers: githubHeaders(githubToken),
+ });
+ if (response.status < 200 || response.status >= 300) {
+ throw authError(response.status, response.body) ??
+ new Error(`Copilot account lookup failed (HTTP ${response.status}): ${response.body}`);
+ }
+ let body: unknown;
+ try {
+ body = JSON.parse(response.body);
+ } catch {
+ throw new Error("Copilot account lookup returned invalid JSON");
+ }
+ return parseCopilotUser(body);
+}
+
+/** 设备码授权完成后:读取账号信息并做一次 token 交换,确认订阅可用。 */
+export async function accountDraft(
+ githubToken: string,
+ context: PluginContext,
+): Promise {
+ const user = await fetchCopilotUser(githubToken, context);
+ const token = await exchangeCopilotToken(githubToken, context);
+ const data: AccountData = {
+ githubToken,
+ deviceId: crypto.randomUUID(),
+ login: user.login,
+ plan: user.plan,
+ copilotToken: token.token,
+ copilotTokenExpiresAtMs: token.expiresAtMs,
+ apiBase: token.apiBase,
+ quota: user.quota,
+ };
+ return { key: user.login.toLowerCase(), privateData: data as unknown as JsonValue };
+}
+
+/** 缓存的 Copilot token 仍新鲜时直接使用,否则(或 force 时)重新交换。 */
+export async function ensureToken(
+ data: AccountData,
+ context: PluginContext,
+ force = false,
+): Promise {
+ if (
+ !force && data.copilotToken !== null &&
+ isFresh(data.copilotToken, data.copilotTokenExpiresAtMs)
+ ) {
+ return {
+ data,
+ token: data.copilotToken,
+ apiBase: copilotApiBase(data.apiBase),
+ refreshed: false,
+ };
+ }
+ const token = await exchangeCopilotToken(data.githubToken, context);
+ return {
+ data: {
+ ...data,
+ copilotToken: token.token,
+ copilotTokenExpiresAtMs: token.expiresAtMs,
+ apiBase: token.apiBase,
+ },
+ token: token.token,
+ apiBase: token.apiBase,
+ refreshed: true,
+ };
+}
+
+export function presentAccount(resource: ResourceSnapshot): ResourceView {
+ const data = accountData(resource);
+ const metrics: ResourceMetric[] = [];
+ if (data.quota && !data.quota.unlimited) {
+ metrics.push({
+ id: "premium",
+ label: { "zh-CN": "Premium 请求剩余", "en-US": "Premium requests left" },
+ unit: "percent",
+ value: data.quota.percentRemaining,
+ ...(data.quota.resetAtMs !== null ? { resetAtMs: data.quota.resetAtMs } : {}),
+ });
+ }
+ return {
+ displayName: data.login,
+ description: data.plan ? `Copilot ${data.plan}` : "GitHub Copilot",
+ ...(metrics.length > 0 ? { metrics } : {}),
+ };
+}
+
+export async function refreshAccount(
+ resource: ResourceSnapshot,
+ context: PluginContext,
+): Promise {
+ const data = accountData(resource);
+ try {
+ const user = await fetchCopilotUser(data.githubToken, context);
+ const active = await ensureToken(data, context, true);
+ const next: AccountData = {
+ ...active.data,
+ login: user.login,
+ plan: user.plan,
+ quota: user.quota,
+ };
+ return { privateData: next as unknown as JsonValue, state: { status: "ready" } };
+ } catch (error) {
+ if (error instanceof CopilotAuthError) {
+ return { state: { status: "invalid", message: error.message } };
+ }
+ throw error;
+ }
+}
diff --git a/server/plugins/build-in/copilot-auth/token.ts b/server/plugins/build-in/copilot-auth/token.ts
new file mode 100644
index 000000000..90794e680
--- /dev/null
+++ b/server/plugins/build-in/copilot-auth/token.ts
@@ -0,0 +1,110 @@
+import type { PluginContext } from "cursor-byok:plugin";
+import {
+ COPILOT_API_HOSTS,
+ COPILOT_TOKEN_URL,
+ DEFAULT_COPILOT_BASE,
+ githubHeaders,
+} from "./constants.ts";
+
+/** Copilot token 提前 5 分钟续期。 */
+const RENEW_MARGIN_MS = 5 * 60 * 1000;
+
+export const AUTH_EXPIRED_MESSAGE = "GitHub authorization expired; sign in again";
+export const NO_COPILOT_MESSAGE = "This GitHub account has no active Copilot subscription";
+
+/** api.github.com 拒绝了 GitHub token:资源已不可用,需要重新登录。 */
+export class CopilotAuthError extends Error {
+ constructor(readonly status: number, message: string) {
+ super(message);
+ }
+}
+
+export type CopilotToken = {
+ token: string;
+ expiresAtMs: number;
+ apiBase: string;
+};
+
+function object(value: unknown): Record | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : null;
+}
+
+function text(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+/** api.github.com 授权失败归类:401 = token 失效,403/404 = 没有 Copilot 席位。 */
+export function authError(status: number, body: string): CopilotAuthError | null {
+ if (status === 401) return new CopilotAuthError(status, AUTH_EXPIRED_MESSAGE);
+ if (status === 403 || status === 404) {
+ let detail: string | null = null;
+ try {
+ detail = text(object(JSON.parse(body))?.message);
+ } catch {
+ detail = text(body);
+ }
+ return new CopilotAuthError(
+ status,
+ detail ? `${NO_COPILOT_MESSAGE}: ${detail}` : NO_COPILOT_MESSAGE,
+ );
+ }
+ return null;
+}
+
+/** 宿主只放行白名单主机,未知的 Copilot 端点在此给出明确错误。 */
+export function copilotApiBase(raw: string | null): string {
+ if (raw === null) return DEFAULT_COPILOT_BASE;
+ let url: URL;
+ try {
+ url = new URL(raw);
+ } catch {
+ throw new Error(`Copilot returned an invalid API endpoint: ${raw}`);
+ }
+ if (url.protocol !== "https:" || !COPILOT_API_HOSTS.includes(url.hostname)) {
+ throw new Error(
+ `Copilot API host ${url.host} is not allowed by this plugin; update plugin.json network permissions`,
+ );
+ }
+ return `https://${url.hostname}`;
+}
+
+export function isFresh(
+ token: string | null,
+ expiresAtMs: number | null,
+ nowMs = Date.now(),
+): boolean {
+ return token !== null && expiresAtMs !== null && expiresAtMs - RENEW_MARGIN_MS > nowMs;
+}
+
+/** 用长期 GitHub OAuth token 换取约 30 分钟有效的 Copilot API token。 */
+export async function exchangeCopilotToken(
+ githubToken: string,
+ context: PluginContext,
+): Promise {
+ const response = await context.network.fetch(COPILOT_TOKEN_URL, {
+ method: "GET",
+ headers: githubHeaders(githubToken),
+ });
+ if (response.status < 200 || response.status >= 300) {
+ throw authError(response.status, response.body) ??
+ new Error(`Copilot token exchange failed (HTTP ${response.status}): ${response.body}`);
+ }
+ let body: Record | null;
+ try {
+ body = object(JSON.parse(response.body));
+ } catch {
+ body = null;
+ }
+ const token = text(body?.token);
+ const expiresAt = body?.expires_at;
+ if (!token || typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
+ throw new Error("Copilot token exchange returned an incomplete response");
+ }
+ return {
+ token,
+ expiresAtMs: expiresAt * 1000,
+ apiBase: copilotApiBase(text(object(body?.endpoints)?.api)),
+ };
+}
diff --git a/server/src/plugin/builtin.rs b/server/src/plugin/builtin.rs
index f2ca588e1..1018e2ea8 100644
--- a/server/src/plugin/builtin.rs
+++ b/server/src/plugin/builtin.rs
@@ -168,10 +168,77 @@ const ANTIGRAVITY_AUTH: &[(&str, &str)] = &[
),
];
+const COPILOT_AUTH: &[(&str, &str)] = &[
+ (
+ "plugin.json",
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/plugins/build-in/copilot-auth/plugin.json"
+ )),
+ ),
+ (
+ "main.ts",
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/plugins/build-in/copilot-auth/main.ts"
+ )),
+ ),
+ (
+ "constants.ts",
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/plugins/build-in/copilot-auth/constants.ts"
+ )),
+ ),
+ (
+ "provider.ts",
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/plugins/build-in/copilot-auth/provider.ts"
+ )),
+ ),
+ (
+ "models.ts",
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/plugins/build-in/copilot-auth/models.ts"
+ )),
+ ),
+ (
+ "oauth.ts",
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/plugins/build-in/copilot-auth/oauth.ts"
+ )),
+ ),
+ (
+ "resources.ts",
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/plugins/build-in/copilot-auth/resources.ts"
+ )),
+ ),
+ (
+ "token.ts",
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/plugins/build-in/copilot-auth/token.ts"
+ )),
+ ),
+ (
+ "assets/copilot.svg",
+ include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/plugins/build-in/copilot-auth/assets/copilot.svg"
+ )),
+ ),
+];
+
const PLUGINS: &[(&str, &[(&str, &str)])] = &[
("codex-auth", CODEX_AUTH),
("grok-auth", GROK_AUTH),
("antigravity-auth", ANTIGRAVITY_AUTH),
+ ("copilot-auth", COPILOT_AUTH),
];
/// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键:
@@ -276,6 +343,7 @@ mod tests {
.path()
.join("antigravity-auth/assets/antigravity.svg")
.is_file());
+ assert!(root.path().join("copilot-auth/token.ts").is_file());
// 版本一致:本地改动与额外文件保持原样,不发生任何写盘。
std::fs::write(plugin.join("main.ts"), "edited").unwrap();
diff --git a/server/src/run/compaction.rs b/server/src/run/compaction.rs
index a42238f3b..cb8e64850 100644
--- a/server/src/run/compaction.rs
+++ b/server/src/run/compaction.rs
@@ -38,13 +38,15 @@ pub(super) fn input_budget(prepared: &PreparedRun) -> Option {
///
/// Providers report this as a plain 400 with prose, so there is nothing
/// structured to match on. Anthropic says "prompt is too long"; OpenAI-style
-/// gateways use `context_length_exceeded` or "maximum context length".
+/// gateways use `context_length_exceeded` or "maximum context length";
+/// GitHub Copilot uses `model_max_prompt_tokens_exceeded`.
pub(super) fn is_context_overflow(message: &str) -> bool {
let lowered = message.to_ascii_lowercase();
lowered.contains("prompt is too long")
|| lowered.contains("context window exceeded")
|| lowered.contains("model_context_window_exceeded")
|| lowered.contains("context_length_exceeded")
+ || lowered.contains("model_max_prompt_tokens_exceeded")
|| (lowered.contains("maximum context length") && lowered.contains("token"))
}
@@ -301,6 +303,10 @@ mod tests {
));
assert!(is_context_overflow("model_context_window_exceeded"));
assert!(is_context_overflow("context_length_exceeded"));
+ assert!(is_context_overflow(
+ "HTTP 400: {\"error\":{\"message\":\"prompt token count of 135000 exceeds \
+ the limit of 128000\",\"code\":\"model_max_prompt_tokens_exceeded\"}}"
+ ));
assert!(is_context_overflow(
"This model's maximum context length is 128000 tokens"
));