mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
feat(github-copilot): add embedding provider for memory search (#61718)
Merged via squash.
Prepared head SHA: 05a78ce7f2
Co-authored-by: feiskyer <676637+feiskyer@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
96
extensions/github-copilot/auth.test.ts
Normal file
96
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
extensions/github-copilot/auth.ts
Normal file
65
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 };
|
||||
}
|
||||
279
extensions/github-copilot/embeddings.test.ts
Normal file
279
extensions/github-copilot/embeddings.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
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());
|
||||
const createGitHubCopilotEmbeddingProviderMock = 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/memory-core-host-engine-embeddings", () => ({
|
||||
createGitHubCopilotEmbeddingProvider: createGitHubCopilotEmbeddingProviderMock,
|
||||
}));
|
||||
|
||||
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,
|
||||
});
|
||||
createGitHubCopilotEmbeddingProviderMock.mockImplementation(async (client) => ({
|
||||
provider: {
|
||||
id: "github-copilot",
|
||||
model: client.model,
|
||||
embedQuery: async () => [0.1, 0.2, 0.3],
|
||||
embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2, 0.3]),
|
||||
},
|
||||
client,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resolveConfiguredSecretInputStringMock.mockReset();
|
||||
resolveFirstGithubTokenMock.mockReset();
|
||||
resolveCopilotApiTokenMock.mockReset();
|
||||
createGitHubCopilotEmbeddingProviderMock.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(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: TEST_BASE_URL,
|
||||
githubToken: "gh_test_token_123",
|
||||
model: "text-embedding-3-small",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith({
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
env: process.env,
|
||||
fetchImpl: fetch,
|
||||
githubToken: "gh_remote_token",
|
||||
headers: { "X-Proxy-Token": "proxy" },
|
||||
model: "text-embedding-3-small",
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
215
extensions/github-copilot/embeddings.ts
Normal file
215
extensions/github-copilot/embeddings.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "openclaw/plugin-sdk/github-copilot-token";
|
||||
import {
|
||||
createGitHubCopilotEmbeddingProvider,
|
||||
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;
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
|
||||
id: COPILOT_EMBEDDING_PROVIDER_ID,
|
||||
transport: "remote",
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -36,6 +36,27 @@ function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>)
|
||||
}
|
||||
|
||||
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 } });
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
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";
|
||||
@@ -27,39 +25,6 @@ export default definePluginEntry({
|
||||
description: "Bundled GitHub Copilot provider plugin",
|
||||
register(api) {
|
||||
const pluginConfig = (api.pluginConfig ?? {}) as GithubCopilotPluginConfig;
|
||||
function resolveFirstGithubToken(params: { agentDir?: string; env: NodeJS.ProcessEnv }): {
|
||||
githubToken: string;
|
||||
hasProfile: boolean;
|
||||
} {
|
||||
const authStore = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const hasProfile = listProfilesForProvider(authStore, PROVIDER_ID).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 = listProfilesForProvider(authStore, PROVIDER_ID)[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,
|
||||
};
|
||||
}
|
||||
return { githubToken: "", hasProfile };
|
||||
}
|
||||
|
||||
async function runGitHubCopilotAuth(ctx: ProviderAuthContext) {
|
||||
const { githubCopilotLoginCommand } = await loadGithubCopilotRuntime();
|
||||
@@ -108,6 +73,8 @@ export default definePluginEntry({
|
||||
};
|
||||
}
|
||||
|
||||
api.registerMemoryEmbeddingProvider(githubCopilotMemoryEmbeddingProviderAdapter);
|
||||
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "GitHub Copilot",
|
||||
@@ -140,8 +107,9 @@ export default definePluginEntry({
|
||||
}
|
||||
const { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } =
|
||||
await loadGithubCopilotRuntime();
|
||||
const { githubToken, hasProfile } = resolveFirstGithubToken({
|
||||
const { githubToken, hasProfile } = await resolveFirstGithubToken({
|
||||
agentDir: ctx.agentDir,
|
||||
config: ctx.config,
|
||||
env: ctx.env,
|
||||
});
|
||||
if (!hasProfile && !githubToken) {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"id": "github-copilot",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["github-copilot"],
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["github-copilot"]
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user