Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
// Github Copilot API module exposes the plugin public contract.
export { githubCopilotLoginCommand } from "./login.js";

View File

@@ -0,0 +1,110 @@
// Github Copilot tests cover auth plugin behavior.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn());
const listProfilesForProviderMock = vi.hoisted(() => vi.fn());
const coerceSecretRefMock = vi.hoisted(() => vi.fn());
const resolveRequiredConfiguredSecretRefInputStringMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
coerceSecretRef: coerceSecretRefMock,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/secret-input-runtime", () => ({
resolveRequiredConfiguredSecretRefInputString: resolveRequiredConfiguredSecretRefInputStringMock,
}));
import { resolveFirstGithubToken } from "./auth.js";
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/provider-auth");
vi.doUnmock("openclaw/plugin-sdk/secret-input-runtime");
vi.resetModules();
});
describe("resolveFirstGithubToken", () => {
beforeEach(() => {
ensureAuthProfileStoreMock.mockReturnValue({
profiles: {
"github-copilot:github": {
type: "token",
tokenRef: { source: "file", provider: "default", id: "/providers/github-copilot/token" },
},
},
});
listProfilesForProviderMock.mockReturnValue(["github-copilot:github"]);
coerceSecretRefMock.mockReturnValue({
source: "file",
provider: "default",
id: "/providers/github-copilot/token",
});
resolveRequiredConfiguredSecretRefInputStringMock.mockResolvedValue("resolved-profile-token");
});
afterEach(() => {
vi.restoreAllMocks();
ensureAuthProfileStoreMock.mockReset();
listProfilesForProviderMock.mockReset();
coerceSecretRefMock.mockReset();
resolveRequiredConfiguredSecretRefInputStringMock.mockReset();
});
it("prefers env tokens when available", async () => {
const result = await resolveFirstGithubToken({
env: { GH_TOKEN: "env-token" } as NodeJS.ProcessEnv,
});
expect(result).toEqual({
githubToken: "env-token",
hasProfile: true,
});
expect(resolveRequiredConfiguredSecretRefInputStringMock).not.toHaveBeenCalled();
});
it("returns direct profile tokens before resolving SecretRefs", async () => {
ensureAuthProfileStoreMock.mockReturnValue({
profiles: {
"github-copilot:github": {
type: "token",
token: "profile-token",
},
},
});
coerceSecretRefMock.mockReturnValue(null);
const result = await resolveFirstGithubToken({
env: {} as NodeJS.ProcessEnv,
});
expect(result).toEqual({
githubToken: "profile-token",
hasProfile: true,
});
});
it("resolves non-env SecretRefs when config is available", async () => {
const config = { secrets: { defaults: { provider: "default" } } } as never;
const env = {} as NodeJS.ProcessEnv;
const result = await resolveFirstGithubToken({
config,
env,
});
expect(result).toEqual({
githubToken: "resolved-profile-token",
hasProfile: true,
});
expect(resolveRequiredConfiguredSecretRefInputStringMock).toHaveBeenCalledWith({
config,
env,
value: {
source: "file",
provider: "default",
id: "/providers/github-copilot/token",
},
path: "providers.github-copilot.authProfiles.github-copilot:github.tokenRef",
});
});
});

View File

@@ -0,0 +1,66 @@
// Github Copilot plugin module implements auth behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
coerceSecretRef,
ensureAuthProfileStore,
listProfilesForProvider,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveRequiredConfiguredSecretRefInputString } from "openclaw/plugin-sdk/secret-input-runtime";
import { PROVIDER_ID } from "./models.js";
export async function resolveFirstGithubToken(params: {
agentDir?: string;
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{
githubToken: string;
hasProfile: boolean;
}> {
const authStore = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const profileIds = listProfilesForProvider(authStore, PROVIDER_ID);
const hasProfile = profileIds.length > 0;
const envToken =
params.env.COPILOT_GITHUB_TOKEN ?? params.env.GH_TOKEN ?? params.env.GITHUB_TOKEN ?? "";
const githubToken = envToken.trim();
if (githubToken || !hasProfile) {
return { githubToken, hasProfile };
}
const profileId = profileIds[0];
const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile?.type !== "token") {
return { githubToken: "", hasProfile };
}
const directToken = profile.token?.trim() ?? "";
if (directToken) {
return { githubToken: directToken, hasProfile };
}
const tokenRef = coerceSecretRef(profile.tokenRef);
if (tokenRef?.source === "env" && tokenRef.id.trim()) {
return {
githubToken: (params.env[tokenRef.id] ?? process.env[tokenRef.id] ?? "").trim(),
hasProfile,
};
}
if (tokenRef && params.config) {
try {
const resolved = await resolveRequiredConfiguredSecretRefInputString({
config: params.config,
env: params.env,
value: profile.tokenRef,
path: `providers.github-copilot.authProfiles.${profileId ?? "default"}.tokenRef`,
});
return {
githubToken: resolved?.trim() ?? "",
hasProfile,
};
} catch {
return { githubToken: "", hasProfile };
}
}
return { githubToken: "", hasProfile };
}

View File

@@ -0,0 +1,234 @@
// Github Copilot tests cover connection bound ids plugin behavior.
import { stream as streamModel, type AssistantMessage, type Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import { resolveFirstGithubToken } from "./auth.js";
import { buildCopilotDynamicHeaders } from "./stream.js";
import { wrapCopilotOpenAIResponsesStream } from "./stream.js";
import { resolveCopilotApiToken } from "./token.js";
const LIVE =
process.env.OPENCLAW_LIVE_TEST === "1" ||
process.env.LIVE === "1" ||
process.env.GITHUB_COPILOT_LIVE_TEST === "1";
const ENV_GITHUB_TOKEN =
process.env.OPENCLAW_LIVE_GITHUB_COPILOT_TOKEN ??
process.env.COPILOT_GITHUB_TOKEN ??
process.env.GH_TOKEN ??
process.env.GITHUB_TOKEN ??
"";
const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_GITHUB_COPILOT_MODEL?.trim() || "gpt-5.4";
const describeLive = LIVE ? describe : describe.skip;
type CopilotApiToken = {
token: string;
expiresAt: number;
source: string;
baseUrl: string;
};
const ZERO_USAGE = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
} as const;
function logProgress(message: string): void {
process.stderr.write(`[github-copilot-live] ${message}\n`);
}
async function withTimeout<T>(label: string, promise: Promise<T>, timeoutMs: number): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
timeoutMs,
);
timer.unref?.();
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
const fetchWithTimeout: typeof fetch = async (input, init) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
timer.unref?.();
try {
return await fetch(input, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
};
function buildModel(baseUrl: string): Model<"openai-responses"> {
return {
id: LIVE_MODEL_ID,
name: LIVE_MODEL_ID,
provider: "github-copilot",
api: "openai-responses",
baseUrl,
headers: {},
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 256,
};
}
function buildReplayAssistantMessage(connectionBoundId: string): AssistantMessage {
return {
role: "assistant",
api: "openai-responses",
provider: "github-copilot",
model: LIVE_MODEL_ID,
usage: ZERO_USAGE,
stopReason: "stop",
timestamp: Date.now() - 1,
content: [
{
type: "text",
text: "Earlier assistant text.",
textSignature: JSON.stringify({ v: 1, id: connectionBoundId }),
},
],
};
}
async function resolveGithubTokenCandidates(): Promise<Array<{ source: string; token: string }>> {
const candidates: Array<{ source: string; token: string }> = [];
const envToken = ENV_GITHUB_TOKEN.trim();
if (envToken) {
candidates.push({ source: "env", token: envToken });
}
const profileEnv = {
...process.env,
COPILOT_GITHUB_TOKEN: "",
GH_TOKEN: "",
GITHUB_TOKEN: "",
};
const profile = await resolveFirstGithubToken({ env: profileEnv });
const profileToken = profile.githubToken.trim();
if (profileToken && !candidates.some((candidate) => candidate.token === profileToken)) {
candidates.push({ source: "auth-profile", token: profileToken });
}
return candidates;
}
function extractText(response: unknown): string {
const content = (response as { content?: Array<{ type?: string; text?: string }> }).content;
if (!Array.isArray(content)) {
return "";
}
const text: string[] = [];
for (const block of content) {
if (block.type === "text") {
const trimmed = block.text?.trim() ?? "";
if (trimmed.length > 0) {
text.push(trimmed);
}
}
}
return text.join(" ");
}
describeLive("github-copilot connection-bound Responses IDs live", () => {
it("rewrites replayed connection-bound item IDs before sending to Copilot", async () => {
logProgress("start");
const candidates = await resolveGithubTokenCandidates();
if (candidates.length === 0) {
logProgress("skip (no GitHub Copilot token found in env or auth profile)");
return;
}
let token: CopilotApiToken | undefined;
const failures: string[] = [];
for (const candidate of candidates) {
try {
logProgress(`exchanging ${candidate.source} GitHub token for Copilot token`);
token = await withTimeout(
"Copilot token exchange",
resolveCopilotApiToken({
githubToken: candidate.token,
fetchImpl: fetchWithTimeout,
}),
15_000,
);
logProgress(
`token ok via ${candidate.source} (${token.source.startsWith("cache:") ? "cache" : "fetched"})`,
);
break;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push(`${candidate.source}: ${message}`);
logProgress(`token exchange failed via ${candidate.source} (${message})`);
}
}
if (!token) {
throw new Error(`Copilot token exchange failed for all candidates: ${failures.join("; ")}`);
}
const model = buildModel(token.baseUrl);
const staleId = Buffer.from(`copilot-${"x".repeat(24)}`).toString("base64");
const context = {
messages: [
buildReplayAssistantMessage(staleId),
{
role: "user" as const,
content: "Reply with exactly: COPILOT_LIVE_OK",
timestamp: Date.now(),
},
],
};
let capturedPayload: Record<string, unknown> | undefined;
const wrappedStream = wrapCopilotOpenAIResponsesStream(streamModel as never);
if (!wrappedStream) {
throw new Error("expected Copilot Responses stream wrapper");
}
const stream = wrappedStream(
model as never,
context as never,
{
apiKey: token.token,
headers: buildCopilotDynamicHeaders({
messages: context.messages,
hasImages: false,
}),
maxTokens: 32,
onPayload: (payload: unknown) => {
capturedPayload = payload as Record<string, unknown>;
},
} as never,
) as { result(): Promise<unknown> };
logProgress("sending Responses request");
const result = await stream.result();
logProgress("Responses request completed");
const input = Array.isArray(capturedPayload?.input) ? capturedPayload.input : [];
const replayedAssistant = input.find(
(item): item is Record<string, unknown> =>
Boolean(item) &&
typeof item === "object" &&
(item as Record<string, unknown>).type === "message",
);
expect(replayedAssistant?.id).toMatch(/^msg_[a-f0-9]{16}$/);
expect(replayedAssistant?.id).not.toBe(staleId);
expect(extractText(result)).toMatch(/^COPILOT_LIVE_OK[.!]?$/i);
}, 60_000);
});

View File

@@ -0,0 +1,98 @@
// Github Copilot tests cover connection bound ids plugin behavior.
import { describe, expect, it } from "vitest";
import {
rewriteCopilotConnectionBoundResponseIds,
rewriteCopilotResponsePayloadConnectionBoundIds,
sanitizeCopilotReplayResponseIds,
} from "./connection-bound-ids.js";
describe("github-copilot connection-bound response IDs", () => {
it("rewrites opaque message response item IDs deterministically", () => {
const originalId = Buffer.from(`message-${"x".repeat(24)}`).toString("base64");
const first = [{ id: originalId, type: "message" }];
const second = [{ id: originalId, type: "message" }];
expect(rewriteCopilotConnectionBoundResponseIds(first)).toBe(true);
expect(rewriteCopilotConnectionBoundResponseIds(second)).toBe(true);
expect(first[0]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
expect(first[0]?.id).toBe(second[0]?.id);
});
it("uses response item type prefixes and preserves local IDs", () => {
const functionCallId = Buffer.from(`function-call-${"y".repeat(20)}`).toString("base64");
const messageId = Buffer.from(`message-${"z".repeat(24)}`).toString("base64");
const input = [
{ id: "rs_existing", type: "reasoning" },
{ id: "msg_existing", type: "message" },
{ id: "fc_existing", type: "function_call" },
{ id: functionCallId, type: "function_call" },
{ id: messageId, type: "message" },
];
expect(rewriteCopilotConnectionBoundResponseIds(input)).toBe(true);
expect(input[0]?.id).toBe("rs_existing");
expect(input[1]?.id).toBe("msg_existing");
expect(input[2]?.id).toBe("fc_existing");
expect(input[3]?.id).toMatch(/^fc_[a-f0-9]{16}$/);
expect(input[4]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
});
it("preserves valid reasoning IDs regardless of encrypted_content", () => {
const withEncrypted = Buffer.from(`reasoning-${"e".repeat(24)}`).toString("base64");
const withNull = Buffer.from(`reasoning-${"n".repeat(24)}`).toString("base64");
const withoutField = Buffer.from(`reasoning-${"a".repeat(24)}`).toString("base64");
const input = [
{ id: withEncrypted, type: "reasoning", encrypted_content: "opaque-encrypted-payload" },
{ id: withNull, type: "reasoning", encrypted_content: null },
{ id: withoutField, type: "reasoning" },
];
expect(rewriteCopilotConnectionBoundResponseIds(input)).toBe(false);
expect(input[0]?.id).toBe(withEncrypted);
expect(input[1]?.id).toBe(withNull);
expect(input[2]?.id).toBe(withoutField);
});
it("preserves valid base64-ish reasoning IDs with and without encrypted content", () => {
const withEncrypted = "abcDEF0123+/=";
const withoutEncrypted = "reasoning/abc+123=";
const input = [
{ id: withEncrypted, type: "reasoning", encrypted_content: "opaque-encrypted-payload" },
{ id: withoutEncrypted, type: "reasoning" },
];
expect(sanitizeCopilotReplayResponseIds(input)).toBe(false);
expect(input.map((item) => item.id)).toEqual([withEncrypted, withoutEncrypted]);
});
it("drops unsafe reasoning replay item IDs while keeping idless reasoning replay", () => {
const overlongId = `5PX6gLHXT5wE+Y2tPmUV4gn+${"B".repeat(384)}`;
const input = [
{
id: overlongId,
type: "reasoning",
encrypted_content: "encrypted-replay-payload",
summary: [],
},
{ type: "reasoning", encrypted_content: "missing-id", summary: [] },
{ id: 123, type: "reasoning", encrypted_content: "non-string-id", summary: [] },
{ id: "rs_valid", type: "reasoning", encrypted_content: "valid", summary: [] },
];
expect(sanitizeCopilotReplayResponseIds(input)).toBe(true);
expect(input).toEqual([
{ type: "reasoning", encrypted_content: "missing-id", summary: [] },
{ id: "rs_valid", type: "reasoning", encrypted_content: "valid", summary: [] },
]);
});
it("patches response payload input arrays only", () => {
const messageId = Buffer.from(`message-${"m".repeat(24)}`).toString("base64");
const payload = { input: [{ id: messageId, type: "message" }] };
expect(rewriteCopilotResponsePayloadConnectionBoundIds(payload)).toBe(true);
expect(payload.input[0]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
expect(rewriteCopilotResponsePayloadConnectionBoundIds(undefined)).toBe(false);
expect(rewriteCopilotResponsePayloadConnectionBoundIds({ input: "text" })).toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
// Github Copilot plugin module implements connection bound ids behavior.
import { createHash } from "node:crypto";
// Copilot's OpenAI-compatible `/responses` endpoint can emit replay item IDs
// that encode upstream connection state. Those IDs are rejected after the
// connection changes, so sanitize them at the provider boundary before send.
function looksLikeConnectionBoundId(id: string): boolean {
if (id.length < 24) {
return false;
}
if (/^(?:rs|msg|fc)_[A-Za-z0-9_-]+$/.test(id)) {
return false;
}
if (!/^[A-Za-z0-9+/_-]+=*$/.test(id)) {
return false;
}
return Buffer.from(id, "base64").length >= 16;
}
function deriveReplacementId(type: string | undefined, originalId: string): string {
const prefix = type === "function_call" ? "fc" : "msg";
const hex = createHash("sha256").update(originalId).digest("hex").slice(0, 16);
return `${prefix}_${hex}`;
}
type InputItem = Record<string, unknown> & { id?: unknown; type?: unknown };
function isInputItem(value: unknown): value is InputItem {
return Boolean(value) && typeof value === "object";
}
function isValidReasoningReplayId(id: unknown): id is string {
return typeof id === "string" && id.length > 0 && id.length <= 64;
}
export function sanitizeCopilotReplayResponseIds(input: unknown): boolean {
if (!Array.isArray(input)) {
return false;
}
let rewrote = false;
for (let index = input.length - 1; index >= 0; index -= 1) {
const item = input[index];
if (!isInputItem(item)) {
continue;
}
const id = item.id;
// Reasoning items with replay IDs reference server-side encrypted state
// bound to that ID. Drop unsafe IDs, but keep the store-disabled idless
// replay form produced by core Responses conversion.
if (item.type === "reasoning") {
if (id !== undefined && !isValidReasoningReplayId(id)) {
input.splice(index, 1);
rewrote = true;
}
continue;
}
if (typeof id !== "string" || id.length === 0) {
continue;
}
if (looksLikeConnectionBoundId(id)) {
item.id = deriveReplacementId(typeof item.type === "string" ? item.type : undefined, id);
rewrote = true;
}
}
return rewrote;
}
export function rewriteCopilotConnectionBoundResponseIds(input: unknown): boolean {
return sanitizeCopilotReplayResponseIds(input);
}
export function sanitizeCopilotReplayResponsePayloadIds(payload: unknown): boolean {
if (!payload || typeof payload !== "object") {
return false;
}
return sanitizeCopilotReplayResponseIds((payload as { input?: unknown }).input);
}
export function rewriteCopilotResponsePayloadConnectionBoundIds(payload: unknown): boolean {
return sanitizeCopilotReplayResponsePayloadIds(payload);
}

View File

@@ -0,0 +1,367 @@
// Github Copilot tests cover embeddings plugin behavior.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("./auth.js", () => ({
resolveFirstGithubToken: resolveFirstGithubTokenMock,
}));
vi.mock("openclaw/plugin-sdk/secret-input-runtime", () => ({
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
}));
vi.mock("./token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
resolveCopilotApiToken: resolveCopilotApiTokenMock,
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js";
afterAll(() => {
vi.doUnmock("./auth.js");
vi.doUnmock("openclaw/plugin-sdk/secret-input-runtime");
vi.doUnmock("./token.js");
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
});
const TEST_BASE_URL = "https://api.githubcopilot.test";
function shouldContinueAutoSelection(error: Error): boolean {
const shouldContinue = githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection;
if (!shouldContinue) {
throw new Error("GitHub Copilot embedding adapter did not expose auto-selection fallback");
}
return shouldContinue(error);
}
function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: unknown }>) {
return { data: models };
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function mockDiscoveryResponse(spec: {
ok: boolean;
status?: number;
json?: unknown;
text?: string;
}) {
const status = spec.status ?? (spec.ok ? 200 : 500);
const response =
spec.json !== undefined
? new Response(JSON.stringify(spec.json), {
status,
headers: { "Content-Type": "application/json" },
})
: new Response(spec.text ?? "", { status });
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
response,
release: vi.fn(async () => {}),
}));
}
function defaultCreateOptions() {
return {
config: {} as Record<string, unknown>,
agentDir: "/tmp/test-agent",
model: "",
};
}
function firstCopilotApiTokenRequest() {
const [call] = resolveCopilotApiTokenMock.mock.calls;
if (!call) {
throw new Error("expected resolveCopilotApiToken call");
}
const [request] = call;
if (!request || typeof request !== "object") {
throw new Error("expected resolveCopilotApiToken request");
}
return request as { env?: typeof process.env; githubToken?: string };
}
function firstDiscoveryRequest() {
const [call] = fetchWithSsrFGuardMock.mock.calls;
if (!call) {
throw new Error("expected GitHub Copilot discovery request");
}
const [request] = call;
if (!request || typeof request !== "object") {
throw new Error("expected GitHub Copilot discovery request options");
}
return request as {
init: { headers: Record<string, string> };
url: string;
};
}
describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
beforeEach(() => {
resolveConfiguredSecretInputStringMock.mockResolvedValue({});
resolveFirstGithubTokenMock.mockResolvedValue({
githubToken: "gh_test_token_123",
hasProfile: false,
});
resolveCopilotApiTokenMock.mockResolvedValue({
token: "copilot_test_token_abc",
expiresAt: Date.now() + 3_600_000,
source: "test",
baseUrl: TEST_BASE_URL,
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
resolveConfiguredSecretInputStringMock.mockReset();
resolveFirstGithubTokenMock.mockReset();
resolveCopilotApiTokenMock.mockReset();
fetchWithSsrFGuardMock.mockReset();
});
it("registers the expected adapter metadata", () => {
expect(githubCopilotMemoryEmbeddingProviderAdapter.id).toBe("github-copilot");
expect(githubCopilotMemoryEmbeddingProviderAdapter.transport).toBe("remote");
expect(githubCopilotMemoryEmbeddingProviderAdapter.autoSelectPriority).toBe(15);
expect(githubCopilotMemoryEmbeddingProviderAdapter.allowExplicitWhenConfiguredAuto).toBe(true);
});
it("picks text-embedding-3-small when available", async () => {
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-large", supported_endpoints: ["/v1/embeddings"] },
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
{ id: "gpt-4o", supported_endpoints: ["/v1/chat/completions"] },
]),
});
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.provider?.model).toBe("text-embedding-3-small");
expect(firstCopilotApiTokenRequest().githubToken).toBe("gh_test_token_123");
});
it("matches embedding-capable models when supported_endpoints is missing or malformed", async () => {
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([
{ id: "gpt-4o", supported_endpoints: { broken: true } },
{ id: "text-embedding-3-small", supported_endpoints: [] },
{ id: "text-embedding-ada-002" },
]),
});
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.provider?.model).toBe("text-embedding-3-small");
});
it("strips the provider prefix from a user-selected model", async () => {
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
});
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create({
...defaultCreateOptions(),
model: "github-copilot/text-embedding-3-small",
} as never);
expect(result.provider?.model).toBe("text-embedding-3-small");
});
it("throws when the user-selected model is unavailable", async () => {
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
});
await expect(
githubCopilotMemoryEmbeddingProviderAdapter.create({
...defaultCreateOptions(),
model: "gpt-4o",
} as never),
).rejects.toThrow('GitHub Copilot embedding model "gpt-4o" is not available');
});
it("throws when discovery finds no embedding models", async () => {
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([{ id: "gpt-4o", supported_endpoints: ["/v1/chat/completions"] }]),
});
await expect(
githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
).rejects.toThrow("No embedding models available from GitHub Copilot");
});
it("wraps invalid discovery JSON as a setup error", async () => {
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
response: new Response("not-valid-json{{{", {
status: 200,
headers: { "Content-Type": "application/json" },
}),
release: vi.fn(async () => {}),
}));
await expect(
githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
).rejects.toThrow("github-copilot.model-discovery: malformed JSON response");
});
it("bounds model discovery error bodies", async () => {
const tracked = cancelTrackedResponse(`${"discovery denied ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
response: tracked.response,
release: vi.fn(async () => {}),
}));
let caught: Error | undefined;
try {
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("GitHub Copilot model discovery HTTP 503");
expect(caught?.message).toContain("discovery denied");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds embeddings error bodies", async () => {
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
});
const tracked = cancelTrackedResponse(`${"embedding denied ".repeat(1024)}tail`, {
status: 429,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchImpl = vi.fn(async () => tracked.response);
vi.stubGlobal("fetch", fetchImpl);
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
let caught: Error | undefined;
try {
await result.provider?.embedQuery("hello");
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("GitHub Copilot embeddings HTTP 429");
expect(caught?.message).toContain("embedding denied");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("honors remote overrides when creating the provider", async () => {
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "gh_remote_token" });
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
});
await githubCopilotMemoryEmbeddingProviderAdapter.create({
...defaultCreateOptions(),
remote: {
apiKey: "ignored-at-runtime",
baseUrl: "https://proxy.example/v1",
headers: { "X-Proxy-Token": "proxy" },
},
} as never);
expect(resolveFirstGithubTokenMock).toHaveBeenCalled();
expect(firstCopilotApiTokenRequest().env).toBe(process.env);
expect(firstCopilotApiTokenRequest().githubToken).toBe("gh_remote_token");
const discoveryCall = firstDiscoveryRequest();
expect(discoveryCall.url).toBe("https://proxy.example/v1/models");
expect(discoveryCall.init.headers["Accept-Encoding"]).toBe("identity");
expect(discoveryCall.init.headers["X-Proxy-Token"]).toBe("proxy");
});
it("includes provider, baseUrl, and model in runtime cache data", async () => {
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
});
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.runtime).toEqual({
id: "github-copilot",
cacheKeyData: {
provider: "github-copilot",
baseUrl: TEST_BASE_URL,
model: "text-embedding-3-small",
},
});
});
it("treats token parsing and discovery failures as auto-fallback errors", () => {
expect(shouldContinueAutoSelection(new Error("Copilot token response missing token"))).toBe(
true,
);
expect(
shouldContinueAutoSelection(
new Error("Unexpected response from GitHub Copilot token endpoint"),
),
).toBe(true);
expect(
shouldContinueAutoSelection(
new Error("github-copilot.model-discovery: malformed JSON response"),
),
).toBe(true);
expect(shouldContinueAutoSelection(new Error("Network timeout"))).toBe(false);
});
});

View File

@@ -0,0 +1,340 @@
// Github Copilot plugin module implements embeddings behavior.
import {
buildRemoteBaseUrlPolicy,
sanitizeAndNormalizeEmbedding,
withRemoteHttpResponse,
type MemoryEmbeddingProvider,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveFirstGithubToken } from "./auth.js";
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
const COPILOT_EMBEDDING_PROVIDER_ID = "github-copilot";
/**
* Preferred embedding models in order. The first available model wins.
*/
const PREFERRED_MODELS = [
"text-embedding-3-small",
"text-embedding-3-large",
"text-embedding-ada-002",
] as const;
const COPILOT_HEADERS_STATIC: Record<string, string> = {
"Content-Type": "application/json",
...buildCopilotIdeHeaders(),
};
const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
function buildSsrfPolicy(baseUrl: string): SsrFPolicy | undefined {
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return undefined;
}
return { allowedHostnames: [parsed.hostname] };
} catch {
return undefined;
}
}
type CopilotModelEntry = {
id?: unknown;
supported_endpoints?: unknown;
};
type GitHubCopilotEmbeddingClient = {
githubToken: string;
model: string;
baseUrl?: string;
headers?: Record<string, string>;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
};
function isCopilotSetupError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
// All Copilot-specific setup failures should allow auto-selection to
// fall through to the next provider (e.g. OpenAI). This covers: missing
// GitHub token, token exchange failures, no embedding models on the plan,
// model discovery errors, and user-pinned model not available on Copilot.
return (
err.message.includes("No GitHub token available") ||
err.message.includes("Copilot token exchange failed") ||
err.message.includes("Copilot token response") ||
err.message.includes("No embedding models available") ||
err.message.includes("GitHub Copilot model discovery") ||
err.message.includes("github-copilot.model-discovery") ||
err.message.includes("GitHub Copilot embedding model") ||
err.message.includes("Unexpected response from GitHub Copilot token endpoint")
);
}
async function discoverEmbeddingModels(params: {
baseUrl: string;
copilotToken: string;
headers?: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
}): Promise<string[]> {
const url = `${params.baseUrl.replace(/\/$/, "")}/models`;
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
method: "GET",
headers: {
...COPILOT_HEADERS_STATIC,
...params.headers,
Authorization: `Bearer ${params.copilotToken}`,
},
},
policy: params.ssrfPolicy,
auditContext: "memory-remote",
});
try {
if (!response.ok) {
const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES);
throw new Error(`GitHub Copilot model discovery HTTP ${response.status}: ${detail}`);
}
const payload = await readProviderJsonResponse(response, "github-copilot.model-discovery");
const allModels = Array.isArray((payload as { data?: unknown })?.data)
? ((payload as { data: CopilotModelEntry[] }).data ?? [])
: [];
// Filter for embedding models. The Copilot API may list embedding models
// with an explicit /v1/embeddings endpoint, or with an empty
// supported_endpoints array. Match both: endpoint-declared embedding
// models and models whose ID indicates embedding capability.
return allModels.flatMap((entry) => {
const id = typeof entry.id === "string" ? entry.id.trim() : "";
if (!id) {
return [];
}
const endpoints = Array.isArray(entry.supported_endpoints)
? entry.supported_endpoints.filter((value): value is string => typeof value === "string")
: [];
return endpoints.some((ep) => ep.includes("embeddings")) || /\bembedding/i.test(id)
? [id]
: [];
});
} finally {
await release();
}
}
function pickBestModel(available: string[], userModel?: string): string {
if (userModel) {
const normalized = userModel.trim();
// Strip the provider prefix if users set "github-copilot/model-name".
const stripped = normalized.startsWith(`${COPILOT_EMBEDDING_PROVIDER_ID}/`)
? normalized.slice(`${COPILOT_EMBEDDING_PROVIDER_ID}/`.length)
: normalized;
if (available.length === 0) {
throw new Error("No embedding models available from GitHub Copilot");
}
if (!available.includes(stripped)) {
throw new Error(
`GitHub Copilot embedding model "${stripped}" is not available. Available: ${available.join(", ")}`,
);
}
return stripped;
}
for (const preferred of PREFERRED_MODELS) {
if (available.includes(preferred)) {
return preferred;
}
}
if (available.length > 0) {
return available[0];
}
throw new Error("No embedding models available from GitHub Copilot");
}
function parseGitHubCopilotEmbeddingPayload(payload: unknown, expectedCount: number): number[][] {
if (!payload || typeof payload !== "object") {
throw new Error("GitHub Copilot embeddings response missing data[]");
}
const data = (payload as { data?: unknown }).data;
if (!Array.isArray(data)) {
throw new Error("GitHub Copilot embeddings response missing data[]");
}
const vectors = Array.from<number[] | undefined>({ length: expectedCount });
for (const entry of data) {
if (!entry || typeof entry !== "object") {
throw new Error("GitHub Copilot embeddings response contains an invalid entry");
}
const indexValue = (entry as { index?: unknown }).index;
const embedding = (entry as { embedding?: unknown }).embedding;
const index = typeof indexValue === "number" ? indexValue : Number.NaN;
if (!Number.isInteger(index)) {
throw new Error("GitHub Copilot embeddings response contains an invalid index");
}
if (index < 0 || index >= expectedCount) {
throw new Error("GitHub Copilot embeddings response contains an out-of-range index");
}
if (vectors[index] !== undefined) {
throw new Error("GitHub Copilot embeddings response contains duplicate indexes");
}
if (!Array.isArray(embedding) || !embedding.every((value) => typeof value === "number")) {
throw new Error("GitHub Copilot embeddings response contains an invalid embedding");
}
vectors[index] = sanitizeAndNormalizeEmbedding(embedding);
}
for (let index = 0; index < expectedCount; index += 1) {
if (vectors[index] === undefined) {
throw new Error("GitHub Copilot embeddings response missing vectors for some inputs");
}
}
return vectors as number[][];
}
async function resolveGitHubCopilotEmbeddingSession(client: GitHubCopilotEmbeddingClient): Promise<{
baseUrl: string;
headers: Record<string, string>;
}> {
const token = await resolveCopilotApiToken({
githubToken: client.githubToken,
env: client.env,
fetchImpl: client.fetchImpl,
});
const baseUrl = client.baseUrl?.trim() || token.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
return {
baseUrl,
headers: {
...COPILOT_HEADERS_STATIC,
...client.headers,
Authorization: `Bearer ${token.token}`,
},
};
}
async function createGitHubCopilotEmbeddingProvider(
client: GitHubCopilotEmbeddingClient,
): Promise<{ provider: MemoryEmbeddingProvider; client: GitHubCopilotEmbeddingClient }> {
const initialSession = await resolveGitHubCopilotEmbeddingSession(client);
const embed = async (input: string[], signal?: AbortSignal): Promise<number[][]> => {
if (input.length === 0) {
return [];
}
const session = await resolveGitHubCopilotEmbeddingSession(client);
const url = `${session.baseUrl.replace(/\/$/, "")}/embeddings`;
return await withRemoteHttpResponse({
url,
fetchImpl: client.fetchImpl,
ssrfPolicy: buildRemoteBaseUrlPolicy(session.baseUrl),
signal,
init: {
method: "POST",
headers: session.headers,
body: JSON.stringify({ model: client.model, input }),
},
onResponse: async (response) => {
if (!response.ok) {
const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES);
throw new Error(`GitHub Copilot embeddings HTTP ${response.status}: ${detail}`);
}
const payload = await readProviderJsonResponse(response, "github-copilot.embeddings", {
maxBytes: COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES,
});
return parseGitHubCopilotEmbeddingPayload(payload, input.length);
},
});
};
return {
provider: {
id: COPILOT_EMBEDDING_PROVIDER_ID,
model: client.model,
embedQuery: async (text, options) => {
const [vector] = await embed([text], options?.signal);
return vector ?? [];
},
embedBatch: async (texts, options) => await embed(texts, options?.signal),
},
client: {
...client,
baseUrl: initialSession.baseUrl,
},
};
}
export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: COPILOT_EMBEDDING_PROVIDER_ID,
transport: "remote",
authProviderId: COPILOT_EMBEDDING_PROVIDER_ID,
autoSelectPriority: 15,
allowExplicitWhenConfiguredAuto: true,
shouldContinueAutoSelection: (err: unknown) => isCopilotSetupError(err),
create: async (options) => {
const remoteGithubToken = await resolveConfiguredSecretInputString({
config: options.config,
env: process.env,
value: options.remote?.apiKey,
path: "agents.*.memorySearch.remote.apiKey",
});
const { githubToken: profileGithubToken } = await resolveFirstGithubToken({
agentDir: options.agentDir,
config: options.config,
env: process.env,
});
const githubToken = remoteGithubToken.value || profileGithubToken;
if (!githubToken) {
throw new Error("No GitHub token available for Copilot embedding provider");
}
const { token: copilotToken, baseUrl: resolvedBaseUrl } = await resolveCopilotApiToken({
githubToken,
env: process.env,
});
const baseUrl =
options.remote?.baseUrl?.trim() || resolvedBaseUrl || DEFAULT_COPILOT_API_BASE_URL;
const ssrfPolicy = buildSsrfPolicy(baseUrl);
// Always discover models even when the user pins one: this validates
// the Copilot token and confirms the plan supports embeddings before
// we attempt any embedding requests.
const availableModels = await discoverEmbeddingModels({
baseUrl,
copilotToken,
headers: options.remote?.headers,
ssrfPolicy,
});
const userModel = options.model?.trim() || undefined;
const model = pickBestModel(availableModels, userModel);
const { provider } = await createGitHubCopilotEmbeddingProvider({
baseUrl,
env: process.env,
fetchImpl: fetch,
githubToken,
headers: options.remote?.headers,
model,
});
return {
provider,
runtime: {
id: COPILOT_EMBEDDING_PROVIDER_ID,
cacheKeyData: {
provider: COPILOT_EMBEDDING_PROVIDER_ID,
baseUrl,
model,
},
},
};
},
};

View File

@@ -0,0 +1,839 @@
// Github Copilot tests cover index plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
saveAuthProfileStore,
} from "openclaw/plugin-sdk/agent-runtime";
import { MAX_DATE_TIMESTAMP_MS, MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import type {
OpenClawConfig,
OpenClawPluginApi,
ProviderAuthResult,
ProviderCatalogResult,
UnifiedModelCatalogEntry,
} from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import {
runGitHubCopilotDeviceFlow,
setGitHubCopilotDeviceFlowFetchGuardForTesting,
} from "./login.js";
const mocks = vi.hoisted(() => ({
githubCopilotLoginCommand: vi.fn(),
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
response: await fetch(params.url, params.init),
release: vi.fn(async () => {}),
})),
resolveCopilotApiToken: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
return {
...actual,
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
};
});
vi.mock("./register.runtime.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
resolveCopilotApiToken: mocks.resolveCopilotApiToken,
githubCopilotLoginCommand: mocks.githubCopilotLoginCommand,
fetchCopilotUsage: vi.fn(),
}));
import plugin from "./index.js";
const tempDirs: string[] = [];
type RegisteredMemoryEmbeddingProvider = Parameters<
OpenClawPluginApi["registerMemoryEmbeddingProvider"]
>[0];
type RegisteredProvider = Parameters<OpenClawPluginApi["registerProvider"]>[0];
type GithubCopilotTestProvider = RegisteredProvider & {
auth: Array<{
run: (ctx: unknown) => Promise<ProviderAuthResult | null>;
runNonInteractive: (ctx: unknown) => Promise<OpenClawConfig | null>;
}>;
catalog: {
run: (ctx: unknown) => Promise<ProviderCatalogResult>;
};
resolveThinkingProfile: NonNullable<RegisteredProvider["resolveThinkingProfile"]>;
};
type GithubCopilotTestModelCatalogProvider = {
liveCatalog: (ctx: unknown) => Promise<readonly UnifiedModelCatalogEntry[] | null | undefined>;
};
afterEach(async () => {
vi.clearAllMocks();
vi.unstubAllGlobals();
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
clearRuntimeAuthProfileStoreSnapshots();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
afterAll(() => {
vi.doUnmock("./register.runtime.js");
vi.resetModules();
});
async function createAgentDir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-github-copilot-test-"));
tempDirs.push(dir);
return dir;
}
function writeExistingCopilotTokenProfile(agentDir: string) {
saveAuthProfileStore(
{
version: 1,
profiles: {
"github-copilot:github": {
type: "token",
provider: "github-copilot",
token: "existing-token",
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
}
function requireFirstMockArg<T>(
mock: { mock: { calls: Array<[T, ...unknown[]]> } },
label: string,
) {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`Expected ${label}`);
}
return call[0];
}
function registerProviderAndCatalogWithPluginConfig(pluginConfig: Record<string, unknown>) {
const registerProviderMock = vi.fn<OpenClawPluginApi["registerProvider"]>();
const registerModelCatalogProviderMock =
vi.fn<OpenClawPluginApi["registerModelCatalogProvider"]>();
plugin.register(
createTestPluginApi({
id: "github-copilot",
name: "GitHub Copilot",
source: "test",
config: {},
pluginConfig,
runtime: {} as never,
registerProvider: registerProviderMock,
registerModelCatalogProvider: registerModelCatalogProviderMock,
}),
);
expect(registerProviderMock).toHaveBeenCalledTimes(1);
expect(registerModelCatalogProviderMock).toHaveBeenCalledTimes(1);
return {
provider: requireFirstMockArg(
registerProviderMock,
"provider registration",
) as GithubCopilotTestProvider,
modelCatalogProvider: requireFirstMockArg(
registerModelCatalogProviderMock,
"model catalog provider registration",
) as GithubCopilotTestModelCatalogProvider,
};
}
function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>) {
return registerProviderAndCatalogWithPluginConfig(pluginConfig).provider;
}
describe("github-copilot plugin", () => {
it("owns Claude replay thinking cleanup", () => {
const provider = registerProviderWithPluginConfig({});
const messages = [
{
role: "assistant",
content: [
{ type: "thinking", thinking: "private", thinkingSignature: "sig" },
{ type: "redacted_thinking", data: "opaque" },
{ type: "text", text: "visible" },
],
},
];
expect(provider.buildReplayPolicy?.({ modelId: "claude-haiku-4.5" } as never)).toEqual({
dropThinkingBlocks: true,
});
expect(
provider.sanitizeReplayHistory?.({
modelId: "claude-haiku-4.5",
messages,
} as never),
).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "visible" }],
},
]);
expect(
provider.sanitizeReplayHistory?.({
modelId: "gpt-5.4",
messages,
} as never),
).toBe(messages);
});
it("registers embedding provider", () => {
const registerMemoryEmbeddingProviderMock =
vi.fn<OpenClawPluginApi["registerMemoryEmbeddingProvider"]>();
plugin.register(
createTestPluginApi({
id: "github-copilot",
name: "GitHub Copilot",
source: "test",
config: {},
pluginConfig: {},
runtime: {} as never,
registerProvider: vi.fn(),
registerMemoryEmbeddingProvider: registerMemoryEmbeddingProviderMock,
}),
);
expect(registerMemoryEmbeddingProviderMock).toHaveBeenCalledTimes(1);
const adapter = requireFirstMockArg<RegisteredMemoryEmbeddingProvider>(
registerMemoryEmbeddingProviderMock,
"memory embedding provider registration",
);
expect(adapter.id).toBe("github-copilot");
});
it("skips catalog discovery when plugin discovery is disabled", async () => {
const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } });
const result = await provider.catalog.run({
config: {
plugins: {
entries: {
"github-copilot": {
config: {
discovery: { enabled: false },
},
},
},
},
},
agentDir: "/tmp/agent",
env: { GH_TOKEN: "gh_test_token" },
resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }),
} as never);
expect(result).toBeNull();
expect(mocks.resolveCopilotApiToken).not.toHaveBeenCalled();
});
it("exposes xhigh thinking for catalog-supported Copilot reasoning efforts", () => {
const provider = registerProviderWithPluginConfig({});
const profile = provider.resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-opus-4.7-1m-internal",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
});
expect(profile?.levels.map((level) => level.id)).toContain("xhigh");
});
it("exposes max thinking for catalog-supported Copilot reasoning efforts", () => {
const provider = registerProviderWithPluginConfig({});
const profile = provider.resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-fable-5",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "max"] },
});
expect(profile?.levels.map((level) => level.id)).toContain("max");
});
it("does not expose max for non-adaptive Claude Copilot models", () => {
const provider = registerProviderWithPluginConfig({});
const profile = provider.resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-opus-4-5",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "max"] },
});
expect(profile?.levels.map((level) => level.id)).not.toContain("max");
});
it("exposes xhigh thinking for non-Claude Copilot models with catalog xhigh effort", () => {
// Regression for #59416: mini-family models (e.g. gpt-5.4-mini) are
// entitled to xhigh per live /models, but the static xhigh allowlist only
// contains gpt-5.4 and gpt-5.3-codex. When live metadata wins, the
// resolved compat must drive xhigh for these non-Claude ids as well.
const provider = registerProviderWithPluginConfig({});
const profile = provider.resolveThinkingProfile({
provider: "github-copilot",
modelId: "gpt-5.4-mini",
compat: { supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh"] },
});
expect(profile?.levels.map((level) => level.id)).toContain("xhigh");
});
it("omits xhigh for non-Claude Copilot models whose catalog effort lacks it", () => {
// Negative half of the #59416 regression: live-first must not over-grant.
// gpt-5-mini reports only [low, medium, high] live, so xhigh must stay off
// even though the reporter asked for the whole mini family to gain it.
const provider = registerProviderWithPluginConfig({});
const profile = provider.resolveThinkingProfile({
provider: "github-copilot",
modelId: "gpt-5-mini",
compat: { supportedReasoningEfforts: ["low", "medium", "high"] },
});
expect(profile?.levels.map((level) => level.id)).not.toContain("xhigh");
});
it("uses live plugin config to re-enable discovery after startup disable", async () => {
mocks.resolveCopilotApiToken.mockResolvedValueOnce({
token: "copilot_api_token",
baseUrl: "https://api.githubcopilot.live",
});
const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } });
const result = await provider.catalog.run({
config: {
plugins: {
entries: {
"github-copilot": {
config: {
discovery: { enabled: true },
},
},
},
},
},
agentDir: "/tmp/agent",
env: { GH_TOKEN: "gh_test_token" },
resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }),
} as never);
expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({
githubToken: "gh_test_token",
env: { GH_TOKEN: "gh_test_token" },
});
expect(result).toEqual({
provider: {
baseUrl: "https://api.githubcopilot.live",
models: [],
},
});
});
it("dual-publishes unified live catalog rows with existing discovery semantics", async () => {
mocks.resolveCopilotApiToken.mockResolvedValueOnce({
token: "copilot_api_token",
baseUrl: "https://api.githubcopilot.live",
});
const { modelCatalogProvider } = registerProviderAndCatalogWithPluginConfig({
discovery: { enabled: false },
});
const result = await modelCatalogProvider.liveCatalog({
config: {
plugins: {
entries: {
"github-copilot": {
config: {
discovery: { enabled: true },
},
},
},
},
},
agentDir: "/tmp/agent",
env: { GH_TOKEN: "gh_test_token" },
resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }),
resolveProviderAuth: () => ({
apiKey: "gh_test_token",
mode: "token",
source: "env",
}),
} as never);
expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({
githubToken: "gh_test_token",
env: { GH_TOKEN: "gh_test_token" },
});
expect(result).toEqual([]);
});
it("offers to reuse an existing token profile during interactive onboarding", async () => {
const provider = registerProviderWithPluginConfig({});
const method = provider.auth[0];
const agentDir = await createAgentDir();
writeExistingCopilotTokenProfile(agentDir);
const prompter = {
confirm: vi.fn(async () => false),
note: vi.fn(),
};
const result = await method.run({
config: {},
env: {},
agentDir,
workspaceDir: "/tmp/workspace",
prompter,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
opts: {},
secretInputMode: "plaintext",
allowSecretRefPrompt: false,
isRemote: false,
openUrl: vi.fn(),
oauth: { createVpsAwareHandlers: vi.fn() },
} as never);
expect(prompter.confirm).toHaveBeenCalledWith({
message: "GitHub Copilot auth already exists. Re-run login?",
initialValue: false,
});
expect(mocks.githubCopilotLoginCommand).not.toHaveBeenCalled();
expect(result).toEqual({
profiles: [
{
profileId: "github-copilot:github",
credential: {
type: "token",
provider: "github-copilot",
token: "existing-token",
},
},
],
defaultModel: "github-copilot/claude-opus-4.7",
});
});
it("can refresh an existing token profile during interactive onboarding", async () => {
const provider = registerProviderWithPluginConfig({});
const method = provider.auth[0];
const agentDir = await createAgentDir();
writeExistingCopilotTokenProfile(agentDir);
const fetchMock = vi.fn(async (input: unknown, _init?: RequestInit) => {
const target =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input instanceof Request
? input.url
: String(input);
if (target === "https://github.com/login/device/code") {
return new Response(
JSON.stringify({
device_code: "device-code-stub",
user_code: "ABCD-1234",
verification_uri: "https://github.com/login/device",
expires_in: 900,
interval: 0,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (target === "https://github.com/login/oauth/access_token") {
return new Response(
JSON.stringify({ access_token: "refreshed-token", token_type: "bearer" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
throw new Error(`unexpected fetch in github-copilot refresh test: ${target}`);
});
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
}));
const prompter = {
confirm: vi.fn(async () => true),
note: vi.fn(),
};
const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
});
try {
const result = await method.run({
config: {},
env: {},
agentDir,
workspaceDir: "/tmp/workspace",
prompter,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
opts: {},
secretInputMode: "plaintext",
allowSecretRefPrompt: false,
isRemote: false,
openUrl: vi.fn(),
oauth: { createVpsAwareHandlers: vi.fn() },
} as never);
expect(prompter.confirm).toHaveBeenCalledWith({
message: "GitHub Copilot auth already exists. Re-run login?",
initialValue: false,
});
expect(mocks.githubCopilotLoginCommand).not.toHaveBeenCalled();
if (!result) {
throw new Error("Expected GitHub Copilot auth result");
}
expect(result.profiles[0]?.credential).toEqual({
type: "token",
provider: "github-copilot",
token: "refreshed-token",
});
} finally {
vi.unstubAllGlobals();
if (isTtyDescriptor) {
Object.defineProperty(process.stdin, "isTTY", isTtyDescriptor);
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY;
}
}
});
it("rejects unsafe GitHub device code lifetimes before polling", async () => {
const release = vi.fn(async () => {});
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
response: new Response(
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1e309,"interval":0}',
{ status: 200, headers: { "Content-Type": "application/json" } },
),
finalUrl: "https://github.com/login/device/code",
release,
}));
const showCode = vi.fn();
await expect(runGitHubCopilotDeviceFlow({ showCode })).rejects.toThrow(
"GitHub device code response missing fields",
);
expect(showCode).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
it("rejects GitHub device code expiries outside the Date timestamp range before polling", async () => {
const release = vi.fn(async () => {});
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
response: new Response(
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1,"interval":0}',
{ status: 200, headers: { "Content-Type": "application/json" } },
),
finalUrl: "https://github.com/login/device/code",
release,
}));
const showCode = vi.fn();
try {
await expect(runGitHubCopilotDeviceFlow({ showCode })).rejects.toThrow(
"GitHub device code response missing fields",
);
} finally {
nowSpy.mockRestore();
}
expect(showCode).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds oversized GitHub device polling intervals before waiting", async () => {
vi.useFakeTimers();
try {
const release = vi.fn(async () => {});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
let accessTokenPolls = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
if (params.url === "https://github.com/login/device/code") {
return {
response: new Response(
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":3000010,"interval":3000000}',
{ status: 200, headers: { "Content-Type": "application/json" } },
),
finalUrl: params.url,
release,
};
}
accessTokenPolls += 1;
return {
response: new Response(
JSON.stringify(
accessTokenPolls === 1
? { error: "authorization_pending" }
: { access_token: "refreshed-token", token_type: "bearer" },
),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
finalUrl: params.url,
release,
};
});
const flow = runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) });
await vi.waitFor(() =>
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS),
);
await vi.advanceTimersByTimeAsync(MAX_TIMER_TIMEOUT_MS);
expect(accessTokenPolls).toBe(1);
await vi.advanceTimersByTimeAsync(3_000_000_000 - MAX_TIMER_TIMEOUT_MS);
await expect(flow).resolves.toEqual({
status: "authorized",
accessToken: "refreshed-token",
});
} finally {
vi.useRealTimers();
}
});
it("stores GitHub Copilot token from non-interactive onboarding", async () => {
const provider = registerProviderWithPluginConfig({});
const method = provider.auth[0];
const agentDir = await createAgentDir();
const runtime = { error: vi.fn(), exit: vi.fn() };
const result = await method.runNonInteractive({
authChoice: "github-copilot",
config: {},
baseConfig: {},
opts: { githubCopilotToken: "ghu_test\r\n123" },
runtime,
agentDir,
resolveApiKey: vi.fn(async () => ({
key: "ghu_test123",
source: "flag" as const,
})),
toApiKeyCredential: vi.fn(),
});
expect(runtime.error).not.toHaveBeenCalled();
expect(result?.auth?.profiles?.["github-copilot:github"]).toEqual({
provider: "github-copilot",
mode: "token",
});
expect(result?.agents?.defaults?.model).toEqual({
primary: "github-copilot/claude-opus-4.7",
});
expect(result?.agents?.defaults?.models?.["github-copilot/claude-opus-4.7"]).toStrictEqual({});
const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"];
expect(profile).toEqual({
type: "token",
provider: "github-copilot",
token: "ghu_test123",
});
});
it("stores env-backed token refs for non-interactive onboarding ref mode", async () => {
const provider = registerProviderWithPluginConfig({});
const method = provider.auth[0];
const agentDir = await createAgentDir();
const runtime = { error: vi.fn(), exit: vi.fn() };
const result = await method.runNonInteractive({
authChoice: "github-copilot",
config: { agents: { defaults: { model: { fallbacks: ["openai/gpt-5.4"] } } } },
baseConfig: {},
opts: { secretInputMode: "ref" },
runtime,
agentDir,
resolveApiKey: vi.fn(async () => ({
key: "ghu_from_env",
source: "env" as const,
envVarName: "COPILOT_GITHUB_TOKEN",
})),
toApiKeyCredential: vi.fn(),
});
expect(runtime.error).not.toHaveBeenCalled();
expect(result?.agents?.defaults?.model).toEqual({
fallbacks: ["openai/gpt-5.4"],
primary: "github-copilot/claude-opus-4.7",
});
const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"];
expect(profile).toEqual({
type: "token",
provider: "github-copilot",
tokenRef: {
source: "env",
provider: "default",
id: "COPILOT_GITHUB_TOKEN",
},
});
});
it("falls back to GH_TOKEN during non-interactive onboarding", async () => {
const provider = registerProviderWithPluginConfig({});
const method = provider.auth[0];
const agentDir = await createAgentDir();
const runtime = { error: vi.fn(), exit: vi.fn() };
const resolveApiKey = vi.fn(async ({ envVar }: { envVar?: string }) =>
envVar === "GH_TOKEN"
? {
key: "ghu_from_gh_token",
source: "env" as const,
envVarName: "GH_TOKEN",
}
: null,
);
const result = await method.runNonInteractive({
authChoice: "github-copilot",
config: {},
baseConfig: {},
opts: {},
runtime,
agentDir,
resolveApiKey,
toApiKeyCredential: vi.fn(),
});
expect(runtime.error).not.toHaveBeenCalled();
expect(resolveApiKey).toHaveBeenCalledTimes(2);
expect(resolveApiKey.mock.calls.map(([params]) => params)).toEqual([
{
provider: "github-copilot",
flagName: "--github-copilot-token",
envVar: "COPILOT_GITHUB_TOKEN",
envVarName: "COPILOT_GITHUB_TOKEN",
allowProfile: false,
required: false,
},
{
provider: "github-copilot",
flagName: "--github-copilot-token",
envVar: "GH_TOKEN",
envVarName: "GH_TOKEN",
allowProfile: false,
required: false,
},
]);
expect(result?.auth?.profiles?.["github-copilot:github"]).toEqual({
provider: "github-copilot",
mode: "token",
});
const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"];
expect(profile).toEqual({
type: "token",
provider: "github-copilot",
token: "ghu_from_gh_token",
});
});
it("preserves an existing primary model during non-interactive onboarding", async () => {
const provider = registerProviderWithPluginConfig({});
const method = provider.auth[0];
const agentDir = await createAgentDir();
const runtime = { error: vi.fn(), exit: vi.fn() };
const result = await method.runNonInteractive({
authChoice: "github-copilot",
config: {
agents: {
defaults: {
model: {
primary: "github-copilot/gpt-5.4",
fallbacks: ["openai/gpt-5.4"],
},
models: {
"github-copilot/gpt-5.4": { label: "Existing" },
},
},
},
},
baseConfig: {},
opts: { githubCopilotToken: "ghu_test" },
runtime,
agentDir,
resolveApiKey: vi.fn(async () => ({
key: "ghu_test",
source: "flag" as const,
})),
toApiKeyCredential: vi.fn(),
});
expect(runtime.error).not.toHaveBeenCalled();
expect(result?.agents?.defaults?.model).toEqual({
primary: "github-copilot/gpt-5.4",
fallbacks: ["openai/gpt-5.4"],
});
expect(result?.agents?.defaults?.models).toEqual({
"github-copilot/gpt-5.4": { label: "Existing" },
});
});
it("reuses an existing token profile during non-interactive onboarding", async () => {
const provider = registerProviderWithPluginConfig({});
const method = provider.auth[0];
const agentDir = await createAgentDir();
const runtime = { error: vi.fn(), exit: vi.fn() };
writeExistingCopilotTokenProfile(agentDir);
const result = await method.runNonInteractive({
authChoice: "github-copilot",
config: {},
baseConfig: {},
opts: {},
runtime,
agentDir,
resolveApiKey: vi.fn(async () => null),
toApiKeyCredential: vi.fn(),
});
expect(runtime.error).not.toHaveBeenCalled();
expect(result?.auth?.profiles?.["github-copilot:github"]).toEqual({
provider: "github-copilot",
mode: "token",
});
});
it("does not emit a second missing-token error after ref-mode flag validation fails", async () => {
const provider = registerProviderWithPluginConfig({});
const method = provider.auth[0];
const agentDir = await createAgentDir();
const runtime = { error: vi.fn(), exit: vi.fn() };
const result = await method.runNonInteractive({
authChoice: "github-copilot",
config: {},
baseConfig: {},
opts: {
githubCopilotToken: "ghu_secret",
secretInputMode: "ref",
},
runtime,
agentDir,
resolveApiKey: vi.fn(async () => null),
toApiKeyCredential: vi.fn(),
});
expect(result).toBeNull();
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error).toHaveBeenCalledWith(
[
"--github-copilot-token cannot be used with --secret-input-mode ref unless COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN is set in env.",
"Set one of those env vars and omit --github-copilot-token, or use --secret-input-mode plaintext.",
].join("\n"),
);
});
});

View File

@@ -0,0 +1,494 @@
// Github Copilot plugin entrypoint registers its OpenClaw integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
definePluginEntry,
type ProviderCatalogContext,
type ProviderCatalogResult,
type ProviderAuthContext,
type ProviderAuthResult,
type ProviderAuthMethodNonInteractiveContext,
type UnifiedModelCatalogEntry,
type UnifiedModelCatalogProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import {
applyAuthProfileConfig,
coerceSecretRef,
ensureAuthProfileStore,
listProfilesForProvider,
normalizeOptionalSecretInput,
resolveDefaultSecretProviderAlias,
upsertAuthProfileWithLock,
} from "openclaw/plugin-sdk/provider-auth";
import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared";
import { resolveFirstGithubToken } from "./auth.js";
import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js";
import { resolveCopilotExtendedThinkingLevels } from "./model-metadata.js";
import {
PROVIDER_ID,
fetchCopilotModelCatalog,
resolveCopilotForwardCompatModel,
} from "./models.js";
import {
buildGithubCopilotReplayPolicy,
sanitizeGithubCopilotReplayHistory,
} from "./replay-policy.js";
import { wrapCopilotProviderStream } from "./stream.js";
const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
const DEFAULT_COPILOT_MODEL = "github-copilot/claude-opus-4.7";
const DEFAULT_COPILOT_PROFILE_ID = "github-copilot:github";
type GithubCopilotPluginConfig = {
discovery?: {
enabled?: boolean;
};
};
async function loadGithubCopilotRuntime() {
return await import("./register.runtime.js");
}
function applyCopilotDefaultModel(cfg: OpenClawConfig): OpenClawConfig {
const defaults = cfg.agents?.defaults;
const existingModel = defaults?.model;
const existingPrimary =
typeof existingModel === "string"
? existingModel.trim()
: typeof existingModel === "object" && typeof existingModel?.primary === "string"
? existingModel.primary.trim()
: "";
if (existingPrimary) {
return cfg;
}
const fallbacks =
typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel
? (existingModel as { fallbacks?: string[] }).fallbacks
: undefined;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...defaults,
model: {
...(fallbacks ? { fallbacks } : undefined),
primary: DEFAULT_COPILOT_MODEL,
},
models: {
...defaults?.models,
[DEFAULT_COPILOT_MODEL]: defaults?.models?.[DEFAULT_COPILOT_MODEL] ?? {},
},
},
},
};
}
function resolveExistingCopilotTokenProfileId(agentDir?: string): string | undefined {
const authStore = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
return listProfilesForProvider(authStore, PROVIDER_ID).find((profileId) => {
const profile = authStore.profiles[profileId];
if (profile?.type !== "token") {
return false;
}
return Boolean(
normalizeOptionalSecretInput(profile.token) || coerceSecretRef(profile.tokenRef)?.id.trim(),
);
});
}
function resolveExistingCopilotAuthResult(agentDir?: string): ProviderAuthResult | null {
const profileId = resolveExistingCopilotTokenProfileId(agentDir);
if (!profileId) {
return null;
}
const authStore = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const credential = authStore.profiles[profileId];
if (!credential || credential.type !== "token") {
return null;
}
return {
profiles: [
{
profileId,
credential,
},
],
defaultModel: DEFAULT_COPILOT_MODEL,
};
}
async function resolveCopilotNonInteractiveToken(
ctx: ProviderAuthMethodNonInteractiveContext,
flagValue: string | undefined,
) {
const resolveFromEnvChain = async () => {
for (const envVar of COPILOT_ENV_VARS) {
const resolved = await ctx.resolveApiKey({
provider: PROVIDER_ID,
flagName: "--github-copilot-token",
envVar,
envVarName: envVar,
allowProfile: false,
required: false,
});
if (resolved) {
return resolved;
}
}
return null;
};
if (ctx.opts.secretInputMode === "ref") {
const resolved = await resolveFromEnvChain();
if (resolved) {
return resolved;
}
if (flagValue) {
ctx.runtime.error(
[
"--github-copilot-token cannot be used with --secret-input-mode ref unless COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN is set in env.",
"Set one of those env vars and omit --github-copilot-token, or use --secret-input-mode plaintext.",
].join("\n"),
);
ctx.runtime.exit(1);
}
return null;
}
const primary = await ctx.resolveApiKey({
provider: PROVIDER_ID,
flagValue,
flagName: "--github-copilot-token",
envVar: COPILOT_ENV_VARS[0],
envVarName: COPILOT_ENV_VARS[0],
allowProfile: false,
required: false,
});
if (primary || flagValue) {
return primary;
}
for (const envVar of COPILOT_ENV_VARS.slice(1)) {
const resolved = await ctx.resolveApiKey({
provider: PROVIDER_ID,
flagName: "--github-copilot-token",
envVar,
envVarName: envVar,
allowProfile: false,
required: false,
});
if (resolved) {
return resolved;
}
}
return null;
}
async function runGitHubCopilotNonInteractiveAuth(
ctx: ProviderAuthMethodNonInteractiveContext,
): Promise<OpenClawConfig | null> {
const opts = ctx.opts as Record<string, unknown> | undefined;
const flagValue = normalizeOptionalSecretInput(opts?.githubCopilotToken);
const resolved = await resolveCopilotNonInteractiveToken(ctx, flagValue);
let profileId = DEFAULT_COPILOT_PROFILE_ID;
if (resolved) {
const useTokenRef = ctx.opts.secretInputMode === "ref" && resolved.source === "env";
if (useTokenRef && !resolved.envVarName) {
ctx.runtime.error(
[
'--secret-input-mode ref requires an explicit environment variable for provider "github-copilot".',
"Set COPILOT_GITHUB_TOKEN in env and retry, or use --secret-input-mode plaintext.",
].join("\n"),
);
ctx.runtime.exit(1);
return null;
}
await upsertAuthProfileWithLock({
profileId,
credential: {
type: "token",
provider: PROVIDER_ID,
...(useTokenRef
? {
tokenRef: {
source: "env",
provider: resolveDefaultSecretProviderAlias(ctx.baseConfig, "env", {
preferFirstProviderForSource: true,
}),
id: resolved.envVarName!,
},
}
: { token: resolved.key }),
},
agentDir: ctx.agentDir,
});
} else {
if (flagValue && ctx.opts.secretInputMode === "ref") {
return null;
}
const existingProfileId = resolveExistingCopilotTokenProfileId(ctx.agentDir);
if (!existingProfileId) {
ctx.runtime.error(
"Missing --github-copilot-token (or COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN env var) for --auth-choice github-copilot.",
);
ctx.runtime.exit(1);
return null;
}
profileId = existingProfileId;
}
return applyCopilotDefaultModel(
applyAuthProfileConfig(ctx.config, {
profileId,
provider: PROVIDER_ID,
mode: "token",
}),
);
}
export default definePluginEntry({
id: "github-copilot",
name: "GitHub Copilot Provider",
description: "Bundled GitHub Copilot provider plugin",
register(api) {
const startupPluginConfig = (api.pluginConfig ?? {}) as GithubCopilotPluginConfig;
function resolveCurrentPluginConfig(config?: OpenClawConfig): GithubCopilotPluginConfig {
const runtimePluginConfig = resolvePluginConfigObject(config, "github-copilot");
if (runtimePluginConfig) {
return runtimePluginConfig as GithubCopilotPluginConfig;
}
return config ? {} : startupPluginConfig;
}
async function runGithubCopilotCatalog(
ctx: ProviderCatalogContext,
): Promise<ProviderCatalogResult> {
const pluginConfig = resolveCurrentPluginConfig(ctx.config);
const discoveryEnabled = pluginConfig.discovery?.enabled;
if (discoveryEnabled === false) {
return null;
}
const { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } =
await loadGithubCopilotRuntime();
const { githubToken, hasProfile } = await resolveFirstGithubToken({
agentDir: ctx.agentDir,
config: ctx.config,
env: ctx.env,
});
if (!hasProfile && !githubToken) {
return null;
}
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
let copilotApiToken: string | undefined;
if (githubToken) {
try {
const token = await resolveCopilotApiToken({
githubToken,
env: ctx.env,
});
baseUrl = token.baseUrl;
copilotApiToken = token.token;
} catch {
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
}
}
// Try to fetch the live model catalog from Copilot's /models endpoint so
// the runtime tracks per-account entitlements and accurate context
// windows (max_context_window_tokens) without manifest churn. On any
// failure we return an empty model list, which lets the static manifest
// catalog continue to be the visible fallback for users.
let discoveredModels: Awaited<ReturnType<typeof fetchCopilotModelCatalog>> = [];
if (copilotApiToken) {
try {
discoveredModels = await getCachedLiveCatalogValue({
keyParts: [PROVIDER_ID, "models", baseUrl, copilotApiToken],
load: async () =>
await fetchCopilotModelCatalog({
copilotApiToken,
baseUrl,
}),
});
} catch {
discoveredModels = [];
}
}
return {
provider: {
baseUrl,
models: discoveredModels,
},
};
}
async function runGithubCopilotUnifiedLiveCatalog(
ctx: UnifiedModelCatalogProviderContext,
): Promise<UnifiedModelCatalogEntry[] | null> {
const result = await runGithubCopilotCatalog(ctx);
if (!result || !("provider" in result)) {
return null;
}
return (result.provider.models ?? []).map((model) => {
const entry: UnifiedModelCatalogEntry = {
kind: "text",
provider: PROVIDER_ID,
model: model.id,
source: "live",
};
if (model.name) {
entry.label = model.name;
}
return entry;
});
}
async function runGitHubCopilotAuth(ctx: ProviderAuthContext) {
const existing = resolveExistingCopilotAuthResult(ctx.agentDir);
if (existing) {
const runLogin = await ctx.prompter.confirm({
message: "GitHub Copilot auth already exists. Re-run login?",
initialValue: false,
});
if (!runLogin) {
return existing;
}
}
await ctx.prompter.note(
[
"This will open a GitHub device login to authorize Copilot.",
"Requires an active GitHub Copilot subscription.",
].join("\n"),
"GitHub Copilot",
);
const { runGitHubCopilotDeviceFlow } = await import("./login.js");
const result = await runGitHubCopilotDeviceFlow({
showCode: async ({ verificationUrl, userCode, expiresInMs }) => {
const expiresInMinutes = Math.max(1, Math.round(expiresInMs / 60_000));
await ctx.prompter.note(
[
"Open this URL in your browser and enter the code below.",
`URL: ${verificationUrl}`,
`Code: ${userCode}`,
`Code expires in ${expiresInMinutes} minutes. Never share it.`,
"",
"If a browser does not open automatically after you continue, copy the URL manually.",
].join("\n"),
"Authorize GitHub Copilot",
);
},
openUrl: async (url) => {
await ctx.openUrl(url);
},
});
if (result.status === "access_denied") {
await ctx.prompter.note("GitHub Copilot login was cancelled.", "GitHub Copilot");
return { profiles: [] };
}
if (result.status === "expired") {
await ctx.prompter.note(
"The GitHub device code expired. Retry login to get a new code.",
"GitHub Copilot",
);
return { profiles: [] };
}
return {
profiles: [
{
profileId: DEFAULT_COPILOT_PROFILE_ID,
credential: {
type: "token" as const,
provider: PROVIDER_ID,
token: result.accessToken,
},
},
],
defaultModel: DEFAULT_COPILOT_MODEL,
};
}
api.registerMemoryEmbeddingProvider(githubCopilotMemoryEmbeddingProviderAdapter);
api.registerProvider({
id: PROVIDER_ID,
label: "GitHub Copilot",
docsPath: "/providers/models",
envVars: COPILOT_ENV_VARS,
auth: [
{
id: "device",
label: "GitHub device login",
hint: "Browser device-code flow",
kind: "device_code",
run: async (ctx) => await runGitHubCopilotAuth(ctx),
runNonInteractive: async (ctx) => await runGitHubCopilotNonInteractiveAuth(ctx),
},
],
wizard: {
setup: {
choiceId: "github-copilot",
choiceLabel: "GitHub Copilot",
choiceHint: "Device login with your GitHub account",
methodId: "device",
modelSelection: {
promptWhenAuthChoiceProvided: true,
},
},
},
catalog: {
order: "late",
run: runGithubCopilotCatalog,
},
resolveDynamicModel: (ctx) => resolveCopilotForwardCompatModel(ctx),
wrapStreamFn: wrapCopilotProviderStream,
buildReplayPolicy: ({ modelId }) => buildGithubCopilotReplayPolicy(modelId),
sanitizeReplayHistory: sanitizeGithubCopilotReplayHistory,
resolveThinkingProfile: ({ modelId, compat }) => {
const extendedLevels = resolveCopilotExtendedThinkingLevels(modelId, compat);
return {
levels: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
...extendedLevels.map((id) => ({ id })),
],
};
},
prepareRuntimeAuth: async (ctx) => {
const { resolveCopilotApiToken } = await loadGithubCopilotRuntime();
const token = await resolveCopilotApiToken({
githubToken: ctx.apiKey,
env: ctx.env,
});
return {
apiKey: token.token,
baseUrl: token.baseUrl,
expiresAt: token.expiresAt,
};
},
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
fetchUsageSnapshot: async (ctx) => {
const { fetchCopilotUsage } = await loadGithubCopilotRuntime();
return await fetchCopilotUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn);
},
});
api.registerModelCatalogProvider({
provider: PROVIDER_ID,
kinds: ["text"],
liveCatalog: runGithubCopilotUnifiedLiveCatalog,
});
},
});

View File

@@ -0,0 +1,205 @@
// Github Copilot tests cover device-flow login behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
runGitHubCopilotDeviceFlow,
setGitHubCopilotDeviceFlowFetchGuardForTesting,
} from "./login.js";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
// A valid device code payload GitHub returns on the first step.
const VALID_DEVICE_CODE_BODY = {
device_code: "dev-code-abc123",
user_code: "ABCD-1234",
verification_uri: "https://github.com/login/device",
expires_in: 900,
interval: 5,
};
function guardResponse(body: unknown, status = 200, url = DEVICE_CODE_URL) {
return {
response: new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
finalUrl: url,
release: vi.fn(async () => {}),
};
}
afterEach(() => {
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
vi.restoreAllMocks();
});
describe("runGitHubCopilotDeviceFlow — normal flow", () => {
it("returns authorized status and access token on successful flow", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
callIdx += 1;
if (callIdx === 1) {
expect(params.url).toBe(DEVICE_CODE_URL);
return guardResponse(VALID_DEVICE_CODE_BODY);
}
expect(params.url).toBe(ACCESS_TOKEN_URL);
return guardResponse(
{ access_token: "ghu_tok_xyz", token_type: "bearer" },
200,
ACCESS_TOKEN_URL,
);
});
const showCode = vi.fn(async () => {});
const result = await runGitHubCopilotDeviceFlow({ showCode });
expect(result).toEqual({ status: "authorized", accessToken: "ghu_tok_xyz" });
expect(showCode).toHaveBeenCalledWith({
verificationUrl: "https://github.com/login/device",
userCode: "ABCD-1234",
expiresInMs: expect.any(Number),
});
expect(callIdx).toBe(2);
});
it("returns access_denied when GitHub rejects the authorization", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
}
return guardResponse({ error: "access_denied" }, 200, ACCESS_TOKEN_URL);
});
const result = await runGitHubCopilotDeviceFlow({
showCode: vi.fn(async () => {}),
});
expect(result).toEqual({ status: "access_denied" });
});
it("returns expired when GitHub reports expired_token", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
}
return guardResponse({ error: "expired_token" }, 200, ACCESS_TOKEN_URL);
});
const result = await runGitHubCopilotDeviceFlow({
showCode: vi.fn(async () => {}),
});
expect(result).toEqual({ status: "expired" });
});
});
describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
it("throws with failureLabel on non-OK device code response", async () => {
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => guardResponse({}, 401));
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn() })).rejects.toThrow(
"GitHub device code failed: HTTP 401",
);
});
it("throws with failureLabel on non-OK access token response", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
}
return guardResponse({}, 500, ACCESS_TOKEN_URL);
});
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) })).rejects.toThrow(
"GitHub device token failed: HTTP 500",
);
});
});
describe("postGitHubDeviceFlowForm — response size bound", () => {
it("bounds oversized device code body and cancels the stream", async () => {
const chunk = new Uint8Array(1024 * 1024); // 1 MiB
let readCount = 0;
let canceled = false;
// 64 chunks × 1 MiB = 64 MiB — far exceeds the 16 MiB cap
const oversizedBody = new ReadableStream<Uint8Array>({
pull(controller) {
if (readCount >= 64) {
controller.close();
return;
}
readCount += 1;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
response: new Response(oversizedBody, {
status: 200,
headers: { "Content-Type": "application/json" },
}),
finalUrl: DEVICE_CODE_URL,
release: async () => {},
}));
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn() })).rejects.toThrow(
"github-copilot.device-flow",
);
// Stream must be cancelled before all 64 MiB are consumed
expect(readCount).toBeLessThan(64);
expect(canceled).toBe(true);
});
it("bounds oversized access token body and cancels the stream", async () => {
const chunk = new Uint8Array(1024 * 1024); // 1 MiB
let readCount = 0;
let canceled = false;
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
}
const oversizedBody = new ReadableStream<Uint8Array>({
pull(controller) {
if (readCount >= 64) {
controller.close();
return;
}
readCount += 1;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
return {
response: new Response(oversizedBody, {
status: 200,
headers: { "Content-Type": "application/json" },
}),
finalUrl: ACCESS_TOKEN_URL,
release: async () => {},
};
});
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) })).rejects.toThrow(
"github-copilot.device-flow",
);
// Stream must be cancelled before all 64 MiB are consumed
expect(readCount).toBeLessThan(64);
expect(canceled).toBe(true);
});
});

View File

@@ -0,0 +1,374 @@
// Github Copilot plugin module implements login behavior.
import { intro, note, outro, spinner } from "@clack/prompts";
import { stylePromptTitle } from "openclaw/plugin-sdk/cli-runtime";
import { logConfigUpdated, updateConfig } from "openclaw/plugin-sdk/config-mutation";
import {
resolveExpiresAtMsFromDurationMs,
nonNegativeSecondsToSafeMilliseconds,
positiveSecondsToSafeMilliseconds,
resolveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import {
applyAuthProfileConfig,
ensureAuthProfileStore,
upsertAuthProfileWithLock,
} from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
const GITHUB_DEVICE_VERIFICATION_URL = "https://github.com/login/device";
const GITHUB_AUTH_SSRF_POLICY: SsrFPolicy = { hostnameAllowlist: ["github.com"] };
type DeviceCodeResponse = {
deviceCode: string;
userCode: string;
verificationUri: string;
expiresInMs: number;
expiresAt: number;
intervalMs: number;
};
type DeviceTokenResponse =
| {
access_token: string;
token_type: string;
scope?: string;
}
| {
error: string;
error_description?: string;
error_uri?: string;
};
const GITHUB_DEVICE_ACCESS_DENIED = Symbol("github-device-access-denied");
const GITHUB_DEVICE_EXPIRED = Symbol("github-device-expired");
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
class GitHubDeviceFlowError extends Error {
readonly kind: symbol;
constructor(kind: symbol, message: string) {
super(message);
this.kind = kind;
this.name = "GitHubDeviceFlowError";
}
}
let githubDeviceFlowFetchGuard = fetchWithSsrFGuard;
export function setGitHubCopilotDeviceFlowFetchGuardForTesting(
impl: typeof fetchWithSsrFGuard | null,
): void {
githubDeviceFlowFetchGuard = impl ?? fetchWithSsrFGuard;
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
function isGitHubDeviceAccessDeniedError(err: unknown): boolean {
return err instanceof GitHubDeviceFlowError && err.kind === GITHUB_DEVICE_ACCESS_DENIED;
}
function isGitHubDeviceExpiredError(err: unknown): boolean {
return err instanceof GitHubDeviceFlowError && err.kind === GITHUB_DEVICE_EXPIRED;
}
function parseJsonResponse(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error("Unexpected response from GitHub");
}
return value as Record<string, unknown>;
}
function parseDeviceCodeResponse(
value: Record<string, unknown>,
issuedAt: number,
): DeviceCodeResponse {
const expiresInMs = positiveSecondsToSafeMilliseconds(value.expires_in);
const intervalMs = nonNegativeSecondsToSafeMilliseconds(value.interval);
const expiresAt =
expiresInMs === undefined
? undefined
: resolveExpiresAtMsFromDurationMs(expiresInMs, { nowMs: issuedAt });
if (
typeof value.device_code !== "string" ||
!value.device_code ||
typeof value.user_code !== "string" ||
!value.user_code ||
typeof value.verification_uri !== "string" ||
!value.verification_uri ||
expiresInMs === undefined ||
expiresAt === undefined ||
intervalMs === undefined
) {
throw new Error("GitHub device code response missing fields");
}
return {
deviceCode: value.device_code,
userCode: value.user_code,
verificationUri: value.verification_uri,
expiresInMs,
expiresAt,
intervalMs,
};
}
async function postGitHubDeviceFlowForm(params: {
url: string;
body: URLSearchParams;
failureLabel: string;
}): Promise<Record<string, unknown>> {
const { response, release } = await githubDeviceFlowFetchGuard({
url: params.url,
init: {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.body,
},
requireHttps: true,
policy: GITHUB_AUTH_SSRF_POLICY,
auditContext: "github-copilot-device-flow",
});
try {
if (!response.ok) {
throw new Error(`${params.failureLabel}: HTTP ${response.status}`);
}
return parseJsonResponse(
await readProviderJsonResponse(response, "github-copilot.device-flow"),
);
} finally {
await release();
}
}
async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> {
const body = new URLSearchParams({
client_id: CLIENT_ID,
scope: params.scope,
});
const json = await postGitHubDeviceFlowForm({
url: DEVICE_CODE_URL,
body,
failureLabel: "GitHub device code failed",
});
// Anchor expiry to when GitHub issued the code, before UI prompts or browser launch.
return parseDeviceCodeResponse(json, Date.now());
}
async function pollForAccessToken(params: {
deviceCode: string;
intervalMs: number;
expiresAt: number;
}): Promise<string> {
const bodyBase = new URLSearchParams({
client_id: CLIENT_ID,
device_code: params.deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
while (Date.now() < params.expiresAt) {
const json = (await postGitHubDeviceFlowForm({
url: ACCESS_TOKEN_URL,
body: bodyBase,
failureLabel: "GitHub device token failed",
})) as DeviceTokenResponse;
if ("access_token" in json && typeof json.access_token === "string") {
return json.access_token;
}
const err = "error" in json ? json.error : "unknown";
if (err === "authorization_pending") {
await sleepGitHubDevicePollDelay(params.intervalMs, params.expiresAt);
continue;
}
if (err === "slow_down") {
await sleepGitHubDevicePollDelay(params.intervalMs + 2000, params.expiresAt);
continue;
}
if (err === "expired_token") {
throw new GitHubDeviceFlowError(
GITHUB_DEVICE_EXPIRED,
"GitHub device code expired; run login again",
);
}
if (err === "access_denied") {
throw new GitHubDeviceFlowError(GITHUB_DEVICE_ACCESS_DENIED, "GitHub login cancelled");
}
throw new Error(`GitHub device flow error: ${err}`);
}
throw new GitHubDeviceFlowError(
GITHUB_DEVICE_EXPIRED,
"GitHub device code expired; run login again",
);
}
async function sleepGitHubDevicePollDelay(delayMs: number, expiresAt: number): Promise<void> {
const requestedDelayMs = Math.max(1, Math.floor(delayMs));
const targetAt = Math.min(Date.now() + requestedDelayMs, expiresAt);
while (Date.now() < targetAt) {
const remainingMs = Math.max(1, targetAt - Date.now());
const safeDelayMs = resolveTimerTimeoutMs(remainingMs, 1);
await new Promise((resolve) => {
setTimeout(resolve, Math.min(safeDelayMs, remainingMs));
});
}
}
function normalizeGitHubDeviceVerificationUrl(raw: string): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error("GitHub device flow returned an invalid verification URL");
}
if (
parsed.protocol !== "https:" ||
parsed.hostname !== "github.com" ||
parsed.pathname !== "/login/device" ||
parsed.username ||
parsed.password
) {
throw new Error("GitHub device flow returned an unexpected verification URL");
}
return GITHUB_DEVICE_VERIFICATION_URL;
}
function normalizeGitHubDeviceUserCode(raw: string): string {
const userCode = raw.trim();
if (!userCode || userCode.length > 64) {
throw new Error("GitHub device flow returned an invalid user code");
}
return userCode;
}
export type GitHubCopilotDeviceFlowResult =
| { status: "authorized"; accessToken: string }
| { status: "access_denied" }
| { status: "expired" };
export type GitHubCopilotDeviceFlowIO = {
showCode(args: { verificationUrl: string; userCode: string; expiresInMs: number }): Promise<void>;
openUrl?: (url: string) => Promise<void>;
};
export async function runGitHubCopilotDeviceFlow(
io: GitHubCopilotDeviceFlowIO,
): Promise<GitHubCopilotDeviceFlowResult> {
const device = await requestDeviceCode({ scope: "read:user" });
const verificationUrl = normalizeGitHubDeviceVerificationUrl(device.verificationUri);
const userCode = normalizeGitHubDeviceUserCode(device.userCode);
await io.showCode({
verificationUrl,
userCode,
expiresInMs: device.expiresInMs,
});
try {
await io.openUrl?.(verificationUrl);
} catch {
// The code and URL have already been shown. Browser launch is best-effort.
}
try {
const accessToken = await pollForAccessToken({
deviceCode: device.deviceCode,
intervalMs: Math.max(1000, device.intervalMs),
expiresAt: device.expiresAt,
});
return { status: "authorized", accessToken };
} catch (err) {
if (isGitHubDeviceAccessDeniedError(err)) {
return { status: "access_denied" };
}
if (isGitHubDeviceExpiredError(err)) {
return { status: "expired" };
}
throw err;
}
}
export async function githubCopilotLoginCommand(
opts: { profileId?: string; yes?: boolean; agentDir?: string },
runtime: RuntimeEnv,
) {
if (!process.stdin.isTTY) {
throw new Error("github-copilot login requires an interactive TTY.");
}
intro(stylePromptTitle("GitHub Copilot login"));
const profileId = opts.profileId?.trim() || "github-copilot:github";
const store = ensureAuthProfileStore(opts.agentDir, {
allowKeychainPrompt: false,
});
if (store.profiles[profileId] && !opts.yes) {
note(
`Auth profile already exists: ${profileId}\nRe-running will overwrite it.`,
stylePromptTitle("Existing credentials"),
);
}
const spin = spinner();
spin.start("Requesting device code from GitHub...");
const device = await requestDeviceCode({ scope: "read:user" });
spin.stop("Device code ready");
note(
[`Visit: ${device.verificationUri}`, `Code: ${device.userCode}`].join("\n"),
stylePromptTitle("Authorize"),
);
const intervalMs = Math.max(1000, device.intervalMs);
const polling = spinner();
polling.start("Waiting for GitHub authorization...");
const accessToken = await pollForAccessToken({
deviceCode: device.deviceCode,
intervalMs,
expiresAt: device.expiresAt,
});
polling.stop("GitHub access token acquired");
await upsertAuthProfileWithLockOrThrow({
profileId,
credential: {
type: "token",
provider: "github-copilot",
token: accessToken,
},
agentDir: opts.agentDir,
});
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, {
provider: "github-copilot",
profileId,
mode: "token",
}),
);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (github-copilot/token)`);
outro("Done");
}

View File

@@ -0,0 +1,133 @@
// Github Copilot plugin module implements model metadata behavior.
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { supportsClaudeAdaptiveThinking } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
type CopilotRuntimeApi = "anthropic-messages" | "openai-completions" | "openai-responses";
type CopilotReasoningCompat = {
supportedReasoningEfforts?: readonly string[] | null;
};
const COPILOT_CHAT_COMPLETIONS_COMPAT: ModelDefinitionConfig["compat"] = {
supportsStore: false,
supportsDeveloperRole: false,
supportsUsageInStreaming: false,
maxTokensField: "max_tokens",
};
const COPILOT_XHIGH_MODEL_IDS = new Set(["gpt-5.4", "gpt-5.3-codex"]);
const STATIC_MODEL_OVERRIDES = new Map<string, Partial<ModelDefinitionConfig>>([
[
"claude-opus-4.6-1m",
{
name: "Claude Opus 4.6 (1M context)",
api: "anthropic-messages",
reasoning: true,
contextWindow: 1_000_000,
maxTokens: 64_000,
thinkingLevelMap: { xhigh: null, max: null },
compat: { supportedReasoningEfforts: ["low", "medium", "high"] },
},
],
[
"claude-opus-4.7-1m-internal",
{
name: "Claude Opus 4.7 (1M context)",
api: "anthropic-messages",
reasoning: true,
contextWindow: 1_000_000,
maxTokens: 64_000,
thinkingLevelMap: { xhigh: "xhigh", max: null },
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
},
],
[
"gpt-5.5",
{
name: "GPT-5.5",
reasoning: true,
contextWindow: 400_000,
maxTokens: 128_000,
},
],
]);
function isCopilotGeminiModelId(modelId: string): boolean {
return /(?:^|[-_.])gemini(?:$|[-_.])/.test(modelId);
}
function isCopilotClaude45ModelId(modelId: string): boolean {
return /^claude-(?:haiku|opus|sonnet)-4[.-]5(?:$|[-.])/.test(modelId);
}
export function resolveCopilotTransportApi(modelId: string): CopilotRuntimeApi {
const normalized = normalizeOptionalLowercaseString(modelId) ?? "";
if (normalized.includes("claude")) {
return "anthropic-messages";
}
if (isCopilotGeminiModelId(normalized)) {
return "openai-completions";
}
return "openai-responses";
}
export function resolveCopilotModelCompat(
modelId: string,
): ModelDefinitionConfig["compat"] | undefined {
const normalized = normalizeOptionalLowercaseString(modelId) ?? "";
if (isCopilotGeminiModelId(normalized)) {
return { ...COPILOT_CHAT_COMPLETIONS_COMPAT };
}
// Copilot's Claude 4.5 endpoints reject Anthropic's eager tool extension,
// while current Claude 4.6+ endpoints accept it.
if (isCopilotClaude45ModelId(normalized)) {
return { supportsEagerToolInputStreaming: false };
}
return undefined;
}
function compatSupportsEffort(
compat: CopilotReasoningCompat | null | undefined,
effort: "xhigh" | "max",
): boolean {
return (
Array.isArray(compat?.supportedReasoningEfforts) &&
compat.supportedReasoningEfforts.some(
(candidate) => normalizeOptionalLowercaseString(candidate) === effort,
)
);
}
export function resolveCopilotExtendedThinkingLevels(
modelId: string,
compat?: CopilotReasoningCompat | null,
): Array<"xhigh" | "max"> {
const normalizedModelId = normalizeOptionalLowercaseString(modelId) ?? "";
const staticCompat = resolveStaticCopilotModelOverride(normalizedModelId)?.compat;
const isClaudeModel = normalizedModelId.includes("claude");
const supportsAdaptiveClaudeEffort =
!isClaudeModel || supportsClaudeAdaptiveThinking({ id: normalizedModelId });
const levels: Array<"xhigh" | "max"> = [];
if (
supportsAdaptiveClaudeEffort &&
(COPILOT_XHIGH_MODEL_IDS.has(normalizedModelId) ||
compatSupportsEffort(compat, "xhigh") ||
compatSupportsEffort(staticCompat, "xhigh"))
) {
levels.push("xhigh");
}
if (
isClaudeModel &&
supportsAdaptiveClaudeEffort &&
(compatSupportsEffort(compat, "max") || compatSupportsEffort(staticCompat, "max"))
) {
levels.push("max");
}
return levels;
}
export function resolveStaticCopilotModelOverride(
modelId: string,
): Partial<ModelDefinitionConfig> | undefined {
return STATIC_MODEL_OVERRIDES.get(normalizeOptionalLowercaseString(modelId) ?? "");
}

View File

@@ -0,0 +1,724 @@
// Github Copilot tests cover models plugin behavior.
import { createProviderUsageFetch, makeResponse } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js";
import { fetchCopilotUsage } from "./usage.js";
vi.mock("openclaw/plugin-sdk/provider-model-shared", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/provider-model-shared")>()),
normalizeModelCompat: (model: Record<string, unknown>) => model,
resolveProviderEndpoint: (baseUrl: string) => ({
baseUrl,
endpointClass: "custom",
warnings: [],
}),
}));
const jsonStoreMocks = vi.hoisted(() => ({
loadJsonFile: vi.fn(),
saveJsonFile: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/json-store", () => ({
loadJsonFile: jsonStoreMocks.loadJsonFile,
saveJsonFile: jsonStoreMocks.saveJsonFile,
}));
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
resolveStateDir: () => "/tmp/openclaw-state",
}));
import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core";
import { fetchCopilotModelCatalog, resolveCopilotForwardCompatModel } from "./models.js";
function createMockCtx(
modelId: string,
registryModels: Record<string, Record<string, unknown>> = {},
): ProviderResolveDynamicModelContext {
return {
modelId,
provider: "github-copilot",
config: {},
modelRegistry: {
find: (provider: string, id: string) => registryModels[`${provider}/${id}`] ?? null,
},
} as unknown as ProviderResolveDynamicModelContext;
}
function requireResolvedModel(ctx: ProviderResolveDynamicModelContext) {
const result = resolveCopilotForwardCompatModel(ctx);
if (!result) {
throw new Error(`expected model ${ctx.modelId} to resolve`);
}
return result;
}
describe("resolveCopilotForwardCompatModel", () => {
it("returns undefined for empty modelId", () => {
expect(resolveCopilotForwardCompatModel(createMockCtx(""))).toBeUndefined();
expect(resolveCopilotForwardCompatModel(createMockCtx(" "))).toBeUndefined();
});
it("returns undefined when model is already in registry", () => {
const ctx = createMockCtx("gpt-4o", {
"github-copilot/gpt-4o": { id: "gpt-4o", name: "gpt-4o" },
});
expect(resolveCopilotForwardCompatModel(ctx)).toBeUndefined();
});
it("clones gpt-5.3-codex template for gpt-5.4", () => {
const template = {
id: "gpt-5.3-codex",
name: "gpt-5.3-codex",
provider: "github-copilot",
api: "openai-responses",
reasoning: true,
contextWindow: 200_000,
};
const ctx = createMockCtx("gpt-5.4", {
"github-copilot/gpt-5.3-codex": template,
});
const result = requireResolvedModel(ctx);
expect(result.id).toBe("gpt-5.4");
expect(result.name).toBe("gpt-5.4");
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
});
it("uses static metadata for gpt-5.3-codex when not in registry", () => {
const ctx = createMockCtx("gpt-5.3-codex");
const result = requireResolvedModel(ctx);
expect(result.id).toBe("gpt-5.3-codex");
expect(result.name).toBe("gpt-5.3-codex");
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
});
it("uses gpt-5.3-codex as the template source for gpt-5.4", () => {
const template53 = {
id: "gpt-5.3-codex",
name: "gpt-5.3-codex",
provider: "github-copilot",
api: "openai-responses",
reasoning: true,
contextWindow: 300_000,
};
const ctx = createMockCtx("gpt-5.4", {
"github-copilot/gpt-5.3-codex": template53,
});
const result = requireResolvedModel(ctx);
expect(result.id).toBe("gpt-5.4");
expect((result as unknown as Record<string, unknown>).contextWindow).toBe(300_000);
});
it("falls through to synthetic catch-all when codex template is missing", () => {
const ctx = createMockCtx("gpt-5.4");
const result = requireResolvedModel(ctx);
expect(result.id).toBe("gpt-5.4");
});
it("uses static metadata for gpt-5.5 when live discovery rows are unavailable", () => {
const result = requireResolvedModel(createMockCtx("gpt-5.5"));
expect(result).toEqual({
id: "gpt-5.5",
name: "GPT-5.5",
provider: "github-copilot",
api: "openai-responses",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400_000,
maxTokens: 128_000,
});
});
it("preserves static Anthropic thinking maps for Claude Opus 1M fallback rows", () => {
const opus46 = requireResolvedModel(createMockCtx("claude-opus-4.6-1m"));
expect(opus46.thinkingLevelMap).toEqual({ xhigh: null, max: null });
const result = requireResolvedModel(createMockCtx("claude-opus-4.7-1m-internal"));
expect(result.thinkingLevelMap).toEqual({ xhigh: "xhigh", max: null });
expect(result.compat).toEqual({
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
});
});
it("creates synthetic model for arbitrary unknown model ID", () => {
const ctx = createMockCtx("gpt-5.4-mini");
const result = requireResolvedModel(ctx);
expect(result.id).toBe("gpt-5.4-mini");
expect(result.name).toBe("gpt-5.4-mini");
expect((result as unknown as Record<string, unknown>).api).toBe("openai-responses");
expect((result as unknown as Record<string, unknown>).input).toEqual(["text", "image"]);
});
it("disables eager tool streaming for synthetic Copilot Claude 4.5 models", () => {
const result = requireResolvedModel(createMockCtx("claude-haiku-4.5"));
expect(result.api).toBe("anthropic-messages");
expect(result.compat).toEqual({ supportsEagerToolInputStreaming: false });
});
it("creates synthetic Gemini models with Chat Completions compatibility", () => {
const result = requireResolvedModel(createMockCtx("gemini-3.1-pro-preview"));
expect((result as unknown as Record<string, unknown>).api).toBe("openai-completions");
expect((result as unknown as Record<string, unknown>).compat).toEqual({
supportsStore: false,
supportsDeveloperRole: false,
supportsUsageInStreaming: false,
maxTokensField: "max_tokens",
});
});
it("infers reasoning=true for o1/o3 model IDs", () => {
for (const id of ["o1", "o3", "o3-mini", "o1-preview"]) {
const ctx = createMockCtx(id);
const result = requireResolvedModel(ctx);
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
}
});
it("infers reasoning=true for Codex model IDs", () => {
for (const id of ["gpt-5.4-codex", "gpt-5.5-codex", "gpt-5.4-codex-mini", "gpt-5.3-codex"]) {
const ctx = createMockCtx(id);
const result = requireResolvedModel(ctx);
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
}
});
it("sets reasoning=false for non-reasoning model IDs including mid-string o1/o3", () => {
for (const id of [
"gpt-5.4-mini",
"claude-sonnet-4.6",
"gpt-4o",
"mycodexmodel",
"audio-o1-hd",
"turbo-o3-voice",
]) {
const ctx = createMockCtx(id);
const result = requireResolvedModel(ctx);
expect((result as unknown as Record<string, unknown>).reasoning).toBe(false);
}
});
});
describe("fetchCopilotUsage", () => {
it("returns HTTP errors for failed requests", async () => {
const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom"));
const result = await fetchCopilotUsage("token", 5000, mockFetch);
expect(result.error).toBe("HTTP 500");
expect(result.windows).toHaveLength(0);
});
it("parses premium/chat usage from remaining percentages", async () => {
const mockFetch = createProviderUsageFetch(async (_url, init) => {
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
expect(headers.Authorization).toBe("token token");
expect(headers["X-Github-Api-Version"]).toBe("2025-04-01");
return makeResponse(200, {
quota_snapshots: {
premium_interactions: { percent_remaining: 20 },
chat: { percent_remaining: 75 },
},
copilot_plan: "pro",
});
});
const result = await fetchCopilotUsage("token", 5000, mockFetch);
expect(result.plan).toBe("pro");
expect(result.windows).toEqual([
{ label: "Premium", usedPercent: 80 },
{ label: "Chat", usedPercent: 25 },
]);
});
it("defaults missing snapshot values and clamps invalid remaining percentages", async () => {
const mockFetch = createProviderUsageFetch(async () =>
makeResponse(200, {
quota_snapshots: {
premium_interactions: { percent_remaining: null },
chat: { percent_remaining: 140 },
},
}),
);
const result = await fetchCopilotUsage("token", 5000, mockFetch);
expect(result.windows).toEqual([
{ label: "Premium", usedPercent: 100 },
{ label: "Chat", usedPercent: 0 },
]);
expect(result.plan).toBeUndefined();
});
it("returns an empty window list when quota snapshots are missing", async () => {
const mockFetch = createProviderUsageFetch(async () =>
makeResponse(200, {
copilot_plan: "free",
}),
);
const result = await fetchCopilotUsage("token", 5000, mockFetch);
expect(result).toEqual({
provider: "github-copilot",
displayName: "Copilot",
windows: [],
plan: "free",
});
});
it("bounds the usage read and cancels the stream when the body exceeds the JSON byte cap", async () => {
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels the
// stream mid-flight; if the cap were removed the unbounded res.json() would buffer the whole body.
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const makeOversizedJsonResponse = (): Response => {
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulled >= TOTAL_CHUNKS) {
controller.close();
return;
}
pulled += 1;
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const mockFetch = createProviderUsageFetch(async () => makeOversizedJsonResponse());
await expect(fetchCopilotUsage("token", 5000, mockFetch)).rejects.toThrow(
/github-copilot-usage: JSON response exceeds/,
);
// The bounded reader cancels the body and never pulls the full advertised 32 MiB stream.
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
});
describe("github-copilot token", () => {
const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json";
beforeEach(() => {
jsonStoreMocks.loadJsonFile.mockClear();
jsonStoreMocks.saveJsonFile.mockClear();
});
it("derives baseUrl from token", () => {
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe(
"https://api.example.com",
);
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBe(
"https://api.foo.bar",
);
});
it("uses cache when token is still valid", async () => {
const now = Date.now();
jsonStoreMocks.loadJsonFile.mockReturnValue({
token: "cached;proxy-ep=proxy.example.com;",
expiresAt: now + 60 * 60 * 1000,
updatedAt: now,
integrationId: "vscode-chat",
});
const fetchImpl = vi.fn();
const res = await resolveCopilotApiToken({
githubToken: "gh",
cachePath,
loadJsonFileImpl: jsonStoreMocks.loadJsonFile,
saveJsonFileImpl: jsonStoreMocks.saveJsonFile,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(res.token).toBe("cached;proxy-ep=proxy.example.com;");
expect(res.baseUrl).toBe("https://api.example.com");
expect(res.source).toContain("cache:");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("fetches and stores token when cache is missing", async () => {
jsonStoreMocks.loadJsonFile.mockReturnValue(undefined);
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
token: "fresh;proxy-ep=https://proxy.contoso.test;",
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
});
const res = await resolveCopilotApiToken({
githubToken: "gh",
cachePath,
loadJsonFileImpl: jsonStoreMocks.loadJsonFile,
saveJsonFileImpl: jsonStoreMocks.saveJsonFile,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;");
expect(res.baseUrl).toBe("https://api.contoso.test");
const [, calledInit] = fetchImpl.mock.calls[0] ?? [];
expect(((calledInit as RequestInit).headers as Record<string, string>)["Accept-Encoding"]).toBe(
"identity",
);
expect(jsonStoreMocks.saveJsonFile).toHaveBeenCalledTimes(1);
});
});
describe("fetchCopilotModelCatalog", () => {
// Trimmed sample of the real Copilot /models response shape captured against
// api.githubcopilot.com against an Individual Copilot subscription. Includes
// a chat model, a router (must be filtered), an embedding (must be filtered),
// an internal 1M-context Claude variant (must be kept), and a vision-disabled
// codex model.
const sampleApiResponse = {
data: [
{
id: "gpt-5.5",
name: "GPT-5.5",
object: "model",
vendor: "OpenAI",
capabilities: {
type: "chat",
family: "gpt-5.5",
limits: {
max_context_window_tokens: 400000,
max_output_tokens: 128000,
max_prompt_tokens: 272000,
},
supports: {
vision: true,
tool_calls: true,
streaming: true,
structured_outputs: true,
reasoning_effort: ["low", "medium", "high"],
},
},
},
{
id: "gpt-5.3-codex",
name: "GPT-5.3-Codex",
object: "model",
vendor: "OpenAI",
capabilities: {
type: "chat",
family: "gpt-5.3-codex",
limits: {
max_context_window_tokens: 400000,
max_output_tokens: 128000,
},
supports: {
vision: false,
tool_calls: true,
reasoning_effort: ["low", "medium", "high"],
},
},
},
{
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
object: "model",
vendor: "Google",
capabilities: {
type: "chat",
limits: {
max_context_window_tokens: 1_000_000,
max_output_tokens: 65_536,
},
supports: {
vision: true,
tool_calls: true,
streaming: true,
},
},
},
{
id: "claude-opus-4.7-1m-internal",
name: "Claude Opus 4.7 (1M context)(Internal only)",
object: "model",
vendor: "Anthropic",
capabilities: {
type: "chat",
limits: {
max_context_window_tokens: 1000000,
max_output_tokens: 64000,
},
supports: {
vision: true,
tool_calls: true,
reasoning_effort: ["low", "medium", "high", "xhigh"],
},
},
},
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
object: "model",
vendor: "Anthropic",
capabilities: {
type: "chat",
limits: {
max_context_window_tokens: 200000,
max_output_tokens: 64000,
},
supports: {
vision: true,
tool_calls: true,
reasoning_effort: ["low", "medium", "high", "max"],
},
},
},
{
// Internal router — must be filtered out (id starts with "accounts/").
id: "accounts/msft/routers/abc123",
name: "Search Agent A",
object: "model",
capabilities: {
type: "chat",
limits: { max_context_window_tokens: 256000, max_output_tokens: 1024 },
},
},
{
// Embedding — must be filtered out by capabilities.type !== "chat".
id: "text-embedding-3-small",
name: "Embedding V3 small",
object: "model",
capabilities: { type: "embedding" },
},
],
};
it("maps Copilot /models entries to ModelDefinitionConfig with real context windows", async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, sampleApiResponse));
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
baseUrl: "https://api.githubcopilot.com",
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [calledUrl, calledInit] = fetchImpl.mock.calls[0] ?? [];
expect(calledUrl).toBe("https://api.githubcopilot.com/models");
expect((calledInit as RequestInit).method).toBe("GET");
expect(((calledInit as RequestInit).headers as Record<string, string>).Authorization).toBe(
"Bearer tid=test",
);
expect(((calledInit as RequestInit).headers as Record<string, string>)["Accept-Encoding"]).toBe(
"identity",
);
expect(out.map((m) => m.id)).toEqual([
"gpt-5.5",
"gpt-5.3-codex",
"gemini-3.1-pro-preview",
"claude-opus-4.7-1m-internal",
"claude-opus-4-5",
]);
const gpt55 = out.find((m) => m.id === "gpt-5.5");
expect(gpt55).toEqual({
id: "gpt-5.5",
name: "GPT-5.5",
api: "openai-responses",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
compat: { supportedReasoningEfforts: ["low", "medium", "high"] },
});
const codex = out.find((m) => m.id === "gpt-5.3-codex");
expect(codex?.input).toEqual(["text"]);
expect(codex?.reasoning).toBe(true);
expect(codex?.contextWindow).toBe(400000);
const gemini = out.find((m) => m.id === "gemini-3.1-pro-preview");
expect(gemini?.api).toBe("openai-completions");
expect(gemini?.compat).toEqual({
supportsStore: false,
supportsDeveloperRole: false,
supportsUsageInStreaming: false,
maxTokensField: "max_tokens",
});
const opus1m = out.find((m) => m.id === "claude-opus-4.7-1m-internal");
expect(opus1m?.api).toBe("anthropic-messages");
expect(opus1m?.contextWindow).toBe(1_000_000);
expect(opus1m?.thinkingLevelMap).toEqual({ xhigh: "xhigh", max: null });
expect(opus1m?.compat).toEqual({
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
});
const opus45 = out.find((m) => m.id === "claude-opus-4-5");
expect(opus45?.thinkingLevelMap).toEqual({ xhigh: null, max: null });
expect(opus45?.compat).toEqual({
supportsEagerToolInputStreaming: false,
supportedReasoningEfforts: ["low", "medium", "high", "max"],
});
});
it("strips trailing slash from baseUrl when building the /models URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, { data: [] }));
await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
baseUrl: "https://api.githubcopilot.com/",
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(fetchImpl.mock.calls[0]?.[0]).toBe("https://api.githubcopilot.com/models");
});
it("dedupes by id when API returns duplicates", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
makeResponse(200, {
data: [
{
id: "gpt-5.5",
name: "GPT-5.5",
object: "model",
capabilities: {
type: "chat",
limits: { max_context_window_tokens: 400000, max_output_tokens: 128000 },
},
},
{
id: "gpt-5.5",
name: "GPT-5.5 (dup)",
object: "model",
capabilities: {
type: "chat",
limits: { max_context_window_tokens: 100000, max_output_tokens: 1000 },
},
},
],
}),
);
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
baseUrl: "https://api.githubcopilot.com",
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(out).toHaveLength(1);
expect(out[0].name).toBe("GPT-5.5");
});
it("falls back from malformed live token limits", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
makeResponse(200, {
data: [
{
id: "gpt-bad-window",
name: "GPT Bad Window",
object: "model",
capabilities: {
type: "chat",
limits: {
max_context_window_tokens: -1,
max_output_tokens: 128000.5,
},
},
},
{
id: "gpt-bad-output",
name: "GPT Bad Output",
object: "model",
capabilities: {
type: "chat",
limits: {
max_context_window_tokens: Number.POSITIVE_INFINITY,
max_output_tokens: 0,
},
},
},
],
}),
);
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
baseUrl: "https://api.githubcopilot.com",
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
id: "gpt-bad-window",
contextWindow: 128000,
maxTokens: 8192,
});
expect(out[1]).toMatchObject({
id: "gpt-bad-output",
contextWindow: 128000,
maxTokens: 8192,
});
});
it("throws on non-2xx HTTP responses so the caller can fall back to the static catalog", async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(401, {}));
await expect(
fetchCopilotModelCatalog({
copilotApiToken: "tid=bad",
baseUrl: "https://api.githubcopilot.com",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).rejects.toThrow(/HTTP 401/);
});
it("throws provider-owned errors for malformed successful /models payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, payload));
await expect(
fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
baseUrl: "https://api.githubcopilot.com",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).rejects.toThrow("Copilot /models: malformed JSON response");
}
});
it("rejects empty token / baseUrl synchronously before fetching", async () => {
const fetchImpl = vi.fn();
await expect(
fetchCopilotModelCatalog({
copilotApiToken: "",
baseUrl: "https://api.githubcopilot.com",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).rejects.toThrow(/copilotApiToken required/);
await expect(
fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
baseUrl: "",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).rejects.toThrow(/baseUrl required/);
expect(fetchImpl).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,320 @@
// Github Copilot plugin module implements models behavior.
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
normalizeModelCompat,
supportsClaudeAdaptiveThinking,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
asPositiveSafeInteger,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveCopilotModelCompat,
resolveCopilotTransportApi,
resolveStaticCopilotModelOverride,
} from "./model-metadata.js";
export const PROVIDER_ID = "github-copilot";
const CODEX_FORWARD_COMPAT_TARGET_IDS = new Set(["gpt-5.4", "gpt-5.3-codex"]);
// gpt-5.3-codex is only a useful template when gpt-5.4 is the target; it is
// always a registry miss (and therefore skipped) when it is the target itself.
const CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex"] as const;
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 8192;
function isCopilotCodexModelId(modelId: string): boolean {
return /(?:^|[-_.])codex(?:$|[-_.])/.test(modelId);
}
export function resolveCopilotForwardCompatModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel | undefined {
const trimmedModelId = ctx.modelId.trim();
if (!trimmedModelId) {
return undefined;
}
// If the model is already in the registry, let the normal path handle it.
const lowerModelId = normalizeOptionalLowercaseString(trimmedModelId) ?? "";
const existing = ctx.modelRegistry.find(PROVIDER_ID, lowerModelId);
if (existing) {
return undefined;
}
// For gpt-5.4 and gpt-5.3-codex, clone from a registered codex template
// to inherit the correct reasoning and capability flags.
if (CODEX_FORWARD_COMPAT_TARGET_IDS.has(lowerModelId)) {
for (const templateId of CODEX_TEMPLATE_MODEL_IDS) {
const template = ctx.modelRegistry.find(
PROVIDER_ID,
templateId,
) as ProviderRuntimeModel | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
} as ProviderRuntimeModel);
}
// Template not found — fall through to synthetic catch-all below.
}
const staticOverride = resolveStaticCopilotModelOverride(lowerModelId);
if (staticOverride) {
const compat = staticOverride.compat ?? resolveCopilotModelCompat(trimmedModelId);
return normalizeModelCompat({
id: trimmedModelId,
name: staticOverride.name ?? trimmedModelId,
provider: PROVIDER_ID,
api: staticOverride.api ?? resolveCopilotTransportApi(trimmedModelId),
reasoning: staticOverride.reasoning ?? false,
input: staticOverride.input ?? ["text", "image"],
cost: staticOverride.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: staticOverride.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
maxTokens: staticOverride.maxTokens ?? DEFAULT_MAX_TOKENS,
...(staticOverride.thinkingLevelMap
? { thinkingLevelMap: staticOverride.thinkingLevelMap }
: {}),
...(compat ? { compat } : {}),
} as ProviderRuntimeModel);
}
// Catch-all: create a synthetic model definition for any unknown model ID.
// The Copilot API is OpenAI-compatible and will return its own error if the
// model isn't available on the user's plan. This lets new models be used
// by simply adding them to agents.defaults.models in openclaw.json — no
// code change required.
const reasoning = /^o[13](\b|$)/.test(lowerModelId) || isCopilotCodexModelId(lowerModelId);
const compat = resolveCopilotModelCompat(trimmedModelId);
return normalizeModelCompat({
id: trimmedModelId,
name: trimmedModelId,
provider: PROVIDER_ID,
api: resolveCopilotTransportApi(trimmedModelId),
reasoning,
// Optimistic: most Copilot models support images, and the API rejects
// image payloads for text-only models rather than failing silently.
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
...(compat ? { compat } : {}),
} as ProviderRuntimeModel);
}
// Subset of the Copilot /models response shape that we depend on. We only read
// fields we need; everything else is preserved as `unknown` so upstream changes
// don't break parsing.
type CopilotApiModelEntry = {
id?: string;
name?: string;
object?: string;
vendor?: string;
preview?: boolean;
model_picker_enabled?: boolean;
capabilities?: {
type?: string;
family?: string;
limits?: {
max_context_window_tokens?: number;
max_output_tokens?: number;
max_prompt_tokens?: number;
};
supports?: {
vision?: boolean;
tool_calls?: boolean;
streaming?: boolean;
structured_outputs?: boolean;
reasoning_effort?: string[] | null;
};
};
};
const COPILOT_MODELS_LIST_DEFAULT_TIMEOUT_MS = 10_000;
const COPILOT_ROUTER_ID_PREFIX = "accounts/";
function resolveCopilotApiForVendor(
vendor: string | undefined,
modelId: string,
): "anthropic-messages" | "openai-completions" | "openai-responses" {
if (vendor && vendor.toLowerCase() === "anthropic") {
return "anthropic-messages";
}
return resolveCopilotTransportApi(modelId);
}
function mergeCopilotCompat(
base: ModelDefinitionConfig["compat"] | undefined,
reasoningEfforts: string[] | null | undefined,
): ModelDefinitionConfig["compat"] | undefined {
const supportedReasoningEfforts = Array.isArray(reasoningEfforts)
? [
...new Set(
reasoningEfforts
.map((effort) => normalizeOptionalLowercaseString(effort))
.filter((effort): effort is string => Boolean(effort)),
),
]
: [];
if (supportedReasoningEfforts.length === 0) {
return base;
}
return {
...base,
supportedReasoningEfforts,
};
}
function resolveCopilotThinkingLevelMap(
api: ModelDefinitionConfig["api"],
modelId: string,
compat: ModelDefinitionConfig["compat"] | undefined,
): ModelDefinitionConfig["thinkingLevelMap"] | undefined {
const efforts = compat?.supportedReasoningEfforts;
if (api !== "anthropic-messages" || !Array.isArray(efforts)) {
return undefined;
}
const supportsAdaptiveEffort = supportsClaudeAdaptiveThinking({ id: modelId });
return {
xhigh: supportsAdaptiveEffort && efforts.includes("xhigh") ? "xhigh" : null,
max: supportsAdaptiveEffort && efforts.includes("max") ? "max" : null,
};
}
function mapCopilotApiModelToDefinition(
entry: CopilotApiModelEntry,
): ModelDefinitionConfig | undefined {
const id = entry.id?.trim();
if (!id) {
return undefined;
}
// Skip non-chat objects (embeddings, routers, etc.) and internal router ids.
if (entry.object && entry.object !== "model") {
return undefined;
}
if (entry.capabilities?.type && entry.capabilities.type !== "chat") {
return undefined;
}
if (id.startsWith(COPILOT_ROUTER_ID_PREFIX)) {
return undefined;
}
const limits = entry.capabilities?.limits;
const supports = entry.capabilities?.supports;
const reasoning = Array.isArray(supports?.reasoning_effort)
? supports.reasoning_effort.length > 0
: false;
const supportsVision = supports?.vision === true;
const input: ModelDefinitionConfig["input"] = supportsVision ? ["text", "image"] : ["text"];
const contextWindow =
asPositiveSafeInteger(limits?.max_context_window_tokens) ?? DEFAULT_CONTEXT_WINDOW;
const maxTokens = asPositiveSafeInteger(limits?.max_output_tokens) ?? DEFAULT_MAX_TOKENS;
const compat = mergeCopilotCompat(resolveCopilotModelCompat(id), supports?.reasoning_effort);
const api = resolveCopilotApiForVendor(entry.vendor, id);
const thinkingLevelMap = resolveCopilotThinkingLevelMap(api, id, compat);
const definition: ModelDefinitionConfig = {
id,
name: entry.name?.trim() || id,
api,
reasoning,
input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
maxTokens,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
...(compat ? { compat } : {}),
};
return definition;
}
function asCopilotApiModelEntry(value: unknown): CopilotApiModelEntry {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Copilot /models: malformed JSON response");
}
return value as CopilotApiModelEntry;
}
export type FetchCopilotModelCatalogParams = {
/** Short-lived Copilot API token (from `resolveCopilotApiToken`). */
copilotApiToken: string;
/** Resolved baseUrl from the same token-exchange response. */
baseUrl: string;
/** Optional fetch override for testing. */
fetchImpl?: typeof fetch;
/** Optional AbortSignal; defaults to a 10s timeout. */
signal?: AbortSignal;
};
/**
* Fetch the live Copilot model catalog from `${baseUrl}/models` and project it
* into `ModelDefinitionConfig[]`. Used by the plugin's discovery hook so the
* runtime catalog tracks per-account entitlements + accurate context windows
* without manifest churn.
*
* Filters out non-chat objects (embeddings, routers) and internal router ids.
* On any HTTP/parse failure the caller should fall back to the static manifest
* catalog; this function throws so the caller decides the recovery shape.
*/
export async function fetchCopilotModelCatalog(
params: FetchCopilotModelCatalogParams,
): Promise<ModelDefinitionConfig[]> {
const fetchImpl = params.fetchImpl ?? fetch;
const trimmedBase = params.baseUrl.replace(/\/+$/, "");
if (!trimmedBase) {
throw new Error("fetchCopilotModelCatalog: baseUrl required");
}
if (!params.copilotApiToken.trim()) {
throw new Error("fetchCopilotModelCatalog: copilotApiToken required");
}
const url = `${trimmedBase}/models`;
const controller = params.signal ? undefined : new AbortController();
const timeoutId = controller
? setTimeout(() => controller.abort(), COPILOT_MODELS_LIST_DEFAULT_TIMEOUT_MS)
: undefined;
try {
const res = await fetchImpl(url, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.copilotApiToken}`,
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
},
signal: params.signal ?? controller?.signal,
});
if (!res.ok) {
throw new Error(`Copilot /models fetch failed: HTTP ${res.status}`);
}
const data = await readProviderJsonArrayFieldResponse(res, "Copilot /models", "data");
const seen = new Set<string>();
const out: ModelDefinitionConfig[] = [];
for (const rawEntry of data) {
const entry = asCopilotApiModelEntry(rawEntry);
const def = mapCopilotApiModelToDefinition(entry);
if (!def) {
continue;
}
if (seen.has(def.id)) {
continue;
}
seen.add(def.id);
out.push(def);
}
return out;
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}

View File

@@ -0,0 +1,205 @@
{
"id": "github-copilot",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"providers": ["github-copilot"],
"providerEndpoints": [
{
"endpointClass": "github-copilot-native",
"hostSuffixes": [".githubcopilot.com"]
}
],
"providerRequest": {
"providers": {
"github-copilot": {
"family": "github-copilot"
}
}
},
"modelCatalog": {
"providers": {
"github-copilot": {
"baseUrl": "https://api.individual.githubcopilot.com",
"api": "openai-responses",
"models": [
{
"id": "claude-opus-4.6",
"name": "Claude Opus 4.6",
"api": "anthropic-messages",
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "claude-opus-4.7",
"name": "Claude Opus 4.7",
"api": "anthropic-messages",
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "claude-opus-4.8",
"name": "Claude Opus 4.8",
"api": "anthropic-messages",
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "claude-sonnet-4.6",
"name": "Claude Sonnet 4.6",
"api": "anthropic-messages",
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gemini-2.5-pro",
"name": "Gemini 2.5 Pro",
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gemini-3-flash",
"name": "Gemini 3 Flash",
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gemini-3.1-pro",
"name": "Gemini 3.1 Pro",
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-5.3-codex",
"name": "GPT-5.3-Codex",
"reasoning": true,
"input": ["text"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-5.4",
"name": "GPT-5.4",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-5.5",
"name": "GPT-5.5",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 400000,
"maxTokens": 128000,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-5.4-mini",
"name": "GPT-5.4 mini",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-5.4-nano",
"name": "GPT-5.4 nano",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "raptor-mini",
"name": "Raptor mini",
"input": ["text"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "goldeneye",
"name": "Goldeneye",
"input": ["text"],
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
}
]
}
},
"discovery": {
"github-copilot": "refreshable"
}
},
"contracts": {
"memoryEmbeddingProviders": ["github-copilot"]
},
"setup": {
"providers": [
{
"id": "github-copilot",
"envVars": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]
}
]
},
"providerAuthChoices": [
{
"provider": "github-copilot",
"method": "device",
"choiceId": "github-copilot",
"choiceLabel": "GitHub Copilot",
"choiceHint": "Device login with your GitHub account",
"groupId": "copilot",
"groupLabel": "Copilot",
"groupHint": "GitHub + local proxy",
"optionKey": "githubCopilotToken",
"cliFlag": "--github-copilot-token",
"cliOption": "--github-copilot-token <token>",
"cliDescription": "GitHub Copilot OAuth token"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"discovery": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" }
}
}
}
},
"uiHints": {
"discovery": {
"label": "Model Discovery",
"help": "Plugin-owned controls for GitHub Copilot model auto-discovery."
},
"discovery.enabled": {
"label": "Enable Discovery",
"help": "When false, OpenClaw keeps the GitHub Copilot plugin available but skips implicit startup discovery from ambient Copilot credentials."
}
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "@openclaw/github-copilot-provider",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw GitHub Copilot provider plugin",
"type": "module",
"dependencies": {
"@clack/prompts": "1.6.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,4 @@
// Github Copilot tests cover provider auth.contract plugin behavior.
import { describeGithubCopilotProviderAuthContract } from "openclaw/plugin-sdk/provider-test-contracts";
describeGithubCopilotProviderAuthContract(() => import("./index.js"));

View File

@@ -0,0 +1,8 @@
// Github Copilot tests cover provider discovery.contract plugin behavior.
import { fileURLToPath } from "node:url";
import { describeGithubCopilotProviderDiscoveryContract } from "openclaw/plugin-sdk/provider-test-contracts";
describeGithubCopilotProviderDiscoveryContract({
load: () => import("./index.js"),
registerRuntimeModuleId: fileURLToPath(new URL("./register.runtime.js", import.meta.url)),
});

View File

@@ -0,0 +1,93 @@
// Github Copilot tests cover provider policy api plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveThinkingProfile } from "./provider-policy-api.js";
describe("github-copilot provider-policy-api", () => {
it("returns the base level set for non-xhigh GitHub Copilot models", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-opus-4.6",
})?.levels.map((level) => level.id),
).toEqual(["off", "minimal", "low", "medium", "high"]);
});
it("appends xhigh for current static GPT Copilot xhigh ids", () => {
for (const modelId of ["gpt-5.4", "gpt-5.3-codex"]) {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId,
})?.levels.map((level) => level.id),
`model=${modelId}`,
).toContain("xhigh");
}
});
it("appends xhigh when catalog compat advertises it", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "future-copilot-model",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
})?.levels.map((level) => level.id),
).toContain("xhigh");
});
it("appends max when catalog compat advertises it", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-fable-5",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "max"] },
})?.levels.map((level) => level.id),
).toContain("max");
});
it("does not expose max for non-Anthropic Copilot transports", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "future-copilot-model",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "max"] },
})?.levels.map((level) => level.id),
).not.toContain("max");
});
it("does not expose adaptive effort for older Claude models", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-opus-4-5",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "max"] },
})?.levels.map((level) => level.id),
).not.toContain("max");
});
it("appends xhigh for static Copilot metadata overrides", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-opus-4.7-1m-internal",
})?.levels.map((level) => level.id),
).toContain("xhigh");
});
it("normalizes the model id casing before xhigh membership checks", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "GPT-5.4",
})?.levels.map((level) => level.id),
).toContain("xhigh");
});
it("returns null for non-GitHub Copilot providers", () => {
expect(
resolveThinkingProfile({
provider: "openai",
modelId: "gpt-5.4",
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,21 @@
// Github Copilot API module exposes the plugin public contract.
import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/core";
import { resolveCopilotExtendedThinkingLevels } from "./model-metadata.js";
export function resolveThinkingProfile(context: ProviderDefaultThinkingPolicyContext) {
if (context.provider.trim().toLowerCase() !== "github-copilot") {
return null;
}
const extendedLevels = resolveCopilotExtendedThinkingLevels(context.modelId, context.compat);
return {
levels: [
{ id: "off" as const },
{ id: "minimal" as const },
{ id: "low" as const },
{ id: "medium" as const },
{ id: "high" as const },
...extendedLevels.map((id) => ({ id })),
],
};
}

View File

@@ -0,0 +1,4 @@
// Github Copilot tests cover provider runtime.contract plugin behavior.
import { describeGithubCopilotProviderRuntimeContract } from "openclaw/plugin-sdk/provider-test-contracts";
describeGithubCopilotProviderRuntimeContract(() => import("./index.js"));

View File

@@ -0,0 +1,25 @@
// Github Copilot plugin module implements register behavior.
import {
coerceSecretRef,
ensureAuthProfileStore,
listProfilesForProvider,
} from "openclaw/plugin-sdk/provider-auth";
import { githubCopilotLoginCommand } from "./login.js";
import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js";
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
import { fetchCopilotUsage } from "./usage.js";
export {
coerceSecretRef,
DEFAULT_COPILOT_API_BASE_URL,
ensureAuthProfileStore,
fetchCopilotUsage,
githubCopilotLoginCommand,
listProfilesForProvider,
PROVIDER_ID,
resolveCopilotApiToken,
resolveCopilotForwardCompatModel,
wrapCopilotAnthropicStream,
wrapCopilotProviderStream,
};

View File

@@ -0,0 +1,55 @@
// Github Copilot plugin module implements replay policy behavior.
import type { ProviderSanitizeReplayHistoryContext } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
const OMITTED_COPILOT_REASONING_TEXT = "[assistant reasoning omitted]";
function isCopilotClaudeModel(modelId?: string | null): boolean {
return normalizeLowercaseStringOrEmpty(modelId).includes("claude");
}
function isThinkingBlock(value: unknown): boolean {
if (!value || typeof value !== "object") {
return false;
}
const type = (value as { type?: unknown }).type;
return type === "thinking" || type === "redacted_thinking";
}
export function stripCopilotAssistantThinkingMessages<T>(messages: T[]): T[] {
let touched = false;
const sanitized = messages.map((message) => {
if (!message || typeof message !== "object") {
return message;
}
const record = message as { role?: unknown; content?: unknown };
if (record.role !== "assistant" || !Array.isArray(record.content)) {
return message;
}
const content = record.content.filter((block) => !isThinkingBlock(block));
if (content.length === record.content.length) {
return message;
}
touched = true;
return {
...message,
content:
content.length > 0 ? content : [{ type: "text", text: OMITTED_COPILOT_REASONING_TEXT }],
};
});
return touched ? sanitized : messages;
}
export function buildGithubCopilotReplayPolicy(modelId?: string) {
return isCopilotClaudeModel(modelId)
? {
dropThinkingBlocks: true,
}
: {};
}
export function sanitizeGithubCopilotReplayHistory(ctx: ProviderSanitizeReplayHistoryContext) {
return isCopilotClaudeModel(ctx.modelId)
? stripCopilotAssistantThinkingMessages(ctx.messages)
: ctx.messages;
}

View File

@@ -0,0 +1,336 @@
// Github Copilot tests cover stream plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { buildCopilotDynamicHeaders } from "./stream.js";
import {
wrapCopilotAnthropicStream,
wrapCopilotOpenAICompletionsStream,
wrapCopilotOpenAIResponsesStream,
wrapCopilotProviderStream,
} from "./stream.js";
function requireStreamFn(streamFn: ReturnType<typeof wrapCopilotProviderStream>) {
expect(streamFn).toBeTypeOf("function");
if (!streamFn) {
throw new Error("expected stream fn");
}
return streamFn;
}
function requireFirstStreamOptions(mock: ReturnType<typeof vi.fn>, label: string) {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`expected ${label}`);
}
const options = call[2];
if (!options || typeof options !== "object") {
throw new Error(`expected ${label} options`);
}
return options as { headers?: Record<string, unknown>; onPayload?: unknown };
}
describe("wrapCopilotAnthropicStream", () => {
it("adds Copilot headers, strips thinking replay, and marks cache for Claude payloads", () => {
const payloads: Array<{
messages: Array<Record<string, unknown>>;
}> = [];
const baseStreamFn = vi.fn((model, _context, options) => {
const payload = {
messages: [
{ role: "system", content: "system prompt" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "draft", cache_control: { type: "ephemeral" } },
{ type: "redacted_thinking", data: "opaque" },
{ type: "text", text: "visible reply" },
],
},
],
};
options?.onPayload?.(payload, model);
payloads.push(payload);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn));
const messages = [
{
role: "user",
content: [
{ type: "text", text: "look" },
{ type: "image", image: "data:image/png;base64,abc" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const context = { messages };
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
expect(expectedCopilotHeaders["Accept-Encoding"]).toBe("identity");
void wrapped(
{
provider: "github-copilot",
api: "anthropic-messages",
id: "claude-sonnet-4.6",
} as never,
context as never,
{
headers: { "X-Test": "1" },
},
);
expect(baseStreamFn).toHaveBeenCalledOnce();
const options = requireFirstStreamOptions(baseStreamFn, "Copilot Anthropic stream");
if (!options?.onPayload) {
throw new Error("expected Copilot Anthropic stream options");
}
expect(options).toEqual({
headers: {
...expectedCopilotHeaders,
"X-Test": "1",
},
onPayload: options.onPayload,
});
expect(payloads[0]?.messages).toEqual([
{
role: "system",
content: [{ type: "text", text: "system prompt", cache_control: { type: "ephemeral" } }],
},
{
role: "assistant",
content: [{ type: "text", text: "visible reply" }],
},
]);
});
it("keeps a non-empty assistant turn when Copilot replay only contains thinking", () => {
const payloads: Array<{
messages: Array<Record<string, unknown>>;
}> = [];
const baseStreamFn = vi.fn((model, _context, options) => {
const payload = {
messages: [
{ role: "user", content: "use the tool result" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "private" },
{ type: "redacted_thinking", data: "opaque" },
],
},
{ role: "user", content: [{ type: "tool_result", content: "done" }] },
],
};
options?.onPayload?.(payload, model);
payloads.push(payload);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn));
void wrapped(
{
provider: "github-copilot",
api: "anthropic-messages",
id: "claude-haiku-4.5",
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{},
);
expect(payloads[0]?.messages).toEqual([
{ role: "user", content: "use the tool result" },
{ role: "assistant", content: [{ type: "text", text: "[assistant reasoning omitted]" }] },
{ role: "user", content: [{ type: "tool_result", content: "done" }] },
]);
});
it("leaves non-Anthropic Copilot models untouched", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
const wrapped = requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn));
const model = {
provider: "github-copilot",
api: "openai-responses",
id: "gpt-4.1",
} as never;
const context = { messages: [{ role: "user", content: "hi" }] } as never;
const options = { headers: { Existing: "1" } };
void wrapped(model, context, options as never);
expect(baseStreamFn.mock.calls).toEqual([[model, context, options]]);
});
it("adds Copilot headers, sanitizes reasoning replay, and rewrites message IDs before payload send", () => {
const reasoningId = Buffer.from(`reasoning-${"x".repeat(24)}`).toString("base64");
const overlongReasoningId = `5PX6gLHXT5wE+Y2tPmUV4gn+${"B".repeat(384)}`;
const messageId = Buffer.from(`message-${"y".repeat(24)}`).toString("base64");
const payloads: Array<{ input: Array<Record<string, unknown>> }> = [];
const baseStreamFn = vi.fn((_model, _context, options) => {
const payload = {
input: [
{ id: reasoningId, type: "reasoning", encrypted_content: "valid-encrypted-payload" },
{ type: "reasoning", encrypted_content: "idless-encrypted-payload", summary: [] },
{
id: overlongReasoningId,
type: "reasoning",
encrypted_content: "invalid-encrypted-payload",
summary: [],
},
{ id: messageId, type: "message" },
],
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
const messages = [
{
role: "toolResult",
content: [
{ type: "text", text: "look" },
{ type: "image", image: "data:image/png;base64,abc" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
void wrapped(
{
provider: "github-copilot",
api: "openai-responses",
id: "gpt-5.4",
} as never,
{ messages } as never,
{ headers: { "X-Test": "1" } },
);
expect(baseStreamFn).toHaveBeenCalledOnce();
const options = requireFirstStreamOptions(baseStreamFn, "Copilot Responses stream");
if (!options?.onPayload) {
throw new Error("expected Copilot Responses stream options");
}
expect(options).toEqual({
headers: {
...expectedCopilotHeaders,
"X-Test": "1",
},
onPayload: options.onPayload,
});
expect(payloads[0]?.input[0]?.id).toBe(reasoningId);
expect(payloads[0]?.input.map((item) => item.type)).toEqual([
"reasoning",
"reasoning",
"message",
]);
expect(payloads[0]?.input[1]?.id).toBeUndefined();
expect(payloads[0]?.input[2]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
});
it("rewrites Copilot Responses IDs returned by an existing payload hook", async () => {
const connectionBoundId = Buffer.from(`message-${"y".repeat(24)}`).toString("base64");
let returnedPayload: unknown;
const baseStreamFn = vi.fn(async (_model, _context, options) => {
returnedPayload = await options?.onPayload?.({ input: [] }, _model);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
await wrapped(
{
provider: "github-copilot",
api: "openai-responses",
id: "gpt-5.4",
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{
onPayload: () => ({ input: [{ id: connectionBoundId, type: "message" }] }),
} as never,
);
expect((returnedPayload as { input: Array<Record<string, unknown>> }).input[0]?.id).toMatch(
/^msg_[a-f0-9]{16}$/,
);
});
it("adds Copilot headers for Chat Completions models", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
const wrapped = requireStreamFn(wrapCopilotOpenAICompletionsStream(baseStreamFn));
const messages = [
{
role: "user",
content: [
{ type: "text", text: "look" },
{ type: "image", data: "abc", mimeType: "image/png" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
void wrapped(
{
provider: "github-copilot",
api: "openai-completions",
id: "gemini-3.1-pro-preview",
} as never,
{ messages } as never,
{ headers: { "X-Test": "1" } },
);
const options = requireFirstStreamOptions(baseStreamFn, "Copilot Chat Completions stream");
expect(options).toEqual({
headers: {
...expectedCopilotHeaders,
"X-Test": "1",
},
});
});
it("adapts provider stream context without changing wrapper behavior", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
const wrapped = requireStreamFn(
wrapCopilotProviderStream({
streamFn: baseStreamFn,
} as never),
);
void wrapped(
{
provider: "github-copilot",
api: "openai-responses",
id: "gpt-4.1",
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{},
);
expect(baseStreamFn).toHaveBeenCalledOnce();
});
it("does not claim provider transport before OpenClaw chooses one", () => {
expect(
wrapCopilotProviderStream({
streamFn: undefined,
} as never),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,166 @@
// Github Copilot plugin module implements stream behavior.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Context } from "openclaw/plugin-sdk/llm";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
import {
applyAnthropicEphemeralCacheControlMarkers,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { rewriteCopilotResponsePayloadConnectionBoundIds } from "./connection-bound-ids.js";
import { stripCopilotAssistantThinkingMessages } from "./replay-policy.js";
type StreamOptions = Parameters<StreamFn>[2];
function containsCopilotContentType(value: unknown, type: string): boolean {
if (Array.isArray(value)) {
return value.some((item) => containsCopilotContentType(item, type));
}
if (!value || typeof value !== "object") {
return false;
}
const entry = value as { type?: unknown; content?: unknown };
return entry.type === type || containsCopilotContentType(entry.content, type);
}
function inferCopilotInitiator(messages: Context["messages"]): "agent" | "user" {
const last = messages[messages.length - 1];
if (!last) {
return "user";
}
if (last.role === "user" && containsCopilotContentType(last.content, "tool_result")) {
return "agent";
}
return last.role === "user" ? "user" : "agent";
}
export function hasCopilotVisionInput(messages: Context["messages"]): boolean {
return messages.some((message) => {
if (message.role === "user" && Array.isArray(message.content)) {
return message.content.some((item) => containsCopilotContentType(item, "image"));
}
if (message.role === "toolResult" && Array.isArray(message.content)) {
return message.content.some((item) => containsCopilotContentType(item, "image"));
}
return false;
});
}
export function buildCopilotDynamicHeaders(params: {
messages: Context["messages"];
hasImages: boolean;
}): Record<string, string> {
return {
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
"x-initiator": inferCopilotInitiator(params.messages),
...(params.hasImages ? { "Copilot-Vision-Request": "true" } : {}),
};
}
function patchOnPayloadResult(result: unknown): unknown {
if (result && typeof result === "object" && "then" in result) {
return Promise.resolve(result).then((next) => {
rewriteCopilotResponsePayloadConnectionBoundIds(next);
return next;
});
}
rewriteCopilotResponsePayloadConnectionBoundIds(result);
return result;
}
function buildCopilotRequestHeaders(
context: Parameters<StreamFn>[1],
headers: Record<string, string> | undefined,
): Record<string, string> {
return {
...buildCopilotDynamicHeaders({
messages: context.messages,
hasImages: hasCopilotVisionInput(context.messages),
}),
...headers,
};
}
function patchCopilotAnthropicPayload(payload: Record<string, unknown>): void {
if (Array.isArray(payload.messages)) {
payload.messages = stripCopilotAssistantThinkingMessages(payload.messages);
}
applyAnthropicEphemeralCacheControlMarkers(payload);
}
export function wrapCopilotAnthropicStream(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
if (!baseStreamFn) {
return undefined;
}
const underlying = baseStreamFn;
return (model, context, options) => {
if (model.provider !== "github-copilot" || model.api !== "anthropic-messages") {
return underlying(model, context, options);
}
return streamWithPayloadPatch(
underlying,
model,
context,
{
...options,
headers: buildCopilotRequestHeaders(context, options?.headers),
},
patchCopilotAnthropicPayload,
);
};
}
export function wrapCopilotOpenAIResponsesStream(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
if (!baseStreamFn) {
return undefined;
}
const underlying = baseStreamFn;
return (model, context, options) => {
if (model.provider !== "github-copilot" || model.api !== "openai-responses") {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
const wrappedOptions: StreamOptions = {
...options,
headers: buildCopilotRequestHeaders(context, options?.headers),
onPayload: (payload, payloadModel) => {
rewriteCopilotResponsePayloadConnectionBoundIds(payload);
return patchOnPayloadResult(originalOnPayload?.(payload, payloadModel));
},
};
return underlying(model, context, wrappedOptions);
};
}
export function wrapCopilotOpenAICompletionsStream(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
if (!baseStreamFn) {
return undefined;
}
const underlying = baseStreamFn;
return (model, context, options) => {
if (model.provider !== "github-copilot" || model.api !== "openai-completions") {
return underlying(model, context, options);
}
return underlying(model, context, {
...options,
headers: buildCopilotRequestHeaders(context, options?.headers),
});
};
}
export function wrapCopilotProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn | undefined {
return wrapCopilotOpenAICompletionsStream(
wrapCopilotOpenAIResponsesStream(wrapCopilotAnthropicStream(ctx.streamFn)),
);
}

View File

@@ -0,0 +1,7 @@
// Github Copilot plugin module implements token behavior.
export {
DEFAULT_COPILOT_API_BASE_URL,
deriveCopilotApiBaseUrlFromToken,
resolveCopilotApiToken,
type CachedCopilotToken,
} from "openclaw/plugin-sdk/provider-auth";

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -0,0 +1,73 @@
// Github Copilot plugin module implements usage behavior.
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
buildUsageHttpErrorSnapshot,
fetchJson,
clampPercent,
PROVIDER_LABELS,
type ProviderUsageSnapshot,
type UsageWindow,
} from "openclaw/plugin-sdk/provider-usage";
type CopilotUsageResponse = {
quota_snapshots?: {
premium_interactions?: { percent_remaining?: number | null };
chat?: { percent_remaining?: number | null };
};
copilot_plan?: string;
};
export async function fetchCopilotUsage(
token: string,
timeoutMs: number,
fetchFn: typeof fetch,
): Promise<ProviderUsageSnapshot> {
const res = await fetchJson(
"https://api.github.com/copilot_internal/user",
{
headers: {
Authorization: `token ${token}`,
...buildCopilotIdeHeaders({ includeApiVersion: true }),
},
},
timeoutMs,
fetchFn,
);
if (!res.ok) {
return buildUsageHttpErrorSnapshot({
provider: "github-copilot",
status: res.status,
});
}
const data = await readProviderJsonResponse<CopilotUsageResponse>(
res,
"github-copilot-usage",
);
const windows: UsageWindow[] = [];
if (data.quota_snapshots?.premium_interactions) {
const remaining = data.quota_snapshots.premium_interactions.percent_remaining;
windows.push({
label: "Premium",
usedPercent: clampPercent(100 - (remaining ?? 0)),
});
}
if (data.quota_snapshots?.chat) {
const remaining = data.quota_snapshots.chat.percent_remaining;
windows.push({
label: "Chat",
usedPercent: clampPercent(100 - (remaining ?? 0)),
});
}
return {
provider: "github-copilot",
displayName: PROVIDER_LABELS["github-copilot"],
windows,
plan: data.copilot_plan,
};
}