From c92ba975386a82c03d9ee5d54146619f5225c2ae Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 14 Aug 2026 04:41:27 +0000 Subject: [PATCH 1/3] fix(catalog): discovered Copilot endpoint for env tokens Shared the plan-endpoint probe between OAuth login and raw token model discovery so Business credentials route to their advertised API host. Added regression coverage for the raw environment-token path. Fixes #8507 --- .../ai/src/registry/oauth/github-copilot.ts | 23 +-------------- packages/catalog/CHANGELOG.md | 4 +++ .../src/provider-models/openai-compat.ts | 15 ++++++---- packages/catalog/src/wire/github-copilot.ts | 28 +++++++++++++++++++ .../test/github-copilot-model-limits.test.ts | 18 ++++++++---- 5 files changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/ai/src/registry/oauth/github-copilot.ts b/packages/ai/src/registry/oauth/github-copilot.ts index 0d95ee2c5..fa8c496e6 100644 --- a/packages/ai/src/registry/oauth/github-copilot.ts +++ b/packages/ai/src/registry/oauth/github-copilot.ts @@ -5,10 +5,10 @@ import { scheduler } from "node:timers/promises"; import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; import { COPILOT_API_HEADERS, + discoverGitHubCopilotApiEndpoint, getGitHubCopilotBaseUrl, isPublicGitHubHost, normalizeDomain, - normalizeGitHubCopilotApiEndpoint, normalizeGitHubCopilotEnterpriseDomain, OPENCODE_HEADERS, } from "@oh-my-pi/pi-catalog/wire/github-copilot"; @@ -226,27 +226,6 @@ export function refreshGitHubCopilotToken( }; } -async function discoverGitHubCopilotApiEndpoint(token: string, fetchImpl: FetchImpl): Promise { - try { - const data = await fetchJson( - "https://api.github.com/copilot_internal/user", - { - headers: { - Accept: "application/json", - Authorization: `token ${token}`, - ...OPENCODE_HEADERS, - }, - }, - fetchImpl, - ); - if (!data || typeof data !== "object") return undefined; - const endpoints = (data as { endpoints?: { api?: unknown } }).endpoints; - return typeof endpoints?.api === "string" ? normalizeGitHubCopilotApiEndpoint(endpoints.api) : undefined; - } catch { - return undefined; - } -} - /** * Enable a model for the user's GitHub Copilot account. * This is required for some models (like Claude, Grok) before they can be used. diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 8cde7ddb0..1e8e07bb6 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed raw `COPILOT_GITHUB_TOKEN` credentials skipping plan-specific endpoint discovery, which routed GitHub Copilot Business model requests to the personal endpoint and returned HTTP 403 ([#8507](https://github.com/can1357/oh-my-pi/issues/8507)). + ## [17.3.2] - 2026-08-13 ### Added diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 76ee02bd0..e5930337b 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -25,6 +25,7 @@ import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from ".. import { coreWeaveProjectHeaders } from "../wire/coreweave"; import { COPILOT_API_HEADERS, + discoverGitHubCopilotApiEndpoint, getGitHubCopilotBaseUrl, isPersonalGitHubCopilotBaseUrl, parseGitHubCopilotApiKey, @@ -5201,11 +5202,15 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana restorableHeaderFallback: { ...COPILOT_API_HEADERS }, ...(apiKey && { fetchDynamicModels: async () => { + const fetchImpl = discoveryFetch(config?.fetch); + const requestBaseUrl = isPersonalGitHubCopilotBaseUrl(baseUrl) + ? ((await discoverGitHubCopilotApiEndpoint(apiKey, fetchImpl)) ?? baseUrl) + : baseUrl; const longContextVariants: ModelSpec[] = []; const models = await fetchOpenAICompatibleModels({ api: "openai-completions", provider: "github-copilot", - baseUrl, + baseUrl: requestBaseUrl, apiKey, headers: COPILOT_API_HEADERS, mapModel: ( @@ -5251,7 +5256,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana const input: ModelSpec["input"] = supportsVision === true ? ["text", "image"] - : supportsVision === false || !isPersonalGitHubCopilotBaseUrl(baseUrl) + : supportsVision === false || !isPersonalGitHubCopilotBaseUrl(requestBaseUrl) ? ["text"] : (reference?.input ?? defaults.input); // With COPILOT_API_HEADERS the served window is the long-context @@ -5272,7 +5277,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana ...reference, api, provider: "github-copilot", - baseUrl, + baseUrl: requestBaseUrl, name, input, contextWindow: defaultTierWindow, @@ -5294,7 +5299,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana : { ...defaults, api, - baseUrl, + baseUrl: requestBaseUrl, name, input, contextWindow: defaultTierWindow, @@ -5340,7 +5345,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana } return base; }, - fetch: config?.fetch, + fetch: fetchImpl, }); if (models === null) { return null; diff --git a/packages/catalog/src/wire/github-copilot.ts b/packages/catalog/src/wire/github-copilot.ts index 8f0a03a3b..de0db263c 100644 --- a/packages/catalog/src/wire/github-copilot.ts +++ b/packages/catalog/src/wire/github-copilot.ts @@ -1,3 +1,6 @@ +import type { FetchImpl } from "../types"; +import { isRecord } from "../utils"; + /** * GitHub Copilot wire metadata: API-key envelope parsing and endpoint * derivation shared by catalog discovery and the pi-ai OAuth flow. The device @@ -72,6 +75,31 @@ export function normalizeGitHubCopilotApiEndpoint(input: string | undefined): st return undefined; } } +/** + * Resolve the plan-specific Copilot API endpoint advertised for a GitHub token. + * Login and raw environment-token discovery share this best-effort probe. + */ +export async function discoverGitHubCopilotApiEndpoint( + token: string, + fetchImpl: FetchImpl, +): Promise { + try { + const response = await fetchImpl("https://api.github.com/copilot_internal/user", { + headers: { + Accept: "application/json", + Authorization: `token ${token}`, + ...OPENCODE_HEADERS, + }, + }); + if (!response.ok) return undefined; + const data: unknown = await response.json(); + if (!isRecord(data) || !isRecord(data.endpoints)) return undefined; + const endpoint = data.endpoints.api; + return typeof endpoint === "string" ? normalizeGitHubCopilotApiEndpoint(endpoint) : undefined; + } catch { + return undefined; + } +} export function parseGitHubCopilotApiKey(apiKeyRaw: string): ParsedGitHubCopilotApiKey { try { diff --git a/packages/catalog/test/github-copilot-model-limits.test.ts b/packages/catalog/test/github-copilot-model-limits.test.ts index 9867b2c0a..8dc4ca545 100644 --- a/packages/catalog/test/github-copilot-model-limits.test.ts +++ b/packages/catalog/test/github-copilot-model-limits.test.ts @@ -42,6 +42,10 @@ async function discoverCopilotModels( const requestApiVersions: Array = []; 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(getHeaderValue(init?.headers, "Authorization")).toBe(`token ${expectedAuthorizationToken}`); + return Response.json({ endpoints: { api: expectedBaseUrl } }); + } expect(url).toBe(`${expectedBaseUrl}/models`); expect(init?.method).toBe("GET"); expect(getHeaderValue(init?.headers, "Authorization")).toBe(`Bearer ${expectedAuthorizationToken}`); @@ -72,13 +76,15 @@ function cachedCopilotCompletionModel(id: string, name: string): ModelSpec<"open } describe("github copilot model limits mapping", () => { - it("uses configured base URL for discovery", async () => { + it("discovers the plan endpoint for a raw environment token before model discovery", async () => { + const token = "ghu_valid_business_token"; const { fetchMock } = await discoverCopilotModels( { data: [] }, - "copilot-test-key", - "https://api.githubcopilot.com", + token, + "https://api.business.githubcopilot.com", + token, ); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); }); it("unwraps structured OAuth keys for discovery and routes enterprise discovery to the enterprise host", async () => { @@ -355,7 +361,7 @@ describe("github copilot model limits mapping", () => { const { models } = await manager.refresh("online-if-uncached"); const model = models.find(candidate => candidate.id === migration.id); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(model?.api).toBe("openai-responses"); } finally { await fs.rm(tempDir, { recursive: true, force: true }); @@ -390,7 +396,7 @@ describe("github copilot model limits mapping", () => { }); const { models } = await manager.refresh("online-if-uncached"); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); // The bundled catalog now ships a responses-route grok-4.5, so the id // resurfaces from the bundle after the failed refresh. The migration // contract is that the stale cached COMPLETIONS route never comes From ce65a40539eb4a3681417359d689957b00a5f3e7 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 14 Aug 2026 04:47:42 +0000 Subject: [PATCH 2/3] 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({ From c0394ba53d69aa47357e6e536e176507dc70f577 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 14 Aug 2026 05:12:06 +0000 Subject: [PATCH 3/3] fix(catalog): scope Copilot model cache by credential Copilot discovery writes an authoritative cache, so online-if-uncached served the prior endpoint for the full TTL after COPILOT_GITHUB_TOKEN switched accounts. Keying the cache namespace on the credential forces fresh discovery for a new token instead of reusing a stale personal-endpoint cache. Fixes #8507 --- packages/catalog/CHANGELOG.md | 2 +- .../src/provider-models/cache-provider-id.ts | 14 ++++++ .../src/provider-models/openai-compat.ts | 1 + .../test/github-copilot-model-limits.test.ts | 47 +++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 1e8e07bb6..86a00ef43 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed raw `COPILOT_GITHUB_TOKEN` credentials skipping plan-specific endpoint discovery, which routed GitHub Copilot Business model requests to the personal endpoint and returned HTTP 403 ([#8507](https://github.com/can1357/oh-my-pi/issues/8507)). +- Fixed raw `COPILOT_GITHUB_TOKEN` credentials skipping plan-specific endpoint discovery, which routed GitHub Copilot Business model requests to the personal endpoint and returned HTTP 403. The GitHub Copilot model cache is now scoped per credential, so switching the token no longer serves another account's stale endpoint for the cache TTL ([#8507](https://github.com/can1357/oh-my-pi/issues/8507)). ## [17.3.2] - 2026-08-13 diff --git a/packages/catalog/src/provider-models/cache-provider-id.ts b/packages/catalog/src/provider-models/cache-provider-id.ts index 4c7dc1948..3c5032e9c 100644 --- a/packages/catalog/src/provider-models/cache-provider-id.ts +++ b/packages/catalog/src/provider-models/cache-provider-id.ts @@ -1,3 +1,5 @@ +import { PERSONAL_GITHUB_COPILOT_BASE_URL } from "../wire/github-copilot"; + export interface ModelCacheProviderIdOptions { apiKey?: string; baseUrl?: string; @@ -56,6 +58,18 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa const scope = `${options.apiKey ?? ""}\u0000${discoveryBaseUrl}`; return `${providerId}:models-v1:${Bun.hash(scope).toString(36)}`; } + case "github-copilot": { + // Copilot model specs bake in the plan-specific endpoint (personal vs + // Business/Enterprise) resolved from the credential. Discovery writes an + // authoritative cache, so `online-if-uncached` serves it for the full + // TTL without re-probing. Keying the namespace on the credential means + // switching `COPILOT_GITHUB_TOKEN` to a different account misses the + // prior endpoint's cache and re-runs discovery instead of hitting the + // stale host and 403ing (PR #8510 review). + const baseUrl = options.baseUrl ?? PERSONAL_GITHUB_COPILOT_BASE_URL; + const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`; + return `github-copilot:models-v1:${Bun.hash(scope).toString(36)}`; + } case "openrouter": return "openrouter:pseudo-api"; case "vllm": { diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 1c7841f40..bf985c9a5 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -5193,6 +5193,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana const resolveReference = createReferenceResolver(getProviderReferences); return { providerId: "github-copilot", + cacheProviderId: resolveModelCacheProviderId("github-copilot", { apiKey: rawApiKey, baseUrl }), dropCachedModelIdsOnStaticMismatch: COPILOT_CACHE_INVALIDATED_MODEL_IDS, // COPILOT_API_HEADERS are compile-time constants (User-Agent + API // version), not credentials. The cache omits all request headers for diff --git a/packages/catalog/test/github-copilot-model-limits.test.ts b/packages/catalog/test/github-copilot-model-limits.test.ts index f985cc567..b3aff18a2 100644 --- a/packages/catalog/test/github-copilot-model-limits.test.ts +++ b/packages/catalog/test/github-copilot-model-limits.test.ts @@ -104,6 +104,53 @@ describe("github copilot model limits mapping", () => { expect(models).toEqual([]); expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("does not reuse another token's authoritative cache after COPILOT_GITHUB_TOKEN switches", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-copilot-token-switch-")); + const cacheDbPath = path.join(tempDir, "models.db"); + try { + const personalFetch = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://api.github.com/copilot_internal/user") { + return Response.json({ endpoints: { api: "https://api.githubcopilot.com" } }); + } + if (url === "https://api.githubcopilot.com/models") { + return Response.json({ data: [{ id: "gpt-5.5", name: "GPT-5.5" }] }); + } + throw new Error(`unexpected personal request: ${url}`); + }); + const personalManager = createModelManager({ + ...githubCopilotModelManagerOptions({ apiKey: "ghu_personal_token", fetch: personalFetch }), + cacheDbPath, + }); + // Personal token discovery writes a fresh authoritative cache. + await personalManager.refresh("online"); + + const businessSeen: string[] = []; + const businessFetch = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input.toString(); + businessSeen.push(url); + if (url === "https://api.github.com/copilot_internal/user") { + return Response.json({ endpoints: { api: "https://api.business.githubcopilot.com" } }); + } + if (url === "https://api.business.githubcopilot.com/models") { + return Response.json({ data: [{ id: "gpt-5.5", name: "GPT-5.5" }] }); + } + throw new Error(`unexpected business request: ${url}`); + }); + const businessManager = createModelManager({ + ...githubCopilotModelManagerOptions({ apiKey: "ghu_business_token", fetch: businessFetch }), + cacheDbPath, + }); + // Default online-if-uncached must not satisfy the switched token from the + // prior token's fresh authoritative personal-endpoint cache. + const { models } = await businessManager.refresh("online-if-uncached"); + expect(businessSeen).toContain("https://api.github.com/copilot_internal/user"); + expect(businessSeen).toContain("https://api.business.githubcopilot.com/models"); + expect(models.some(model => model.baseUrl === "https://api.business.githubcopilot.com")).toBe(true); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); it("unwraps structured OAuth keys for discovery and routes enterprise discovery to the enterprise host", async () => { const structuredApiKey = JSON.stringify({