From 88d3620a85bff82a905dbb6ccdfd16c5ac5cf447 Mon Sep 17 00:00:00 2001 From: Pengfei Ni Date: Wed, 15 Apr 2026 17:39:28 +0800 Subject: [PATCH] feat(github-copilot): add embedding provider for memory search (#61718) Merged via squash. Prepared head SHA: 05a78ce7f215934157f899e0cfac40449ac95e0d Co-authored-by: feiskyer <676637+feiskyer@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc --- CHANGELOG.md | 1 + docs/concepts/memory-search.md | 24 +- docs/providers/github-copilot.md | 40 +++ docs/reference/memory-config.md | 40 +-- extensions/github-copilot/auth.test.ts | 96 ++++++ extensions/github-copilot/auth.ts | 65 ++++ extensions/github-copilot/embeddings.test.ts | 279 ++++++++++++++++++ extensions/github-copilot/embeddings.ts | 215 ++++++++++++++ extensions/github-copilot/index.test.ts | 21 ++ extensions/github-copilot/index.ts | 46 +-- .../github-copilot/openclaw.plugin.json | 3 + src/memory-host-sdk/engine-embeddings.ts | 4 + .../host/embeddings-github-copilot.test.ts | 178 +++++++++++ .../host/embeddings-github-copilot.ts | 151 ++++++++++ 14 files changed, 1094 insertions(+), 69 deletions(-) create mode 100644 extensions/github-copilot/auth.test.ts create mode 100644 extensions/github-copilot/auth.ts create mode 100644 extensions/github-copilot/embeddings.test.ts create mode 100644 extensions/github-copilot/embeddings.ts create mode 100644 src/memory-host-sdk/host/embeddings-github-copilot.test.ts create mode 100644 src/memory-host-sdk/host/embeddings-github-copilot.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d81ac302f2d..d3865e9b4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine. - docs-i18n: add behavior baseline fixtures (#64073). Thanks @hxy91819 - docs-i18n: harden behavior fixture path reads (#67046). Thanks @hxy91819 +- GitHub Copilot/memory search: add a GitHub Copilot embedding provider for memory search, and expose a dedicated Copilot embedding host helper so plugins can reuse the transport while honoring remote overrides, token refresh, and safer payload validation. (#61718) Thanks @feiskyer and @vincentkoc. ### Fixes diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md index 944c006e118..ff444e6cbd8 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 | +| -------------- | ---------------- | ------------- | ---------------------------------------------------- | +| Bedrock | `bedrock` | No | Auto-detected when the AWS credential chain resolves | +| Gemini | `gemini` | Yes | Supports image/audio indexing | +| GitHub Copilot | `github-copilot` | No | Auto-detected, uses Copilot subscription | +| Local | `local` | No | GGUF model, ~0.6 GB download | +| Mistral | `mistral` | Yes | Auto-detected | +| Ollama | `ollama` | No | Local, must set explicitly | +| OpenAI | `openai` | Yes | Auto-detected, fast | +| Voyage | `voyage` | Yes | Auto-detected | ## 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..2cae17b9ed0 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: `bedrock`, `gemini`, `github-copilot`, `local`, `mistral`, `ollama`, `openai`, `voyage` | +| `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 | +| -------------- | -------------------------------------------------- | --------------------------------- | +| Bedrock | AWS credential chain | No API key needed | +| Gemini | `GEMINI_API_KEY` | `models.providers.google.apiKey` | +| GitHub Copilot | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | Auth profile via device login | +| Mistral | `MISTRAL_API_KEY` | `models.providers.mistral.apiKey` | +| Ollama | `OLLAMA_API_KEY` (placeholder) | -- | +| OpenAI | `OPENAI_API_KEY` | `models.providers.openai.apiKey` | +| Voyage | `VOYAGE_API_KEY` | `models.providers.voyage.apiKey` | Codex OAuth covers chat/completions only and does not satisfy embedding requests. diff --git a/extensions/github-copilot/auth.test.ts b/extensions/github-copilot/auth.test.ts new file mode 100644 index 00000000000..74dd3d759e8 --- /dev/null +++ b/extensions/github-copilot/auth.test.ts @@ -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", + }), + ); + }); +}); diff --git a/extensions/github-copilot/auth.ts b/extensions/github-copilot/auth.ts new file mode 100644 index 00000000000..1e524f8048d --- /dev/null +++ b/extensions/github-copilot/auth.ts @@ -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 }; +} diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts new file mode 100644 index 00000000000..73a656d564c --- /dev/null +++ b/extensions/github-copilot/embeddings.test.ts @@ -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, + 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 }; + 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); + }); +}); diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts new file mode 100644 index 00000000000..d06c8a06942 --- /dev/null +++ b/extensions/github-copilot/embeddings.ts @@ -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 = { + "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; + ssrfPolicy?: SsrFPolicy; +}): Promise { + 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, + }, + }, + }; + }, +}; diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index 0dd54da507b..3f68d8748a7 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -36,6 +36,27 @@ 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..64d97b7f8c6 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -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) { 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"] }, diff --git a/src/memory-host-sdk/engine-embeddings.ts b/src/memory-host-sdk/engine-embeddings.ts index eef160b2f63..ad794fe4f1d 100644 --- a/src/memory-host-sdk/engine-embeddings.ts +++ b/src/memory-host-sdk/engine-embeddings.ts @@ -30,6 +30,10 @@ export { createMistralEmbeddingProvider, DEFAULT_MISTRAL_EMBEDDING_MODEL, } from "./host/embeddings-mistral.js"; +export { + createGitHubCopilotEmbeddingProvider, + type GitHubCopilotEmbeddingClient, +} from "./host/embeddings-github-copilot.js"; export { createOllamaEmbeddingProvider, DEFAULT_OLLAMA_EMBEDDING_MODEL, diff --git a/src/memory-host-sdk/host/embeddings-github-copilot.test.ts b/src/memory-host-sdk/host/embeddings-github-copilot.test.ts new file mode 100644 index 00000000000..0f3904b1555 --- /dev/null +++ b/src/memory-host-sdk/host/embeddings-github-copilot.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn()); +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../agents/github-copilot-token.js", () => ({ + DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test", + resolveCopilotApiToken: resolveCopilotApiTokenMock, +})); + +vi.mock("../../infra/net/fetch-guard.js", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +import { createGitHubCopilotEmbeddingProvider } from "./embeddings-github-copilot.js"; + +function mockFetchResponse(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 () => {}), + })); +} + +describe("createGitHubCopilotEmbeddingProvider", () => { + beforeEach(() => { + resolveCopilotApiTokenMock.mockResolvedValue({ + token: "copilot-token-a", + expiresAt: Date.now() + 3_600_000, + source: "test", + baseUrl: "https://api.githubcopilot.test", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resolveCopilotApiTokenMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + }); + + it("normalizes embeddings returned for queries", async () => { + mockFetchResponse({ + ok: true, + json: { + data: [{ index: 0, embedding: [3, 4] }], + }, + }); + + const { provider } = await createGitHubCopilotEmbeddingProvider({ + githubToken: "gh_test", + model: "text-embedding-3-small", + }); + + await expect(provider.embedQuery("hello")).resolves.toEqual([0.6, 0.8]); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.githubcopilot.test/embeddings", + }), + ); + }); + + it("preserves input order by explicit response index", async () => { + mockFetchResponse({ + ok: true, + json: { + data: [ + { index: 1, embedding: [0, 2] }, + { index: 0, embedding: [1, 0] }, + ], + }, + }); + + const { provider } = await createGitHubCopilotEmbeddingProvider({ + githubToken: "gh_test", + model: "text-embedding-3-small", + }); + + await expect(provider.embedBatch(["first", "second"])).resolves.toEqual([ + [1, 0], + [0, 1], + ]); + }); + + it("uses a fresh Copilot token for later requests", async () => { + resolveCopilotApiTokenMock + .mockResolvedValueOnce({ + token: "copilot-token-create", + expiresAt: Date.now() + 3_600_000, + source: "test", + baseUrl: "https://api.githubcopilot.test", + }) + .mockResolvedValueOnce({ + token: "copilot-token-first", + expiresAt: Date.now() + 3_600_000, + source: "test", + baseUrl: "https://api.githubcopilot.test", + }) + .mockResolvedValueOnce({ + token: "copilot-token-second", + expiresAt: Date.now() + 3_600_000, + source: "test", + baseUrl: "https://api.githubcopilot.test", + }); + mockFetchResponse({ + ok: true, + json: { data: [{ index: 0, embedding: [1, 0] }] }, + }); + mockFetchResponse({ + ok: true, + json: { data: [{ index: 0, embedding: [0, 1] }] }, + }); + + const { provider } = await createGitHubCopilotEmbeddingProvider({ + githubToken: "gh_test", + model: "text-embedding-3-small", + }); + + await provider.embedQuery("first"); + await provider.embedQuery("second"); + + const firstHeaders = fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.init?.headers as Record< + string, + string + >; + const secondHeaders = fetchWithSsrFGuardMock.mock.calls[1]?.[0]?.init?.headers as Record< + string, + string + >; + expect(firstHeaders.Authorization).toBe("Bearer copilot-token-first"); + expect(secondHeaders.Authorization).toBe("Bearer copilot-token-second"); + }); + + it("honors custom baseUrl and header overrides", async () => { + mockFetchResponse({ + ok: true, + json: { data: [{ index: 0, embedding: [1, 0] }] }, + }); + + const { provider } = await createGitHubCopilotEmbeddingProvider({ + githubToken: "gh_test", + model: "text-embedding-3-small", + baseUrl: "https://proxy.example/v1", + headers: { "X-Proxy-Token": "proxy" }, + }); + + await provider.embedQuery("hello"); + + const call = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as { + init: { headers: Record }; + url: string; + }; + expect(call.url).toBe("https://proxy.example/v1/embeddings"); + expect(call.init.headers["X-Proxy-Token"]).toBe("proxy"); + expect(call.init.headers.Authorization).toBe("Bearer copilot-token-a"); + }); + + it("fails fast on sparse or malformed embedding payloads", async () => { + mockFetchResponse({ + ok: true, + json: { + data: [{ index: 1, embedding: [1, 0] }], + }, + }); + + const { provider } = await createGitHubCopilotEmbeddingProvider({ + githubToken: "gh_test", + model: "text-embedding-3-small", + }); + + await expect(provider.embedBatch(["first", "second"])).rejects.toThrow( + "GitHub Copilot embeddings response missing vectors for some inputs", + ); + }); +}); diff --git a/src/memory-host-sdk/host/embeddings-github-copilot.ts b/src/memory-host-sdk/host/embeddings-github-copilot.ts new file mode 100644 index 00000000000..246b764abb9 --- /dev/null +++ b/src/memory-host-sdk/host/embeddings-github-copilot.ts @@ -0,0 +1,151 @@ +import { + DEFAULT_COPILOT_API_BASE_URL, + resolveCopilotApiToken, +} from "../../agents/github-copilot-token.js"; +import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js"; +import type { EmbeddingProvider } from "./embeddings.types.js"; +import { buildRemoteBaseUrlPolicy, withRemoteHttpResponse } from "./remote-http.js"; + +export type GitHubCopilotEmbeddingClient = { + githubToken: string; + model: string; + baseUrl?: string; + headers?: Record; + env?: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; +}; + +const COPILOT_EMBEDDING_PROVIDER_ID = "github-copilot"; + +const COPILOT_HEADERS_STATIC: Record = { + "Content-Type": "application/json", + "Editor-Version": "vscode/1.96.2", + "User-Agent": "GitHubCopilotChat/0.26.7", +}; + +function resolveConfiguredBaseUrl( + configuredBaseUrl: string | undefined, + tokenBaseUrl: string | undefined, +): string { + const trimmed = configuredBaseUrl?.trim(); + if (trimmed) { + return trimmed; + } + return tokenBaseUrl || DEFAULT_COPILOT_API_BASE_URL; +} + +async function resolveGitHubCopilotEmbeddingSession(client: GitHubCopilotEmbeddingClient): Promise<{ + baseUrl: string; + headers: Record; +}> { + const token = await resolveCopilotApiToken({ + githubToken: client.githubToken, + env: client.env, + fetchImpl: client.fetchImpl, + }); + const baseUrl = resolveConfiguredBaseUrl(client.baseUrl, token.baseUrl); + return { + baseUrl, + headers: { + ...COPILOT_HEADERS_STATIC, + ...client.headers, + Authorization: `Bearer ${token.token}`, + }, + }; +} + +function parseGitHubCopilotEmbeddingPayload(payload: unknown, expectedCount: number): number[][] { + if (!payload || typeof payload !== "object") { + throw new Error("GitHub Copilot embeddings response missing data[]"); + } + const data = (payload as { data?: unknown }).data; + if (!Array.isArray(data)) { + throw new Error("GitHub Copilot embeddings response missing data[]"); + } + + const vectors = Array.from({ length: expectedCount }); + for (const entry of data) { + if (!entry || typeof entry !== "object") { + throw new Error("GitHub Copilot embeddings response contains an invalid entry"); + } + const indexValue = (entry as { index?: unknown }).index; + const embedding = (entry as { embedding?: unknown }).embedding; + const index = typeof indexValue === "number" ? indexValue : Number.NaN; + if (!Number.isInteger(index)) { + throw new Error("GitHub Copilot embeddings response contains an invalid index"); + } + if (index < 0 || index >= expectedCount) { + throw new Error("GitHub Copilot embeddings response contains an out-of-range index"); + } + if (vectors[index] !== undefined) { + throw new Error("GitHub Copilot embeddings response contains duplicate indexes"); + } + if (!Array.isArray(embedding) || !embedding.every((value) => typeof value === "number")) { + throw new Error("GitHub Copilot embeddings response contains an invalid embedding"); + } + vectors[index] = sanitizeAndNormalizeEmbedding(embedding); + } + + for (let index = 0; index < expectedCount; index += 1) { + if (vectors[index] === undefined) { + throw new Error("GitHub Copilot embeddings response missing vectors for some inputs"); + } + } + return vectors as number[][]; +} + +export async function createGitHubCopilotEmbeddingProvider( + client: GitHubCopilotEmbeddingClient, +): Promise<{ provider: EmbeddingProvider; client: GitHubCopilotEmbeddingClient }> { + const initialSession = await resolveGitHubCopilotEmbeddingSession(client); + + const embed = async (input: string[]): Promise => { + if (input.length === 0) { + return []; + } + + const session = await resolveGitHubCopilotEmbeddingSession(client); + const url = `${session.baseUrl.replace(/\/$/, "")}/embeddings`; + return await withRemoteHttpResponse({ + url, + fetchImpl: client.fetchImpl, + ssrfPolicy: buildRemoteBaseUrlPolicy(session.baseUrl), + init: { + method: "POST", + headers: session.headers, + body: JSON.stringify({ model: client.model, input }), + }, + onResponse: async (response) => { + if (!response.ok) { + throw new Error( + `GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`, + ); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error("GitHub Copilot embeddings returned invalid JSON"); + } + return parseGitHubCopilotEmbeddingPayload(payload, input.length); + }, + }); + }; + + return { + provider: { + id: COPILOT_EMBEDDING_PROVIDER_ID, + model: client.model, + embedQuery: async (text) => { + const [vector] = await embed([text]); + return vector ?? []; + }, + embedBatch: embed, + }, + client: { + ...client, + baseUrl: initialSession.baseUrl, + }, + }; +}