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 @@ + + + github-copilot + + + + + + + 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" ));