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
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:
2
extensions/github-copilot/api.ts
Normal file
2
extensions/github-copilot/api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Github Copilot API module exposes the plugin public contract.
|
||||
export { githubCopilotLoginCommand } from "./login.js";
|
||||
110
extensions/github-copilot/auth.test.ts
Normal file
110
extensions/github-copilot/auth.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
66
extensions/github-copilot/auth.ts
Normal file
66
extensions/github-copilot/auth.ts
Normal 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 };
|
||||
}
|
||||
234
extensions/github-copilot/connection-bound-ids.live.test.ts
Normal file
234
extensions/github-copilot/connection-bound-ids.live.test.ts
Normal 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);
|
||||
});
|
||||
98
extensions/github-copilot/connection-bound-ids.test.ts
Normal file
98
extensions/github-copilot/connection-bound-ids.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
extensions/github-copilot/connection-bound-ids.ts
Normal file
82
extensions/github-copilot/connection-bound-ids.ts
Normal 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);
|
||||
}
|
||||
367
extensions/github-copilot/embeddings.test.ts
Normal file
367
extensions/github-copilot/embeddings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
340
extensions/github-copilot/embeddings.ts
Normal file
340
extensions/github-copilot/embeddings.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
839
extensions/github-copilot/index.test.ts
Normal file
839
extensions/github-copilot/index.test.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
494
extensions/github-copilot/index.ts
Normal file
494
extensions/github-copilot/index.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
205
extensions/github-copilot/login.test.ts
Normal file
205
extensions/github-copilot/login.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
374
extensions/github-copilot/login.ts
Normal file
374
extensions/github-copilot/login.ts
Normal 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");
|
||||
}
|
||||
133
extensions/github-copilot/model-metadata.ts
Normal file
133
extensions/github-copilot/model-metadata.ts
Normal 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) ?? "");
|
||||
}
|
||||
724
extensions/github-copilot/models.test.ts
Normal file
724
extensions/github-copilot/models.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
320
extensions/github-copilot/models.ts
Normal file
320
extensions/github-copilot/models.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
205
extensions/github-copilot/openclaw.plugin.json
Normal file
205
extensions/github-copilot/openclaw.plugin.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
18
extensions/github-copilot/package.json
Normal file
18
extensions/github-copilot/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
4
extensions/github-copilot/provider-auth.contract.test.ts
Normal file
4
extensions/github-copilot/provider-auth.contract.test.ts
Normal 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"));
|
||||
@@ -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)),
|
||||
});
|
||||
93
extensions/github-copilot/provider-policy-api.test.ts
Normal file
93
extensions/github-copilot/provider-policy-api.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
21
extensions/github-copilot/provider-policy-api.ts
Normal file
21
extensions/github-copilot/provider-policy-api.ts
Normal 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 })),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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"));
|
||||
25
extensions/github-copilot/register.runtime.ts
Normal file
25
extensions/github-copilot/register.runtime.ts
Normal 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,
|
||||
};
|
||||
55
extensions/github-copilot/replay-policy.ts
Normal file
55
extensions/github-copilot/replay-policy.ts
Normal 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;
|
||||
}
|
||||
336
extensions/github-copilot/stream.test.ts
Normal file
336
extensions/github-copilot/stream.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
166
extensions/github-copilot/stream.ts
Normal file
166
extensions/github-copilot/stream.ts
Normal 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)),
|
||||
);
|
||||
}
|
||||
7
extensions/github-copilot/token.ts
Normal file
7
extensions/github-copilot/token.ts
Normal 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";
|
||||
16
extensions/github-copilot/tsconfig.json
Normal file
16
extensions/github-copilot/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
73
extensions/github-copilot/usage.ts
Normal file
73
extensions/github-copilot/usage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user