mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-21 13:22:30 +08:00
Merged via squash.
Prepared head SHA: 05a78ce7f2
Co-authored-by: feiskyer <676637+feiskyer@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
280 lines
9.2 KiB
TypeScript
280 lines
9.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
|
|
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
|
|
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
|
|
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
|
const createGitHubCopilotEmbeddingProviderMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("./auth.js", () => ({
|
|
resolveFirstGithubToken: resolveFirstGithubTokenMock,
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
|
|
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/github-copilot-token", () => ({
|
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
|
|
resolveCopilotApiToken: resolveCopilotApiTokenMock,
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-embeddings", () => ({
|
|
createGitHubCopilotEmbeddingProvider: createGitHubCopilotEmbeddingProviderMock,
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
|
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
|
}));
|
|
|
|
import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js";
|
|
|
|
const TEST_BASE_URL = "https://api.githubcopilot.test";
|
|
|
|
function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: unknown }>) {
|
|
return { data: models };
|
|
}
|
|
|
|
function mockDiscoveryResponse(spec: {
|
|
ok: boolean;
|
|
status?: number;
|
|
json?: unknown;
|
|
text?: string;
|
|
}) {
|
|
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
|
|
response: {
|
|
ok: spec.ok,
|
|
status: spec.status ?? (spec.ok ? 200 : 500),
|
|
json: async () => spec.json,
|
|
text: async () => spec.text ?? "",
|
|
},
|
|
release: vi.fn(async () => {}),
|
|
}));
|
|
}
|
|
|
|
function defaultCreateOptions() {
|
|
return {
|
|
config: {} as Record<string, unknown>,
|
|
agentDir: "/tmp/test-agent",
|
|
model: "",
|
|
};
|
|
}
|
|
|
|
describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
|
beforeEach(() => {
|
|
resolveConfiguredSecretInputStringMock.mockResolvedValue({});
|
|
resolveFirstGithubTokenMock.mockResolvedValue({
|
|
githubToken: "gh_test_token_123",
|
|
hasProfile: false,
|
|
});
|
|
resolveCopilotApiTokenMock.mockResolvedValue({
|
|
token: "copilot_test_token_abc",
|
|
expiresAt: Date.now() + 3_600_000,
|
|
source: "test",
|
|
baseUrl: TEST_BASE_URL,
|
|
});
|
|
createGitHubCopilotEmbeddingProviderMock.mockImplementation(async (client) => ({
|
|
provider: {
|
|
id: "github-copilot",
|
|
model: client.model,
|
|
embedQuery: async () => [0.1, 0.2, 0.3],
|
|
embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2, 0.3]),
|
|
},
|
|
client,
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
resolveConfiguredSecretInputStringMock.mockReset();
|
|
resolveFirstGithubTokenMock.mockReset();
|
|
resolveCopilotApiTokenMock.mockReset();
|
|
createGitHubCopilotEmbeddingProviderMock.mockReset();
|
|
fetchWithSsrFGuardMock.mockReset();
|
|
});
|
|
|
|
it("registers the expected adapter metadata", () => {
|
|
expect(githubCopilotMemoryEmbeddingProviderAdapter.id).toBe("github-copilot");
|
|
expect(githubCopilotMemoryEmbeddingProviderAdapter.transport).toBe("remote");
|
|
expect(githubCopilotMemoryEmbeddingProviderAdapter.autoSelectPriority).toBe(15);
|
|
expect(githubCopilotMemoryEmbeddingProviderAdapter.allowExplicitWhenConfiguredAuto).toBe(true);
|
|
});
|
|
|
|
it("picks text-embedding-3-small when available", async () => {
|
|
mockDiscoveryResponse({
|
|
ok: true,
|
|
json: buildModelsResponse([
|
|
{ id: "text-embedding-3-large", supported_endpoints: ["/v1/embeddings"] },
|
|
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
|
|
{ id: "gpt-4o", supported_endpoints: ["/v1/chat/completions"] },
|
|
]),
|
|
});
|
|
|
|
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
|
|
|
expect(result.provider?.model).toBe("text-embedding-3-small");
|
|
expect(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
baseUrl: TEST_BASE_URL,
|
|
githubToken: "gh_test_token_123",
|
|
model: "text-embedding-3-small",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("matches embedding-capable models when supported_endpoints is missing or malformed", async () => {
|
|
mockDiscoveryResponse({
|
|
ok: true,
|
|
json: buildModelsResponse([
|
|
{ id: "gpt-4o", supported_endpoints: { broken: true } },
|
|
{ id: "text-embedding-3-small", supported_endpoints: [] },
|
|
{ id: "text-embedding-ada-002" },
|
|
]),
|
|
});
|
|
|
|
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
|
|
|
expect(result.provider?.model).toBe("text-embedding-3-small");
|
|
});
|
|
|
|
it("strips the provider prefix from a user-selected model", async () => {
|
|
mockDiscoveryResponse({
|
|
ok: true,
|
|
json: buildModelsResponse([
|
|
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
|
|
]),
|
|
});
|
|
|
|
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create({
|
|
...defaultCreateOptions(),
|
|
model: "github-copilot/text-embedding-3-small",
|
|
} as never);
|
|
|
|
expect(result.provider?.model).toBe("text-embedding-3-small");
|
|
});
|
|
|
|
it("throws when the user-selected model is unavailable", async () => {
|
|
mockDiscoveryResponse({
|
|
ok: true,
|
|
json: buildModelsResponse([
|
|
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
|
|
]),
|
|
});
|
|
|
|
await expect(
|
|
githubCopilotMemoryEmbeddingProviderAdapter.create({
|
|
...defaultCreateOptions(),
|
|
model: "gpt-4o",
|
|
} as never),
|
|
).rejects.toThrow('GitHub Copilot embedding model "gpt-4o" is not available');
|
|
});
|
|
|
|
it("throws when discovery finds no embedding models", async () => {
|
|
mockDiscoveryResponse({
|
|
ok: true,
|
|
json: buildModelsResponse([{ id: "gpt-4o", supported_endpoints: ["/v1/chat/completions"] }]),
|
|
});
|
|
|
|
await expect(
|
|
githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
|
|
).rejects.toThrow("No embedding models available from GitHub Copilot");
|
|
});
|
|
|
|
it("wraps invalid discovery JSON as a setup error", async () => {
|
|
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
|
|
response: {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => {
|
|
throw new SyntaxError("bad json");
|
|
},
|
|
text: async () => "",
|
|
},
|
|
release: vi.fn(async () => {}),
|
|
}));
|
|
|
|
await expect(
|
|
githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
|
|
).rejects.toThrow("GitHub Copilot model discovery returned invalid JSON");
|
|
});
|
|
|
|
it("honors remote overrides when creating the provider", async () => {
|
|
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "gh_remote_token" });
|
|
mockDiscoveryResponse({
|
|
ok: true,
|
|
json: buildModelsResponse([
|
|
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
|
|
]),
|
|
});
|
|
|
|
await githubCopilotMemoryEmbeddingProviderAdapter.create({
|
|
...defaultCreateOptions(),
|
|
remote: {
|
|
apiKey: "ignored-at-runtime",
|
|
baseUrl: "https://proxy.example/v1",
|
|
headers: { "X-Proxy-Token": "proxy" },
|
|
},
|
|
} as never);
|
|
|
|
expect(resolveFirstGithubTokenMock).toHaveBeenCalled();
|
|
expect(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith({
|
|
baseUrl: "https://proxy.example/v1",
|
|
env: process.env,
|
|
fetchImpl: fetch,
|
|
githubToken: "gh_remote_token",
|
|
headers: { "X-Proxy-Token": "proxy" },
|
|
model: "text-embedding-3-small",
|
|
});
|
|
|
|
const discoveryCall = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as {
|
|
init: { headers: Record<string, string> };
|
|
url: string;
|
|
};
|
|
expect(discoveryCall.url).toBe("https://proxy.example/v1/models");
|
|
expect(discoveryCall.init.headers["X-Proxy-Token"]).toBe("proxy");
|
|
});
|
|
|
|
it("includes provider, baseUrl, and model in runtime cache data", async () => {
|
|
mockDiscoveryResponse({
|
|
ok: true,
|
|
json: buildModelsResponse([
|
|
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
|
|
]),
|
|
});
|
|
|
|
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
|
|
|
expect(result.runtime).toEqual({
|
|
id: "github-copilot",
|
|
cacheKeyData: {
|
|
provider: "github-copilot",
|
|
baseUrl: TEST_BASE_URL,
|
|
model: "text-embedding-3-small",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("treats token parsing and discovery failures as auto-fallback errors", () => {
|
|
expect(
|
|
githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(
|
|
new Error("Copilot token response missing token"),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(
|
|
new Error("Unexpected response from GitHub Copilot token endpoint"),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(
|
|
new Error("GitHub Copilot model discovery returned invalid JSON"),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!(
|
|
new Error("Network timeout"),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|