From ce65a40539eb4a3681417359d689957b00a5f3e7 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 14 Aug 2026 04:47:42 +0000 Subject: [PATCH] fix(catalog): bound Copilot endpoint probe with discovery timeout Threaded the shared 10s discovery AbortSignal into the copilot_internal/user probe so a stalled endpoint falls back to the personal host instead of hanging startup or refresh. Fixes #8507 --- .../src/provider-models/openai-compat.ts | 5 ++++- packages/catalog/src/wire/github-copilot.ts | 6 +++++- .../test/github-copilot-model-limits.test.ts | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index e5930337b..1c7841f40 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -1,6 +1,7 @@ import { USER_AGENT } from "@oh-my-pi/pi-utils"; import * as logger from "@oh-my-pi/pi-utils/logger"; import { + DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, fetchOpenAICompatibleModels, type OpenAICompatibleModelMapperContext, type OpenAICompatibleModelRecord, @@ -5204,7 +5205,9 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana fetchDynamicModels: async () => { const fetchImpl = discoveryFetch(config?.fetch); const requestBaseUrl = isPersonalGitHubCopilotBaseUrl(baseUrl) - ? ((await discoverGitHubCopilotApiEndpoint(apiKey, fetchImpl)) ?? baseUrl) + ? ((await withCatalogDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, signal => + discoverGitHubCopilotApiEndpoint(apiKey, fetchImpl, signal), + )) ?? baseUrl) : baseUrl; const longContextVariants: ModelSpec[] = []; const models = await fetchOpenAICompatibleModels({ diff --git a/packages/catalog/src/wire/github-copilot.ts b/packages/catalog/src/wire/github-copilot.ts index de0db263c..d2d25d935 100644 --- a/packages/catalog/src/wire/github-copilot.ts +++ b/packages/catalog/src/wire/github-copilot.ts @@ -77,11 +77,14 @@ export function normalizeGitHubCopilotApiEndpoint(input: string | undefined): st } /** * Resolve the plan-specific Copilot API endpoint advertised for a GitHub token. - * Login and raw environment-token discovery share this best-effort probe. + * Login and raw environment-token discovery share this best-effort probe. Pass + * a `signal` to bound it against the same discovery deadline as `/models`; a + * stalled probe otherwise blocks discovery indefinitely. */ export async function discoverGitHubCopilotApiEndpoint( token: string, fetchImpl: FetchImpl, + signal?: AbortSignal, ): Promise { try { const response = await fetchImpl("https://api.github.com/copilot_internal/user", { @@ -90,6 +93,7 @@ export async function discoverGitHubCopilotApiEndpoint( Authorization: `token ${token}`, ...OPENCODE_HEADERS, }, + signal, }); if (!response.ok) return undefined; const data: unknown = await response.json(); diff --git a/packages/catalog/test/github-copilot-model-limits.test.ts b/packages/catalog/test/github-copilot-model-limits.test.ts index 8dc4ca545..f985cc567 100644 --- a/packages/catalog/test/github-copilot-model-limits.test.ts +++ b/packages/catalog/test/github-copilot-model-limits.test.ts @@ -44,6 +44,9 @@ async function discoverCopilotModels( const url = typeof input === "string" ? input : input.toString(); if (url === "https://api.github.com/copilot_internal/user") { expect(getHeaderValue(init?.headers, "Authorization")).toBe(`token ${expectedAuthorizationToken}`); + // The probe must be bounded by the shared discovery deadline so a + // stalled endpoint cannot hang discovery (PR #8510 review). + expect(init?.signal).toBeInstanceOf(AbortSignal); return Response.json({ endpoints: { api: expectedBaseUrl } }); } expect(url).toBe(`${expectedBaseUrl}/models`); @@ -86,6 +89,21 @@ describe("github copilot model limits mapping", () => { ); expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("falls back to the personal endpoint when the raw-token probe fails", async () => { + const token = "ghu_valid_business_token"; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://api.github.com/copilot_internal/user") { + expect(init?.signal).toBeInstanceOf(AbortSignal); + throw new DOMException("The operation timed out.", "TimeoutError"); + } + expect(url).toBe("https://api.githubcopilot.com/models"); + return Response.json({ data: [] }); + }); + const models = await githubCopilotModelManagerOptions({ apiKey: token, fetch: fetchMock }).fetchDynamicModels?.(); + expect(models).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); it("unwraps structured OAuth keys for discovery and routes enterprise discovery to the enterprise host", async () => { const structuredApiKey = JSON.stringify({