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
This commit is contained in:
roboomp
2026-08-14 04:47:42 +00:00
parent c92ba97538
commit ce65a40539
3 changed files with 27 additions and 2 deletions
@@ -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<Api>[] = [];
const models = await fetchOpenAICompatibleModels<Api>({
+5 -1
View File
@@ -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<string | undefined> {
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();
@@ -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({