diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md index 944c006e118..dbaf05c20a1 100644 --- a/docs/concepts/memory-search.md +++ b/docs/concepts/memory-search.md @@ -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 diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index 81d30ebe74a..bc8656a7654 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -119,6 +119,46 @@ Requires an interactive TTY. Run the login command directly in a terminal, not inside a headless script or CI job. +## 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 diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index d7e47bfb890..7e7f1eb7b40 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -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. diff --git a/extensions/github-copilot/auth.ts b/extensions/github-copilot/auth.ts new file mode 100644 index 00000000000..45114802a8e --- /dev/null +++ b/extensions/github-copilot/auth.ts @@ -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 }; +} diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts new file mode 100644 index 00000000000..a48dda8b1fb --- /dev/null +++ b/extensions/github-copilot/embeddings.test.ts @@ -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, + 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", + }); + }); + }); +}); diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts new file mode 100644 index 00000000000..f271d85f032 --- /dev/null +++ b/extensions/github-copilot/embeddings.ts @@ -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 = { + "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 { + 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 { + const embeddingsUrl = `${params.baseUrl.replace(/\/$/, "")}/embeddings`; + const headers: Record = { + ...COPILOT_HEADERS_STATIC, + Authorization: `Bearer ${params.copilotToken}`, + }; + + const embedBatch = async (texts: string[]): Promise => { + 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, + }, + }, + }; + }, +}; diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index 0dd54da507b..cbbb2ca421c 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -36,6 +36,28 @@ function registerProviderWithPluginConfig(pluginConfig: Record) } 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 } }); diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 49b63912efc..aa4429659d2 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -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", diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index f970121545a..01f3f8b3e0b 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -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"] },