fix(github-copilot): bound embedding error bodies

This commit is contained in:
Vincent Koc
2026-06-19 14:17:01 +02:00
parent 56c5630107
commit 4d37f42df7
2 changed files with 86 additions and 6 deletions

View File

@@ -47,6 +47,28 @@ function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: u
return { data: models };
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function mockDiscoveryResponse(spec: {
ok: boolean;
status?: number;
@@ -116,6 +138,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
resolveConfiguredSecretInputStringMock.mockReset();
resolveFirstGithubTokenMock.mockReset();
resolveCopilotApiTokenMock.mockReset();
@@ -221,6 +244,63 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
).rejects.toThrow("GitHub Copilot model discovery returned invalid JSON");
});
it("bounds model discovery error bodies", async () => {
const tracked = cancelTrackedResponse(`${"discovery denied ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
response: tracked.response,
release: vi.fn(async () => {}),
}));
let caught: Error | undefined;
try {
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("GitHub Copilot model discovery HTTP 503");
expect(caught?.message).toContain("discovery denied");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds embeddings error bodies", async () => {
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
});
const tracked = cancelTrackedResponse(`${"embedding denied ".repeat(1024)}tail`, {
status: 429,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchImpl = vi.fn(async () => tracked.response);
vi.stubGlobal("fetch", fetchImpl);
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
let caught: Error | undefined;
try {
await result.provider?.embedQuery("hello");
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("GitHub Copilot embeddings HTTP 429");
expect(caught?.message).toContain("embedding denied");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("honors remote overrides when creating the provider", async () => {
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "gh_remote_token" });
mockDiscoveryResponse({

View File

@@ -7,6 +7,7 @@ import {
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveFirstGithubToken } from "./auth.js";
@@ -27,6 +28,7 @@ const COPILOT_HEADERS_STATIC: Record<string, string> = {
"Content-Type": "application/json",
...buildCopilotIdeHeaders(),
};
const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
function buildSsrfPolicy(baseUrl: string): SsrFPolicy | undefined {
try {
@@ -95,9 +97,8 @@ async function discoverEmbeddingModels(params: {
});
try {
if (!response.ok) {
throw new Error(
`GitHub Copilot model discovery HTTP ${response.status}: ${await response.text()}`,
);
const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES);
throw new Error(`GitHub Copilot model discovery HTTP ${response.status}: ${detail}`);
}
let payload: unknown;
try {
@@ -241,9 +242,8 @@ async function createGitHubCopilotEmbeddingProvider(
},
onResponse: async (response) => {
if (!response.ok) {
throw new Error(
`GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`,
);
const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES);
throw new Error(`GitHub Copilot embeddings HTTP ${response.status}: ${detail}`);
}
let payload: unknown;