Skip to content

Commit 88d3620

Browse files
feiskyervincentkoc
andauthored
feat(github-copilot): add embedding provider for memory search (#61718)
Merged via squash. Prepared head SHA: 05a78ce Co-authored-by: feiskyer <676637+feiskyer@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc
1 parent 7821fae commit 88d3620

14 files changed

Lines changed: 1094 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212
- 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.
1313
- docs-i18n: add behavior baseline fixtures (#64073). Thanks @hxy91819
1414
- docs-i18n: harden behavior fixture path reads (#67046). Thanks @hxy91819
15+
- 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.
1516

1617
### Fixes
1718

docs/concepts/memory-search.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ chunks and searching them using embeddings, keywords, or both.
1515

1616
## Quick start
1717

18-
If you have an OpenAI, Gemini, Voyage, or Mistral API key configured, memory
19-
search works automatically. To set a provider explicitly:
18+
If you have a GitHub Copilot subscription, OpenAI, Gemini, Voyage, or Mistral
19+
API key configured, memory search works automatically. To set a provider
20+
explicitly:
2021

2122
```json5
2223
{
@@ -35,15 +36,16 @@ node-llama-cpp).
3536

3637
## Supported providers
3738

38-
| Provider | ID | Needs API key | Notes |
39-
| -------- | --------- | ------------- | ---------------------------------------------------- |
40-
| OpenAI | `openai` | Yes | Auto-detected, fast |
41-
| Gemini | `gemini` | Yes | Supports image/audio indexing |
42-
| Voyage | `voyage` | Yes | Auto-detected |
43-
| Mistral | `mistral` | Yes | Auto-detected |
44-
| Bedrock | `bedrock` | No | Auto-detected when the AWS credential chain resolves |
45-
| Ollama | `ollama` | No | Local, must set explicitly |
46-
| Local | `local` | No | GGUF model, ~0.6 GB download |
39+
| Provider | ID | Needs API key | Notes |
40+
| -------------- | ---------------- | ------------- | ---------------------------------------------------- |
41+
| Bedrock | `bedrock` | No | Auto-detected when the AWS credential chain resolves |
42+
| Gemini | `gemini` | Yes | Supports image/audio indexing |
43+
| GitHub Copilot | `github-copilot` | No | Auto-detected, uses Copilot subscription |
44+
| Local | `local` | No | GGUF model, ~0.6 GB download |
45+
| Mistral | `mistral` | Yes | Auto-detected |
46+
| Ollama | `ollama` | No | Local, must set explicitly |
47+
| OpenAI | `openai` | Yes | Auto-detected, fast |
48+
| Voyage | `voyage` | Yes | Auto-detected |
4749

4850
## How search works
4951

docs/providers/github-copilot.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,46 @@ Requires an interactive TTY. Run the login command directly in a terminal, not
119119
inside a headless script or CI job.
120120
</Warning>
121121

122+
## Memory search embeddings
123+
124+
GitHub Copilot can also serve as an embedding provider for
125+
[memory search](/concepts/memory-search). If you have a Copilot subscription and
126+
have logged in, OpenClaw can use it for embeddings without a separate API key.
127+
128+
### Auto-detection
129+
130+
When `memorySearch.provider` is `"auto"` (the default), GitHub Copilot is tried
131+
at priority 15 -- after local embeddings but before OpenAI and other paid
132+
providers. If a GitHub token is available, OpenClaw discovers available
133+
embedding models from the Copilot API and picks the best one automatically.
134+
135+
### Explicit config
136+
137+
```json5
138+
{
139+
agents: {
140+
defaults: {
141+
memorySearch: {
142+
provider: "github-copilot",
143+
// Optional: override the auto-discovered model
144+
model: "text-embedding-3-small",
145+
},
146+
},
147+
},
148+
}
149+
```
150+
151+
### How it works
152+
153+
1. OpenClaw resolves your GitHub token (from env vars or auth profile).
154+
2. Exchanges it for a short-lived Copilot API token.
155+
3. Queries the Copilot `/models` endpoint to discover available embedding models.
156+
4. Picks the best model (prefers `text-embedding-3-small`).
157+
5. Sends embedding requests to the Copilot `/embeddings` endpoint.
158+
159+
Model availability depends on your GitHub plan. If no embedding models are
160+
available, OpenClaw skips Copilot and tries the next provider.
161+
122162
## Related
123163

124164
<CardGroup cols={2}>

docs/reference/memory-config.md

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,23 +37,24 @@ plugin-owned config, transcript persistence, and safe rollout pattern.
3737

3838
## Provider selection
3939

40-
| Key | Type | Default | Description |
41-
| ---------- | --------- | ---------------- | ------------------------------------------------------------------------------------------- |
42-
| `provider` | `string` | auto-detected | Embedding adapter ID: `openai`, `gemini`, `voyage`, `mistral`, `bedrock`, `ollama`, `local` |
43-
| `model` | `string` | provider default | Embedding model name |
44-
| `fallback` | `string` | `"none"` | Fallback adapter ID when the primary fails |
45-
| `enabled` | `boolean` | `true` | Enable or disable memory search |
40+
| Key | Type | Default | Description |
41+
| ---------- | --------- | ---------------- | ------------------------------------------------------------------------------------------------------------- |
42+
| `provider` | `string` | auto-detected | Embedding adapter ID: `bedrock`, `gemini`, `github-copilot`, `local`, `mistral`, `ollama`, `openai`, `voyage` |
43+
| `model` | `string` | provider default | Embedding model name |
44+
| `fallback` | `string` | `"none"` | Fallback adapter ID when the primary fails |
45+
| `enabled` | `boolean` | `true` | Enable or disable memory search |
4646

4747
### Auto-detection order
4848

4949
When `provider` is not set, OpenClaw selects the first available:
5050

5151
1. `local` -- if `memorySearch.local.modelPath` is configured and the file exists.
52-
2. `openai` -- if an OpenAI key can be resolved.
53-
3. `gemini` -- if a Gemini key can be resolved.
54-
4. `voyage` -- if a Voyage key can be resolved.
55-
5. `mistral` -- if a Mistral key can be resolved.
56-
6. `bedrock` -- if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
52+
2. `github-copilot` -- if a GitHub Copilot token can be resolved (env var or auth profile).
53+
3. `openai` -- if an OpenAI key can be resolved.
54+
4. `gemini` -- if a Gemini key can be resolved.
55+
5. `voyage` -- if a Voyage key can be resolved.
56+
6. `mistral` -- if a Mistral key can be resolved.
57+
7. `bedrock` -- if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
5758

5859
`ollama` is supported but not auto-detected (set it explicitly).
5960

@@ -62,14 +63,15 @@ When `provider` is not set, OpenClaw selects the first available:
6263
Remote embeddings require an API key. Bedrock uses the AWS SDK default
6364
credential chain instead (instance roles, SSO, access keys).
6465

65-
| Provider | Env var | Config key |
66-
| -------- | ------------------------------ | --------------------------------- |
67-
| OpenAI | `OPENAI_API_KEY` | `models.providers.openai.apiKey` |
68-
| Gemini | `GEMINI_API_KEY` | `models.providers.google.apiKey` |
69-
| Voyage | `VOYAGE_API_KEY` | `models.providers.voyage.apiKey` |
70-
| Mistral | `MISTRAL_API_KEY` | `models.providers.mistral.apiKey` |
71-
| Bedrock | AWS credential chain | No API key needed |
72-
| Ollama | `OLLAMA_API_KEY` (placeholder) | -- |
66+
| Provider | Env var | Config key |
67+
| -------------- | -------------------------------------------------- | --------------------------------- |
68+
| Bedrock | AWS credential chain | No API key needed |
69+
| Gemini | `GEMINI_API_KEY` | `models.providers.google.apiKey` |
70+
| GitHub Copilot | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | Auth profile via device login |
71+
| Mistral | `MISTRAL_API_KEY` | `models.providers.mistral.apiKey` |
72+
| Ollama | `OLLAMA_API_KEY` (placeholder) | -- |
73+
| OpenAI | `OPENAI_API_KEY` | `models.providers.openai.apiKey` |
74+
| Voyage | `VOYAGE_API_KEY` | `models.providers.voyage.apiKey` |
7375

7476
Codex OAuth covers chat/completions only and does not satisfy embedding
7577
requests.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn());
4+
const listProfilesForProviderMock = vi.hoisted(() => vi.fn());
5+
const coerceSecretRefMock = vi.hoisted(() => vi.fn());
6+
const resolveRequiredConfiguredSecretRefInputStringMock = vi.hoisted(() => vi.fn());
7+
8+
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
9+
coerceSecretRef: coerceSecretRefMock,
10+
ensureAuthProfileStore: ensureAuthProfileStoreMock,
11+
listProfilesForProvider: listProfilesForProviderMock,
12+
}));
13+
14+
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
15+
resolveRequiredConfiguredSecretRefInputString: resolveRequiredConfiguredSecretRefInputStringMock,
16+
}));
17+
18+
import { resolveFirstGithubToken } from "./auth.js";
19+
20+
describe("resolveFirstGithubToken", () => {
21+
beforeEach(() => {
22+
ensureAuthProfileStoreMock.mockReturnValue({
23+
profiles: {
24+
"github-copilot:github": {
25+
type: "token",
26+
tokenRef: { source: "file", provider: "default", id: "/providers/github-copilot/token" },
27+
},
28+
},
29+
});
30+
listProfilesForProviderMock.mockReturnValue(["github-copilot:github"]);
31+
coerceSecretRefMock.mockReturnValue({
32+
source: "file",
33+
provider: "default",
34+
id: "/providers/github-copilot/token",
35+
});
36+
resolveRequiredConfiguredSecretRefInputStringMock.mockResolvedValue("resolved-profile-token");
37+
});
38+
39+
afterEach(() => {
40+
vi.restoreAllMocks();
41+
ensureAuthProfileStoreMock.mockReset();
42+
listProfilesForProviderMock.mockReset();
43+
coerceSecretRefMock.mockReset();
44+
resolveRequiredConfiguredSecretRefInputStringMock.mockReset();
45+
});
46+
47+
it("prefers env tokens when available", async () => {
48+
const result = await resolveFirstGithubToken({
49+
env: { GH_TOKEN: "env-token" } as NodeJS.ProcessEnv,
50+
});
51+
52+
expect(result).toEqual({
53+
githubToken: "env-token",
54+
hasProfile: true,
55+
});
56+
expect(resolveRequiredConfiguredSecretRefInputStringMock).not.toHaveBeenCalled();
57+
});
58+
59+
it("returns direct profile tokens before resolving SecretRefs", async () => {
60+
ensureAuthProfileStoreMock.mockReturnValue({
61+
profiles: {
62+
"github-copilot:github": {
63+
type: "token",
64+
token: "profile-token",
65+
},
66+
},
67+
});
68+
coerceSecretRefMock.mockReturnValue(null);
69+
70+
const result = await resolveFirstGithubToken({
71+
env: {} as NodeJS.ProcessEnv,
72+
});
73+
74+
expect(result).toEqual({
75+
githubToken: "profile-token",
76+
hasProfile: true,
77+
});
78+
});
79+
80+
it("resolves non-env SecretRefs when config is available", async () => {
81+
const result = await resolveFirstGithubToken({
82+
config: { secrets: { defaults: { provider: "default" } } } as never,
83+
env: {} as NodeJS.ProcessEnv,
84+
});
85+
86+
expect(result).toEqual({
87+
githubToken: "resolved-profile-token",
88+
hasProfile: true,
89+
});
90+
expect(resolveRequiredConfiguredSecretRefInputStringMock).toHaveBeenCalledWith(
91+
expect.objectContaining({
92+
path: "providers.github-copilot.authProfiles.github-copilot:github.tokenRef",
93+
}),
94+
);
95+
});
96+
});

extensions/github-copilot/auth.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
2+
import { resolveRequiredConfiguredSecretRefInputString } from "openclaw/plugin-sdk/config-runtime";
3+
import {
4+
coerceSecretRef,
5+
ensureAuthProfileStore,
6+
listProfilesForProvider,
7+
} from "openclaw/plugin-sdk/provider-auth";
8+
import { PROVIDER_ID } from "./models.js";
9+
10+
export async function resolveFirstGithubToken(params: {
11+
agentDir?: string;
12+
config?: OpenClawConfig;
13+
env: NodeJS.ProcessEnv;
14+
}): Promise<{
15+
githubToken: string;
16+
hasProfile: boolean;
17+
}> {
18+
const authStore = ensureAuthProfileStore(params.agentDir, {
19+
allowKeychainPrompt: false,
20+
});
21+
const profileIds = listProfilesForProvider(authStore, PROVIDER_ID);
22+
const hasProfile = profileIds.length > 0;
23+
const envToken =
24+
params.env.COPILOT_GITHUB_TOKEN ?? params.env.GH_TOKEN ?? params.env.GITHUB_TOKEN ?? "";
25+
const githubToken = envToken.trim();
26+
if (githubToken || !hasProfile) {
27+
return { githubToken, hasProfile };
28+
}
29+
30+
const profileId = profileIds[0];
31+
const profile = profileId ? authStore.profiles[profileId] : undefined;
32+
if (profile?.type !== "token") {
33+
return { githubToken: "", hasProfile };
34+
}
35+
const directToken = profile.token?.trim() ?? "";
36+
if (directToken) {
37+
return { githubToken: directToken, hasProfile };
38+
}
39+
const tokenRef = coerceSecretRef(profile.tokenRef);
40+
if (tokenRef?.source === "env" && tokenRef.id.trim()) {
41+
return {
42+
githubToken: (params.env[tokenRef.id] ?? process.env[tokenRef.id] ?? "").trim(),
43+
hasProfile,
44+
};
45+
}
46+
47+
if (tokenRef && params.config) {
48+
try {
49+
const resolved = await resolveRequiredConfiguredSecretRefInputString({
50+
config: params.config,
51+
env: params.env,
52+
value: profile.tokenRef,
53+
path: `providers.github-copilot.authProfiles.${profileId ?? "default"}.tokenRef`,
54+
});
55+
return {
56+
githubToken: resolved?.trim() ?? "",
57+
hasProfile,
58+
};
59+
} catch {
60+
return { githubToken: "", hasProfile };
61+
}
62+
}
63+
64+
return { githubToken: "", hasProfile };
65+
}

0 commit comments

Comments
 (0)