mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 19:10:45 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
1
openclaw/extensions/github-copilot/api.ts
Normal file
1
openclaw/extensions/github-copilot/api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { githubCopilotLoginCommand } from "./login.js";
|
||||
96
openclaw/extensions/github-copilot/auth.test.ts
Normal file
96
openclaw/extensions/github-copilot/auth.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { 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/config-runtime", () => ({
|
||||
resolveRequiredConfiguredSecretRefInputString: resolveRequiredConfiguredSecretRefInputStringMock,
|
||||
}));
|
||||
|
||||
import { resolveFirstGithubToken } from "./auth.js";
|
||||
|
||||
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 result = await resolveFirstGithubToken({
|
||||
config: { secrets: { defaults: { provider: "default" } } } as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
githubToken: "resolved-profile-token",
|
||||
hasProfile: true,
|
||||
});
|
||||
expect(resolveRequiredConfiguredSecretRefInputStringMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "providers.github-copilot.authProfiles.github-copilot:github.tokenRef",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
openclaw/extensions/github-copilot/auth.ts
Normal file
65
openclaw/extensions/github-copilot/auth.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveRequiredConfiguredSecretRefInputString } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
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 };
|
||||
}
|
||||
258
openclaw/extensions/github-copilot/embeddings.test.ts
Normal file
258
openclaw/extensions/github-copilot/embeddings.test.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { 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/config-runtime", () => ({
|
||||
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/github-copilot-token", () => ({
|
||||
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";
|
||||
|
||||
const TEST_BASE_URL = "https://api.githubcopilot.test";
|
||||
|
||||
function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: unknown }>) {
|
||||
return { data: models };
|
||||
}
|
||||
|
||||
function mockDiscoveryResponse(spec: {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
}) {
|
||||
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
|
||||
response: {
|
||||
ok: spec.ok,
|
||||
status: spec.status ?? (spec.ok ? 200 : 500),
|
||||
json: async () => spec.json,
|
||||
text: async () => spec.text ?? "",
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function defaultCreateOptions() {
|
||||
return {
|
||||
config: {} as Record<string, unknown>,
|
||||
agentDir: "/tmp/test-agent",
|
||||
model: "",
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
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(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubToken: "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: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError("bad json");
|
||||
},
|
||||
text: async () => "",
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
|
||||
).rejects.toThrow("GitHub Copilot model discovery returned invalid JSON");
|
||||
});
|
||||
|
||||
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(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: process.env,
|
||||
githubToken: "gh_remote_token",
|
||||
}),
|
||||
);
|
||||
|
||||
const discoveryCall = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as {
|
||||
init: { headers: Record<string, string> };
|
||||
url: string;
|
||||
};
|
||||
expect(discoveryCall.url).toBe("https://proxy.example/v1/models");
|
||||
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(
|
||||
githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(
|
||||
new Error("Copilot token response missing token"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(
|
||||
new Error("Unexpected response from GitHub Copilot token endpoint"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(
|
||||
new Error("GitHub Copilot model discovery returned invalid JSON"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(
|
||||
new Error("Network timeout"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
344
openclaw/extensions/github-copilot/embeddings.ts
Normal file
344
openclaw/extensions/github-copilot/embeddings.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "openclaw/plugin-sdk/github-copilot-token";
|
||||
import {
|
||||
buildRemoteBaseUrlPolicy,
|
||||
sanitizeAndNormalizeEmbedding,
|
||||
withRemoteHttpResponse,
|
||||
type MemoryEmbeddingProvider,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveFirstGithubToken } from "./auth.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",
|
||||
"Editor-Version": "vscode/1.96.2",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
};
|
||||
|
||||
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 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) {
|
||||
throw new Error(
|
||||
`GitHub Copilot model discovery HTTP ${response.status}: ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new Error("GitHub Copilot model discovery returned invalid JSON");
|
||||
}
|
||||
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[]): 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),
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: session.headers,
|
||||
body: JSON.stringify({ model: client.model, input }),
|
||||
},
|
||||
onResponse: async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new Error("GitHub Copilot embeddings returned invalid JSON");
|
||||
}
|
||||
return parseGitHubCopilotEmbeddingPayload(payload, input.length);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
provider: {
|
||||
id: COPILOT_EMBEDDING_PROVIDER_ID,
|
||||
model: client.model,
|
||||
embedQuery: async (text) => {
|
||||
const [vector] = await embed([text]);
|
||||
return vector ?? [];
|
||||
},
|
||||
embedBatch: embed,
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
73
openclaw/extensions/github-copilot/index.test.ts
Normal file
73
openclaw/extensions/github-copilot/index.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
|
||||
|
||||
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./register.runtime.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
|
||||
resolveCopilotApiToken: resolveCopilotApiTokenMock,
|
||||
githubCopilotLoginCommand: vi.fn(),
|
||||
fetchCopilotUsage: vi.fn(),
|
||||
}));
|
||||
|
||||
import plugin from "./index.js";
|
||||
|
||||
function _registerProvider() {
|
||||
return registerProviderWithPluginConfig({});
|
||||
}
|
||||
|
||||
function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>) {
|
||||
const registerProviderMock = vi.fn();
|
||||
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig,
|
||||
runtime: {} as never,
|
||||
registerProvider: registerProviderMock,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registerProviderMock).toHaveBeenCalledTimes(1);
|
||||
return registerProviderMock.mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
describe("github-copilot plugin", () => {
|
||||
it("registers embedding provider", () => {
|
||||
const registerMemoryEmbeddingProviderMock = vi.fn();
|
||||
|
||||
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 = registerMemoryEmbeddingProviderMock.mock.calls[0]?.[0];
|
||||
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: {},
|
||||
agentDir: "/tmp/agent",
|
||||
env: { GH_TOKEN: "gh_test_token" },
|
||||
resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }),
|
||||
} as never);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(resolveCopilotApiTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
164
openclaw/extensions/github-copilot/index.ts
Normal file
164
openclaw/extensions/github-copilot/index.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveFirstGithubToken } from "./auth.js";
|
||||
import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js";
|
||||
import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js";
|
||||
import { buildGithubCopilotReplayPolicy } from "./replay-policy.js";
|
||||
import { wrapCopilotProviderStream } from "./stream.js";
|
||||
|
||||
const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
|
||||
const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.2", "gpt-5.2-codex"] as const;
|
||||
|
||||
type GithubCopilotPluginConfig = {
|
||||
discovery?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
async function loadGithubCopilotRuntime() {
|
||||
return await import("./register.runtime.js");
|
||||
}
|
||||
export default definePluginEntry({
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot Provider",
|
||||
description: "Bundled GitHub Copilot provider plugin",
|
||||
register(api) {
|
||||
const pluginConfig = (api.pluginConfig ?? {}) as GithubCopilotPluginConfig;
|
||||
|
||||
async function runGitHubCopilotAuth(ctx: ProviderAuthContext) {
|
||||
const { githubCopilotLoginCommand } = await loadGithubCopilotRuntime();
|
||||
await ctx.prompter.note(
|
||||
[
|
||||
"This will open a GitHub device login to authorize Copilot.",
|
||||
"Requires an active GitHub Copilot subscription.",
|
||||
].join("\n"),
|
||||
"GitHub Copilot",
|
||||
);
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
await ctx.prompter.note(
|
||||
"GitHub Copilot login requires an interactive TTY.",
|
||||
"GitHub Copilot",
|
||||
);
|
||||
return { profiles: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
await githubCopilotLoginCommand(
|
||||
{ yes: true, profileId: "github-copilot:github" },
|
||||
ctx.runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
await ctx.prompter.note(`GitHub Copilot login failed: ${String(err)}`, "GitHub Copilot");
|
||||
return { profiles: [] };
|
||||
}
|
||||
|
||||
const authStore = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const credential = authStore.profiles["github-copilot:github"];
|
||||
if (!credential || credential.type !== "token") {
|
||||
return { profiles: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: "github-copilot:github",
|
||||
credential,
|
||||
},
|
||||
],
|
||||
defaultModel: "github-copilot/claude-opus-4.6",
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceId: "github-copilot",
|
||||
choiceLabel: "GitHub Copilot",
|
||||
choiceHint: "Device login with your GitHub account",
|
||||
methodId: "device",
|
||||
},
|
||||
},
|
||||
catalog: {
|
||||
order: "late",
|
||||
run: async (ctx) => {
|
||||
const discoveryEnabled =
|
||||
pluginConfig.discovery?.enabled ?? ctx.config?.models?.copilotDiscovery?.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;
|
||||
if (githubToken) {
|
||||
try {
|
||||
const token = await resolveCopilotApiToken({
|
||||
githubToken,
|
||||
env: ctx.env,
|
||||
});
|
||||
baseUrl = token.baseUrl;
|
||||
} catch {
|
||||
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: {
|
||||
baseUrl,
|
||||
models: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveDynamicModel: (ctx) => resolveCopilotForwardCompatModel(ctx),
|
||||
wrapStreamFn: wrapCopilotProviderStream,
|
||||
buildReplayPolicy: ({ modelId }) => buildGithubCopilotReplayPolicy(modelId),
|
||||
supportsXHighThinking: ({ modelId }) =>
|
||||
COPILOT_XHIGH_MODEL_IDS.includes(
|
||||
(normalizeOptionalLowercaseString(modelId) ?? "") as never,
|
||||
),
|
||||
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);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
186
openclaw/extensions/github-copilot/login.ts
Normal file
186
openclaw/extensions/github-copilot/login.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { intro, note, outro, spinner } from "@clack/prompts";
|
||||
import { stylePromptTitle } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { logConfigUpdated, updateConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
ensureAuthProfileStore,
|
||||
upsertAuthProfile,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/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";
|
||||
|
||||
type DeviceCodeResponse = {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
};
|
||||
|
||||
type DeviceTokenResponse =
|
||||
| {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope?: string;
|
||||
}
|
||||
| {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
};
|
||||
|
||||
function parseJsonResponse(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Unexpected response from GitHub");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
scope: params.scope,
|
||||
});
|
||||
|
||||
const res = await fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub device code failed: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json = parseJsonResponse(await res.json()) as DeviceCodeResponse;
|
||||
if (!json.device_code || !json.user_code || !json.verification_uri) {
|
||||
throw new Error("GitHub device code response missing fields");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function pollForAccessToken(params: {
|
||||
deviceCode: string;
|
||||
intervalMs: number;
|
||||
expiresAt: number;
|
||||
}): Promise<string> {
|
||||
const bodyBase = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
device_code: params.deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
|
||||
while (Date.now() < params.expiresAt) {
|
||||
const res = await fetch(ACCESS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: bodyBase,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub device token failed: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json = parseJsonResponse(await res.json()) 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 new Promise((r) => setTimeout(r, params.intervalMs));
|
||||
continue;
|
||||
}
|
||||
if (err === "slow_down") {
|
||||
await new Promise((r) => setTimeout(r, params.intervalMs + 2000));
|
||||
continue;
|
||||
}
|
||||
if (err === "expired_token") {
|
||||
throw new Error("GitHub device code expired; run login again");
|
||||
}
|
||||
if (err === "access_denied") {
|
||||
throw new Error("GitHub login cancelled");
|
||||
}
|
||||
throw new Error(`GitHub device flow error: ${err}`);
|
||||
}
|
||||
|
||||
throw new Error("GitHub device code expired; run login again");
|
||||
}
|
||||
|
||||
export async function githubCopilotLoginCommand(
|
||||
opts: { profileId?: string; yes?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error("github-copilot login requires an interactive TTY.");
|
||||
}
|
||||
|
||||
intro(stylePromptTitle("GitHub Copilot login"));
|
||||
|
||||
const profileId = opts.profileId?.trim() || "github-copilot:github";
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
|
||||
if (store.profiles[profileId] && !opts.yes) {
|
||||
note(
|
||||
`Auth profile already exists: ${profileId}\nRe-running will overwrite it.`,
|
||||
stylePromptTitle("Existing credentials"),
|
||||
);
|
||||
}
|
||||
|
||||
const spin = spinner();
|
||||
spin.start("Requesting device code from GitHub...");
|
||||
const device = await requestDeviceCode({ scope: "read:user" });
|
||||
spin.stop("Device code ready");
|
||||
|
||||
note(
|
||||
[`Visit: ${device.verification_uri}`, `Code: ${device.user_code}`].join("\n"),
|
||||
stylePromptTitle("Authorize"),
|
||||
);
|
||||
|
||||
const expiresAt = Date.now() + device.expires_in * 1000;
|
||||
const intervalMs = Math.max(1000, device.interval * 1000);
|
||||
|
||||
const polling = spinner();
|
||||
polling.start("Waiting for GitHub authorization...");
|
||||
const accessToken = await pollForAccessToken({
|
||||
deviceCode: device.device_code,
|
||||
intervalMs,
|
||||
expiresAt,
|
||||
});
|
||||
polling.stop("GitHub access token acquired");
|
||||
|
||||
upsertAuthProfile({
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: accessToken,
|
||||
// GitHub device flow token doesn't reliably include expiry here.
|
||||
// Leave expires unset; we'll exchange into Copilot token plus expiry later.
|
||||
},
|
||||
});
|
||||
|
||||
await updateConfig((cfg) =>
|
||||
applyAuthProfileConfig(cfg, {
|
||||
provider: "github-copilot",
|
||||
profileId,
|
||||
mode: "token",
|
||||
}),
|
||||
);
|
||||
|
||||
logConfigUpdated(runtime);
|
||||
runtime.log(`Auth profile: ${profileId} (github-copilot/token)`);
|
||||
|
||||
outro("Done");
|
||||
}
|
||||
42
openclaw/extensions/github-copilot/models-defaults.ts
Normal file
42
openclaw/extensions/github-copilot/models-defaults.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { resolveCopilotTransportApi } from "./models.js";
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
||||
const DEFAULT_MAX_TOKENS = 8192;
|
||||
|
||||
// Copilot model ids vary by plan/org and can change.
|
||||
// We keep this list intentionally broad; if a model isn't available Copilot will
|
||||
// return an error and users can remove it from their config.
|
||||
const DEFAULT_MODEL_IDS = [
|
||||
"claude-opus-4.6",
|
||||
"claude-sonnet-4.6",
|
||||
"claude-sonnet-4.5",
|
||||
"gpt-4o",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"o3-mini",
|
||||
] as const;
|
||||
|
||||
export function getDefaultCopilotModelIds(): string[] {
|
||||
return [...DEFAULT_MODEL_IDS];
|
||||
}
|
||||
|
||||
export function buildCopilotModelDefinition(modelId: string): ModelDefinitionConfig {
|
||||
const id = modelId.trim();
|
||||
if (!id) {
|
||||
throw new Error("Model id required");
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
api: resolveCopilotTransportApi(id),
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
311
openclaw/extensions/github-copilot/models.test.ts
Normal file
311
openclaw/extensions/github-copilot/models.test.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createProviderUsageFetch,
|
||||
makeResponse,
|
||||
} from "../../test/helpers/plugins/provider-usage-fetch.js";
|
||||
import { buildCopilotModelDefinition, getDefaultCopilotModelIds } from "./models-defaults.js";
|
||||
import { fetchCopilotUsage } from "./usage.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getOAuthApiKey: vi.fn(),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-model-shared", () => ({
|
||||
normalizeModelCompat: (model: Record<string, unknown>) => model,
|
||||
}));
|
||||
|
||||
const loadJsonFile = vi.fn();
|
||||
const saveJsonFile = vi.fn();
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/json-store", () => ({
|
||||
loadJsonFile,
|
||||
saveJsonFile,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
|
||||
resolveStateDir: () => "/tmp/openclaw-state",
|
||||
}));
|
||||
|
||||
import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core";
|
||||
import { resolveCopilotForwardCompatModel } from "./models.js";
|
||||
|
||||
let deriveCopilotApiBaseUrlFromToken: typeof import("./token.js").deriveCopilotApiBaseUrlFromToken;
|
||||
let resolveCopilotApiToken: typeof import("./token.js").resolveCopilotApiToken;
|
||||
|
||||
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("github-copilot model defaults", () => {
|
||||
describe("getDefaultCopilotModelIds", () => {
|
||||
it("includes claude-opus-4.6", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-opus-4.6");
|
||||
});
|
||||
|
||||
it("includes claude-sonnet-4.6", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.6");
|
||||
});
|
||||
|
||||
it("includes claude-sonnet-4.5", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.5");
|
||||
});
|
||||
|
||||
it("returns a mutable copy", () => {
|
||||
const a = getDefaultCopilotModelIds();
|
||||
const b = getDefaultCopilotModelIds();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCopilotModelDefinition", () => {
|
||||
it("builds a valid definition for claude-sonnet-4.6", () => {
|
||||
const def = buildCopilotModelDefinition("claude-sonnet-4.6");
|
||||
expect(def.id).toBe("claude-sonnet-4.6");
|
||||
expect(def.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("trims whitespace from model id", () => {
|
||||
const def = buildCopilotModelDefinition(" gpt-4o ");
|
||||
expect(def.id).toBe("gpt-4o");
|
||||
expect(def.api).toBe("openai-responses");
|
||||
});
|
||||
|
||||
it("throws on empty model id", () => {
|
||||
expect(() => buildCopilotModelDefinition("")).toThrow("Model id required");
|
||||
expect(() => buildCopilotModelDefinition(" ")).toThrow("Model id required");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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.2-codex template for gpt-5.4", () => {
|
||||
const template = {
|
||||
id: "gpt-5.2-codex",
|
||||
name: "gpt-5.2-codex",
|
||||
provider: "github-copilot",
|
||||
api: "openai-responses",
|
||||
reasoning: true,
|
||||
contextWindow: 200_000,
|
||||
};
|
||||
const ctx = createMockCtx("gpt-5.4", {
|
||||
"github-copilot/gpt-5.2-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("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("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("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("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",
|
||||
"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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("github-copilot token", () => {
|
||||
const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json";
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
loadJsonFile.mockClear();
|
||||
saveJsonFile.mockClear();
|
||||
({ deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } = await import("./token.js"));
|
||||
});
|
||||
|
||||
it("derives baseUrl from token", async () => {
|
||||
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();
|
||||
loadJsonFile.mockReturnValue({
|
||||
token: "cached;proxy-ep=proxy.example.com;",
|
||||
expiresAt: now + 60 * 60 * 1000,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const fetchImpl = vi.fn();
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: loadJsonFile,
|
||||
saveJsonFileImpl: 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(String(res.source)).toContain("cache:");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches and stores token when cache is missing", async () => {
|
||||
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: loadJsonFile,
|
||||
saveJsonFileImpl: 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");
|
||||
expect(saveJsonFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
77
openclaw/extensions/github-copilot/models.ts
Normal file
77
openclaw/extensions/github-copilot/models.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type {
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export const PROVIDER_ID = "github-copilot";
|
||||
const CODEX_GPT_54_MODEL_ID = "gpt-5.4";
|
||||
const CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
||||
const DEFAULT_MAX_TOKENS = 8192;
|
||||
|
||||
export function resolveCopilotTransportApi(
|
||||
modelId: string,
|
||||
): "anthropic-messages" | "openai-responses" {
|
||||
return (normalizeOptionalLowercaseString(modelId) ?? "").includes("claude")
|
||||
? "anthropic-messages"
|
||||
: "openai-responses";
|
||||
}
|
||||
|
||||
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 specifically, clone from the gpt-5.2-codex template
|
||||
// to preserve any special settings the registry has for codex models.
|
||||
if (lowerModelId === CODEX_GPT_54_MODEL_ID) {
|
||||
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.
|
||||
}
|
||||
|
||||
// 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);
|
||||
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,
|
||||
} as ProviderRuntimeModel);
|
||||
}
|
||||
46
openclaw/extensions/github-copilot/openclaw.plugin.json
Normal file
46
openclaw/extensions/github-copilot/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"id": "github-copilot",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["github-copilot"],
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["github-copilot"]
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"github-copilot": ["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"
|
||||
}
|
||||
],
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
15
openclaw/extensions/github-copilot/package.json
Normal file
15
openclaw/extensions/github-copilot/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@openclaw/github-copilot-provider",
|
||||
"version": "2026.4.20",
|
||||
"private": true,
|
||||
"description": "OpenClaw GitHub Copilot provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
24
openclaw/extensions/github-copilot/register.runtime.ts
Normal file
24
openclaw/extensions/github-copilot/register.runtime.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import {
|
||||
coerceSecretRef,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth-login";
|
||||
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,
|
||||
};
|
||||
9
openclaw/extensions/github-copilot/replay-policy.ts
Normal file
9
openclaw/extensions/github-copilot/replay-policy.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export function buildGithubCopilotReplayPolicy(modelId?: string) {
|
||||
return normalizeLowercaseStringOrEmpty(modelId).includes("claude")
|
||||
? {
|
||||
dropThinkingBlocks: true,
|
||||
}
|
||||
: {};
|
||||
}
|
||||
111
openclaw/extensions/github-copilot/stream.test.ts
Normal file
111
openclaw/extensions/github-copilot/stream.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { buildCopilotDynamicHeaders } from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
|
||||
|
||||
describe("wrapCopilotAnthropicStream", () => {
|
||||
it("adds Copilot headers and Anthropic cache markers for Claude payloads", async () => {
|
||||
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", text: "draft", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {},
|
||||
} as never;
|
||||
});
|
||||
|
||||
const wrapped = 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,
|
||||
});
|
||||
|
||||
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();
|
||||
expect(baseStreamFn.mock.calls[0]?.[2]).toMatchObject({
|
||||
headers: {
|
||||
...expectedCopilotHeaders,
|
||||
"X-Test": "1",
|
||||
},
|
||||
});
|
||||
expect(payloads[0]?.messages).toEqual([
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "system prompt", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", text: "draft" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves non-Anthropic Copilot models untouched", () => {
|
||||
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
|
||||
const wrapped = wrapCopilotAnthropicStream(baseStreamFn);
|
||||
const options = { headers: { Existing: "1" } };
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
provider: "github-copilot",
|
||||
api: "openai-responses",
|
||||
id: "gpt-4.1",
|
||||
} as never,
|
||||
{ messages: [{ role: "user", content: "hi" }] } as never,
|
||||
options as never,
|
||||
);
|
||||
|
||||
expect(baseStreamFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), options);
|
||||
});
|
||||
|
||||
it("adapts provider stream context without changing wrapper behavior", () => {
|
||||
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
|
||||
|
||||
const wrapped = 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();
|
||||
});
|
||||
});
|
||||
41
openclaw/extensions/github-copilot/stream.ts
Normal file
41
openclaw/extensions/github-copilot/stream.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
applyAnthropicEphemeralCacheControlMarkers,
|
||||
buildCopilotDynamicHeaders,
|
||||
hasCopilotVisionInput,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
|
||||
type _StreamContext = Parameters<StreamFn>[1];
|
||||
|
||||
export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
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: {
|
||||
...buildCopilotDynamicHeaders({
|
||||
messages: context.messages,
|
||||
hasImages: hasCopilotVisionInput(context.messages),
|
||||
}),
|
||||
...options?.headers,
|
||||
},
|
||||
},
|
||||
applyAnthropicEphemeralCacheControlMarkers,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapCopilotProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn {
|
||||
return wrapCopilotAnthropicStream(ctx.streamFn);
|
||||
}
|
||||
7
openclaw/extensions/github-copilot/token.ts
Normal file
7
openclaw/extensions/github-copilot/token.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import {
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
deriveCopilotApiBaseUrlFromToken,
|
||||
resolveCopilotApiToken,
|
||||
} from "openclaw/plugin-sdk/github-copilot-token";
|
||||
|
||||
export { DEFAULT_COPILOT_API_BASE_URL, deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken };
|
||||
16
openclaw/extensions/github-copilot/tsconfig.json
Normal file
16
openclaw/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"
|
||||
]
|
||||
}
|
||||
69
openclaw/extensions/github-copilot/usage.ts
Normal file
69
openclaw/extensions/github-copilot/usage.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
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}`,
|
||||
"Editor-Version": "vscode/1.96.2",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"X-Github-Api-Version": "2025-04-01",
|
||||
},
|
||||
},
|
||||
timeoutMs,
|
||||
fetchFn,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return buildUsageHttpErrorSnapshot({
|
||||
provider: "github-copilot",
|
||||
status: res.status,
|
||||
});
|
||||
}
|
||||
|
||||
const data = (await res.json()) as CopilotUsageResponse;
|
||||
const windows: UsageWindow[] = [];
|
||||
|
||||
if (data.quota_snapshots?.premium_interactions) {
|
||||
const remaining = data.quota_snapshots.premium_interactions.percent_remaining;
|
||||
windows.push({
|
||||
label: "Premium",
|
||||
usedPercent: clampPercent(100 - (remaining ?? 0)),
|
||||
});
|
||||
}
|
||||
|
||||
if (data.quota_snapshots?.chat) {
|
||||
const remaining = data.quota_snapshots.chat.percent_remaining;
|
||||
windows.push({
|
||||
label: "Chat",
|
||||
usedPercent: clampPercent(100 - (remaining ?? 0)),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "github-copilot",
|
||||
displayName: PROVIDER_LABELS["github-copilot"],
|
||||
windows,
|
||||
plan: data.copilot_plan,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue