feat(github-copilot): add embedding provider for memory search

Add GitHub Copilot as a memory search embedding provider so users with
Copilot subscriptions can use embeddings without a separate API key.

- Extract resolveFirstGithubToken to shared auth.ts for reuse
- Add MemoryEmbeddingProviderAdapter with dynamic model discovery
  via the Copilot /models endpoint, auto-selecting the best
  available embedding model (prefers text-embedding-3-small)
- Register the provider at auto-selection priority 15 (between
  local and OpenAI) and declare the memoryEmbeddingProviders
  contract in the plugin manifest
- Match models by ID pattern when supported_endpoints is empty,
  as the Copilot API lists embedding models without declaring
  their endpoint
- Add docs for memory search provider tables, config reference,
  and the GitHub Copilot provider page
This commit is contained in:
Pengfei Ni
2026-04-06 06:19:19 +00:00
committed by Vincent Koc
parent 7821fae05d
commit 55d00f335a
9 changed files with 931 additions and 68 deletions

View File

@@ -15,8 +15,9 @@ chunks and searching them using embeddings, keywords, or both.
## Quick start
If you have an OpenAI, Gemini, Voyage, or Mistral API key configured, memory
search works automatically. To set a provider explicitly:
If you have a GitHub Copilot subscription, OpenAI, Gemini, Voyage, or Mistral
API key configured, memory search works automatically. To set a provider
explicitly:
```json5
{
@@ -35,15 +36,16 @@ node-llama-cpp).
## Supported providers
| Provider | ID | Needs API key | Notes |
| -------- | --------- | ------------- | ---------------------------------------------------- |
| OpenAI | `openai` | Yes | Auto-detected, fast |
| Gemini | `gemini` | Yes | Supports image/audio indexing |
| Voyage | `voyage` | Yes | Auto-detected |
| Mistral | `mistral` | Yes | Auto-detected |
| Bedrock | `bedrock` | No | Auto-detected when the AWS credential chain resolves |
| Ollama | `ollama` | No | Local, must set explicitly |
| Local | `local` | No | GGUF model, ~0.6 GB download |
| Provider | ID | Needs API key | Notes |
| -------------- | ---------------- | ------------- | ---------------------------------------------------- |
| GitHub Copilot | `github-copilot` | No | Auto-detected, uses Copilot subscription |
| OpenAI | `openai` | Yes | Auto-detected, fast |
| Gemini | `gemini` | Yes | Supports image/audio indexing |
| Voyage | `voyage` | Yes | Auto-detected |
| Mistral | `mistral` | Yes | Auto-detected |
| Bedrock | `bedrock` | No | Auto-detected when the AWS credential chain resolves |
| Ollama | `ollama` | No | Local, must set explicitly |
| Local | `local` | No | GGUF model, ~0.6 GB download |
## How search works

View File

@@ -119,6 +119,46 @@ Requires an interactive TTY. Run the login command directly in a terminal, not
inside a headless script or CI job.
</Warning>
## Memory search embeddings
GitHub Copilot can also serve as an embedding provider for
[memory search](/concepts/memory-search). If you have a Copilot subscription and
have logged in, OpenClaw can use it for embeddings without a separate API key.
### Auto-detection
When `memorySearch.provider` is `"auto"` (the default), GitHub Copilot is tried
at priority 15 -- after local embeddings but before OpenAI and other paid
providers. If a GitHub token is available, OpenClaw discovers available
embedding models from the Copilot API and picks the best one automatically.
### Explicit config
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "github-copilot",
// Optional: override the auto-discovered model
model: "text-embedding-3-small",
},
},
},
}
```
### How it works
1. OpenClaw resolves your GitHub token (from env vars or auth profile).
2. Exchanges it for a short-lived Copilot API token.
3. Queries the Copilot `/models` endpoint to discover available embedding models.
4. Picks the best model (prefers `text-embedding-3-small`).
5. Sends embedding requests to the Copilot `/embeddings` endpoint.
Model availability depends on your GitHub plan. If no embedding models are
available, OpenClaw skips Copilot and tries the next provider.
## Related
<CardGroup cols={2}>

View File

@@ -37,23 +37,24 @@ plugin-owned config, transcript persistence, and safe rollout pattern.
## Provider selection
| Key | Type | Default | Description |
| ---------- | --------- | ---------------- | ------------------------------------------------------------------------------------------- |
| `provider` | `string` | auto-detected | Embedding adapter ID: `openai`, `gemini`, `voyage`, `mistral`, `bedrock`, `ollama`, `local` |
| `model` | `string` | provider default | Embedding model name |
| `fallback` | `string` | `"none"` | Fallback adapter ID when the primary fails |
| `enabled` | `boolean` | `true` | Enable or disable memory search |
| Key | Type | Default | Description |
| ---------- | --------- | ---------------- | ------------------------------------------------------------------------------------------------------------- |
| `provider` | `string` | auto-detected | Embedding adapter ID: `github-copilot`, `openai`, `gemini`, `voyage`, `mistral`, `bedrock`, `ollama`, `local` |
| `model` | `string` | provider default | Embedding model name |
| `fallback` | `string` | `"none"` | Fallback adapter ID when the primary fails |
| `enabled` | `boolean` | `true` | Enable or disable memory search |
### Auto-detection order
When `provider` is not set, OpenClaw selects the first available:
1. `local` -- if `memorySearch.local.modelPath` is configured and the file exists.
2. `openai` -- if an OpenAI key can be resolved.
3. `gemini` -- if a Gemini key can be resolved.
4. `voyage` -- if a Voyage key can be resolved.
5. `mistral` -- if a Mistral key can be resolved.
6. `bedrock` -- if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
2. `github-copilot` -- if a GitHub Copilot token can be resolved (env var or auth profile).
3. `openai` -- if an OpenAI key can be resolved.
4. `gemini` -- if a Gemini key can be resolved.
5. `voyage` -- if a Voyage key can be resolved.
6. `mistral` -- if a Mistral key can be resolved.
7. `bedrock` -- if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
`ollama` is supported but not auto-detected (set it explicitly).
@@ -62,14 +63,15 @@ When `provider` is not set, OpenClaw selects the first available:
Remote embeddings require an API key. Bedrock uses the AWS SDK default
credential chain instead (instance roles, SSO, access keys).
| Provider | Env var | Config key |
| -------- | ------------------------------ | --------------------------------- |
| OpenAI | `OPENAI_API_KEY` | `models.providers.openai.apiKey` |
| Gemini | `GEMINI_API_KEY` | `models.providers.google.apiKey` |
| Voyage | `VOYAGE_API_KEY` | `models.providers.voyage.apiKey` |
| Mistral | `MISTRAL_API_KEY` | `models.providers.mistral.apiKey` |
| Bedrock | AWS credential chain | No API key needed |
| Ollama | `OLLAMA_API_KEY` (placeholder) | -- |
| Provider | Env var | Config key |
| -------------- | -------------------------------------------------- | --------------------------------- |
| GitHub Copilot | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | Auth profile via device login |
| OpenAI | `OPENAI_API_KEY` | `models.providers.openai.apiKey` |
| Gemini | `GEMINI_API_KEY` | `models.providers.google.apiKey` |
| Voyage | `VOYAGE_API_KEY` | `models.providers.voyage.apiKey` |
| Mistral | `MISTRAL_API_KEY` | `models.providers.mistral.apiKey` |
| Bedrock | AWS credential chain | No API key needed |
| Ollama | `OLLAMA_API_KEY` (placeholder) | -- |
Codex OAuth covers chat/completions only and does not satisfy embedding
requests.

View File

@@ -0,0 +1,40 @@
import {
coerceSecretRef,
ensureAuthProfileStore,
listProfilesForProvider,
} from "openclaw/plugin-sdk/provider-auth";
import { PROVIDER_ID } from "./models.js";
export 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 };
}

View File

@@ -0,0 +1,519 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("./auth.js", () => ({
resolveFirstGithubToken: resolveFirstGithubTokenMock,
}));
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,
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
coerceSecretRef: vi.fn(),
ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })),
listProfilesForProvider: vi.fn(() => []),
}));
import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js";
const TEST_COPILOT_TOKEN = "copilot_test_token_abc";
const TEST_BASE_URL = "https://api.githubcopilot.test";
function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: string[] }>) {
return { data: models };
}
function buildEmbeddingResponse(embeddings: Array<{ embedding: number[]; index: number }>) {
return { data: embeddings };
}
function mockFetchSequence(
responses: Array<{ ok: boolean; status?: number; json?: unknown; text?: string }>,
) {
let callIndex = 0;
fetchWithSsrFGuardMock.mockImplementation(async () => {
const spec = responses[callIndex++];
if (!spec) {
throw new Error(`Unexpected fetchWithSsrFGuard call #${callIndex}`);
}
return {
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(() => {
resolveFirstGithubTokenMock.mockReturnValue({
githubToken: "gh_test_token_123",
hasProfile: false,
});
resolveCopilotApiTokenMock.mockResolvedValue({
token: TEST_COPILOT_TOKEN,
expiresAt: Date.now() + 3_600_000,
source: "test",
baseUrl: TEST_BASE_URL,
});
});
afterEach(() => {
vi.restoreAllMocks();
resolveFirstGithubTokenMock.mockReset();
resolveCopilotApiTokenMock.mockReset();
fetchWithSsrFGuardMock.mockReset();
});
describe("adapter properties", () => {
it("has correct id", () => {
expect(githubCopilotMemoryEmbeddingProviderAdapter.id).toBe("github-copilot");
});
it("has transport set to remote", () => {
expect(githubCopilotMemoryEmbeddingProviderAdapter.transport).toBe("remote");
});
it("has autoSelectPriority of 15", () => {
expect(githubCopilotMemoryEmbeddingProviderAdapter.autoSelectPriority).toBe(15);
});
it("allows explicit override when configured auto", () => {
expect(githubCopilotMemoryEmbeddingProviderAdapter.allowExplicitWhenConfiguredAuto).toBe(
true,
);
});
});
describe("model discovery", () => {
it("picks text-embedding-3-small when available", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-large", supported_endpoints: ["/v1/embeddings"] },
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
{ id: "text-embedding-ada-002", 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");
});
it("falls back to text-embedding-3-large when small is unavailable", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-large", supported_endpoints: ["/v1/embeddings"] },
{ id: "text-embedding-ada-002", supported_endpoints: ["/v1/embeddings"] },
]),
},
]);
const result =
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.provider?.model).toBe("text-embedding-3-large");
});
it("filters models by embedding endpoint support", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "gpt-4o", supported_endpoints: ["/v1/chat/completions"] },
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
},
]);
const result =
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.provider?.model).toBe("text-embedding-3-small");
});
it("discovers models by ID when supported_endpoints is empty", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "gpt-4o", supported_endpoints: ["/v1/chat/completions"] },
{ 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("picks first available model when no preferred model is available", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "custom-embedding-v1", supported_endpoints: ["/v1/embeddings"] },
]),
},
]);
const result =
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.provider?.model).toBe("custom-embedding-v1");
});
});
describe("user-configured model", () => {
it("uses user-configured model override", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
{ id: "custom-model", supported_endpoints: ["/v1/embeddings"] },
]),
},
]);
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create({
...defaultCreateOptions(),
model: "custom-model",
} as never);
expect(result.provider?.model).toBe("custom-model");
});
it("strips github-copilot/ prefix from user model", async () => {
mockFetchSequence([
{
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 user model is not in discovered list", async () => {
mockFetchSequence([
{
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 user model is set but no embedding models are discovered", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "gpt-4o", supported_endpoints: ["/v1/chat/completions"] },
]),
},
]);
await expect(
githubCopilotMemoryEmbeddingProviderAdapter.create({
...defaultCreateOptions(),
model: "text-embedding-3-small",
} as never),
).rejects.toThrow("No embedding models available from GitHub Copilot");
});
});
describe("error handling", () => {
it("throws when no embedding models are available", async () => {
mockFetchSequence([
{
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("throws when model discovery returns HTTP error", async () => {
mockFetchSequence([
{
ok: false,
status: 401,
text: "Unauthorized",
},
]);
await expect(
githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
).rejects.toThrow("GitHub Copilot model discovery HTTP 401");
});
it("throws when no GitHub token is available", async () => {
resolveFirstGithubTokenMock.mockReturnValue({
githubToken: "",
hasProfile: false,
});
await expect(
githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
).rejects.toThrow("No GitHub token available");
});
it("throws when embeddings endpoint returns HTTP error", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
},
{ ok: false, status: 429, text: "Rate limit exceeded" },
]);
const result =
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
await expect(result.provider!.embedQuery("hello")).rejects.toThrow(
"GitHub Copilot embeddings HTTP 429",
);
});
it("throws when embeddings response is malformed", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
},
{ ok: true, json: { model: "text-embedding-3-small" } },
]);
const result =
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
await expect(result.provider!.embedQuery("hello")).rejects.toThrow(
"GitHub Copilot embeddings response missing data[]",
);
});
});
describe("shouldContinueAutoSelection", () => {
it("returns true for missing GitHub token errors", () => {
const err = new Error("No GitHub token available for Copilot embedding provider");
expect(githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(err)).toBe(
true,
);
});
it("returns true for token exchange failures", () => {
const err = new Error("Copilot token exchange failed: HTTP 401");
expect(githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(err)).toBe(
true,
);
});
it("returns true for no embedding models available", () => {
const err = new Error("No embedding models available from GitHub Copilot");
expect(githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(err)).toBe(
true,
);
});
it("returns true for model discovery failures", () => {
const err = new Error("GitHub Copilot model discovery HTTP 403: Forbidden");
expect(githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(err)).toBe(
true,
);
});
it("returns true for user model not available", () => {
const err = new Error(
'GitHub Copilot embedding model "gpt-4o" is not available. Available: text-embedding-3-small',
);
expect(githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(err)).toBe(
true,
);
});
it("returns false for non-Copilot errors", () => {
const err = new Error("Network timeout");
expect(githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(err)).toBe(
false,
);
});
it("returns false for non-Error values", () => {
expect(
githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!("some string"),
).toBe(false);
});
});
describe("embedQuery", () => {
it("calls the endpoint and returns a vector", async () => {
const embedding = [0.1, 0.2, 0.3];
const magnitude = Math.sqrt(embedding.reduce((sum, v) => sum + v * v, 0));
const normalized = embedding.map((v) => v / magnitude);
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
},
{
ok: true,
json: buildEmbeddingResponse([{ embedding, index: 0 }]),
},
]);
const result =
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
const vector = await result.provider!.embedQuery("hello world");
expect(vector).toEqual(normalized);
// Verify the embeddings call used POST with correct body (second fetch call)
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2);
const embeddingsCall = fetchWithSsrFGuardMock.mock.calls[1][0] as {
url: string;
init: { method: string; body: string };
};
expect(embeddingsCall.url).toBe(`${TEST_BASE_URL}/embeddings`);
expect(embeddingsCall.init.method).toBe("POST");
const body = JSON.parse(embeddingsCall.init.body) as { model: string; input: string[] };
expect(body.model).toBe("text-embedding-3-small");
expect(body.input).toEqual(["hello world"]);
});
});
describe("embedBatch", () => {
it("returns multiple vectors sorted by index", async () => {
const emb0 = [0.1, 0.2, 0.3];
const emb1 = [0.4, 0.5, 0.6];
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
},
{
ok: true,
// Return in reverse index order to verify sorting
json: buildEmbeddingResponse([
{ embedding: emb1, index: 1 },
{ embedding: emb0, index: 0 },
]),
},
]);
const result =
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
const vectors = await result.provider!.embedBatch(["first", "second"]);
expect(vectors).toHaveLength(2);
// Verify order matches input order (index 0 first, index 1 second)
const mag0 = Math.sqrt(emb0.reduce((sum, v) => sum + v * v, 0));
const mag1 = Math.sqrt(emb1.reduce((sum, v) => sum + v * v, 0));
expect(vectors[0]).toEqual(emb0.map((v) => v / mag0));
expect(vectors[1]).toEqual(emb1.map((v) => v / mag1));
});
it("returns empty array for empty input", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
},
]);
const result =
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
const vectors = await result.provider!.embedBatch([]);
expect(vectors).toEqual([]);
// No extra fetch call for empty input
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1);
});
});
describe("runtime", () => {
it("includes cache key data with provider, baseUrl, and model", async () => {
mockFetchSequence([
{
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
},
]);
const result =
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.runtime).toBeDefined();
expect(result.runtime!.id).toBe("github-copilot");
expect(result.runtime!.cacheKeyData).toEqual({
provider: "github-copilot",
baseUrl: TEST_BASE_URL,
model: "text-embedding-3-small",
});
});
});
});

View File

@@ -0,0 +1,268 @@
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "openclaw/plugin-sdk/github-copilot-token";
import type {
MemoryEmbeddingProvider,
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: string;
supported_endpoints?: string[];
};
type CopilotModelsResponse = {
data?: CopilotModelEntry[];
};
type CopilotEmbeddingDataEntry = {
embedding: number[];
index: number;
};
type CopilotEmbeddingResponse = {
data?: CopilotEmbeddingDataEntry[];
model?: string;
};
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("No embedding models available") ||
err.message.includes("GitHub Copilot model discovery") ||
err.message.includes("GitHub Copilot embedding model")
);
}
async function discoverEmbeddingModels(params: {
baseUrl: string;
copilotToken: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<string[]> {
const url = `${params.baseUrl.replace(/\/$/, "")}/models`;
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
method: "GET",
headers: {
...COPILOT_HEADERS_STATIC,
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()}`,
);
}
const body = (await response.json()) as CopilotModelsResponse;
const allModels = Array.isArray(body.data) ? body.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.
const models = allModels.filter(
(m) =>
m.supported_endpoints?.some((ep) => ep.includes("embeddings")) || /\bembedding/i.test(m.id),
);
return models.map((m) => m.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 sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
if (magnitude < 1e-10) {
return sanitized;
}
return sanitized.map((value) => value / magnitude);
}
// Note: the Copilot token is captured at creation time. Copilot tokens are
// short-lived (~30 min) so long-lived sessions may hit 401s. This matches
// how other embedding providers capture API keys at creation. A token
// refresh mechanism can be added if this becomes a practical issue.
async function createCopilotEmbeddingProvider(params: {
baseUrl: string;
copilotToken: string;
model: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<MemoryEmbeddingProvider> {
const embeddingsUrl = `${params.baseUrl.replace(/\/$/, "")}/embeddings`;
const headers: Record<string, string> = {
...COPILOT_HEADERS_STATIC,
Authorization: `Bearer ${params.copilotToken}`,
};
const embedBatch = async (texts: string[]): Promise<number[][]> => {
if (texts.length === 0) {
return [];
}
const { response, release } = await fetchWithSsrFGuard({
url: embeddingsUrl,
init: {
method: "POST",
headers,
body: JSON.stringify({ model: params.model, input: texts }),
},
policy: params.ssrfPolicy,
auditContext: "memory-remote",
});
try {
if (!response.ok) {
throw new Error(
`GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`,
);
}
const body = (await response.json()) as CopilotEmbeddingResponse;
if (!Array.isArray(body.data)) {
throw new Error("GitHub Copilot embeddings response missing data[]");
}
return body.data
.toSorted((a, b) => a.index - b.index)
.map((entry) => sanitizeAndNormalizeEmbedding(entry.embedding));
} finally {
await release();
}
};
return {
id: COPILOT_EMBEDDING_PROVIDER_ID,
model: params.model,
embedQuery: async (text: string) => {
const [result] = await embedBatch([text]);
if (!result) {
throw new Error("GitHub Copilot embeddings returned no vectors for query");
}
return result;
},
embedBatch,
};
}
export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: COPILOT_EMBEDDING_PROVIDER_ID,
transport: "remote",
autoSelectPriority: 15,
allowExplicitWhenConfiguredAuto: true,
shouldContinueAutoSelection: (err: unknown) => isCopilotSetupError(err),
create: async (options) => {
const { githubToken } = resolveFirstGithubToken({
agentDir: options.agentDir,
env: process.env,
});
if (!githubToken) {
throw new Error("No GitHub token available for Copilot embedding provider");
}
const { token: copilotToken, baseUrl: resolvedBaseUrl } = await resolveCopilotApiToken({
githubToken,
});
const baseUrl = 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,
ssrfPolicy,
});
const userModel = options.model?.trim() || undefined;
const model = pickBestModel(availableModels, userModel);
const provider = await createCopilotEmbeddingProvider({
baseUrl,
copilotToken,
model,
ssrfPolicy,
});
return {
provider,
runtime: {
id: COPILOT_EMBEDDING_PROVIDER_ID,
cacheKeyData: {
provider: COPILOT_EMBEDDING_PROVIDER_ID,
baseUrl,
model,
},
},
};
},
};

View File

@@ -36,6 +36,28 @@ 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 } });

View File

@@ -1,11 +1,9 @@
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 { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js";
import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.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",

View File

@@ -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"]
},