From 0909b31f50afce90ca5efc4c3910ab96ab4ba049 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:57:55 -0300 Subject: [PATCH] fix(github-copilot): discover model catalog live from api.githubcopilot.com (#3120, #3121) (#3164) The github (Copilot) provider had a static hardcoded catalog with no discovery source, so Import Models never refreshed (#3120) and advertised non-entitled models that 400 on use (#3121). Add a live /models fetch with fallback to the static list. Co-authored-by: gabrielmoreira --- open-sse/services/githubCopilotModels.ts | 134 ++++++++++++++++ src/app/api/providers/[id]/models/route.ts | 51 +++++++ .../github-copilot-model-discovery.test.ts | 143 ++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 open-sse/services/githubCopilotModels.ts create mode 100644 tests/unit/github-copilot-model-discovery.test.ts diff --git a/open-sse/services/githubCopilotModels.ts b/open-sse/services/githubCopilotModels.ts new file mode 100644 index 0000000000..b24a57c3e8 --- /dev/null +++ b/open-sse/services/githubCopilotModels.ts @@ -0,0 +1,134 @@ +/** + * GitHub Copilot live model discovery (#3120, #3121). + * + * The `github` (Copilot) provider previously shipped a STATIC hardcoded model + * catalog in `providerRegistry.ts` and had no discovery source, so "Import + * Models" could never refresh the list (#3120) and advertised models the + * account is not entitled to (e.g. gemini previews), which fail upstream with + * `400 ... not supported` when tested (#3121). + * + * Copilot exposes its per-account catalog at `https://api.githubcopilot.com/models`, + * authenticated with the Copilot bearer token + the standard Copilot chat + * headers. The response shape is `{ data: [{ id, name, model_picker_enabled, + * policy, capabilities, ... }] }`. We map `data[].id` into managed models. Only + * entitled models appear in the live response, so parsing it directly gives the + * entitlement filtering #3121 needs. + * + * A safe fallback to the existing static catalog is preserved for + * offline/unauthed/failed refresh so the import flow never breaks. + */ +import { getGitHubCopilotChatHeaders } from "../config/providerHeaderProfiles.ts"; + +export const GITHUB_COPILOT_MODELS_URL = "https://api.githubcopilot.com/models"; + +export type GitHubCopilotModel = { + id: string; + name: string; + owned_by: string; +}; + +type RawRecord = Record; + +function asRecord(value: unknown): RawRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Parse a Copilot `/models` response into managed model rows. Only ids present + * in the live response are returned, which is exactly the entitlement filter + * #3121 requires. + */ +export function parseGitHubCopilotModels(data: unknown): GitHubCopilotModel[] { + const payload = asRecord(data); + const items = Array.isArray(payload.data) + ? (payload.data as unknown[]) + : Array.isArray(payload.models) + ? (payload.models as unknown[]) + : []; + + const seen = new Set(); + const models: GitHubCopilotModel[] = []; + + for (const value of items) { + const item = asRecord(value); + const id = toNonEmptyString(item.id) || toNonEmptyString(item.model); + if (!id || seen.has(id)) continue; + seen.add(id); + const name = toNonEmptyString(item.name) || toNonEmptyString(item.display_name) || id; + models.push({ id, name, owned_by: "github" }); + } + + return models; +} + +export type FetchGitHubCopilotModelsOptions = { + /** Copilot bearer token (copilotToken; falls back to GitHub accessToken upstream). */ + token: string | null | undefined; + /** Injectable fetch (defaults to global fetch). */ + fetchImpl?: typeof fetch; + /** Static catalog to fall back to when live discovery is unavailable. */ + fallbackModels?: Array<{ id: string; name?: string }>; +}; + +export type GitHubCopilotModelsResult = { + models: GitHubCopilotModel[]; + /** "api" = live discovery; "fallback" = static catalog (offline/unauthed/error). */ + source: "api" | "fallback"; +}; + +function toFallbackResult( + fallbackModels: Array<{ id: string; name?: string }> | undefined +): GitHubCopilotModelsResult { + const models = (fallbackModels || []) + .map((model) => { + const id = toNonEmptyString(model.id); + if (!id) return null; + return { id, name: toNonEmptyString(model.name) || id, owned_by: "github" }; + }) + .filter((model): model is GitHubCopilotModel => Boolean(model)); + return { models, source: "fallback" }; +} + +/** + * Discover the Copilot model catalog live, falling back to the static catalog + * when no token is available or the upstream request fails. + */ +export async function fetchGitHubCopilotModels( + options: FetchGitHubCopilotModelsOptions +): Promise { + const { token, fetchImpl = fetch, fallbackModels } = options; + + if (!toNonEmptyString(token)) { + return toFallbackResult(fallbackModels); + } + + try { + const response = await fetchImpl(GITHUB_COPILOT_MODELS_URL, { + method: "GET", + headers: { + ...getGitHubCopilotChatHeaders("application/json"), + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return toFallbackResult(fallbackModels); + } + + const data = await response.json(); + const models = parseGitHubCopilotModels(data); + if (models.length === 0) { + return toFallbackResult(fallbackModels); + } + return { models, source: "api" }; + } catch { + // Network/parse failure — never break the import flow. + return toFallbackResult(fallbackModels); + } +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 5b893926aa..83d7a77f31 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -23,6 +23,7 @@ import { import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts"; +import { fetchGitHubCopilotModels } from "@omniroute/open-sse/services/githubCopilotModels.ts"; import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { ensureAntigravityProjectAssigned } from "@omniroute/open-sse/services/antigravityProjectBootstrap.ts"; import { @@ -1926,6 +1927,56 @@ export async function GET( }); } + if (provider === "github") { + // #3120/#3121 — GitHub Copilot's catalog is per-account and dynamic. The + // registry static list never refreshes and advertises non-entitled models + // (e.g. gemini previews) that fail upstream when tested. Discover the live + // catalog from api.githubcopilot.com/models with the Copilot bearer + + // Copilot chat headers; fall back to the static registry catalog when the + // live fetch is unavailable (offline/unauthed/error) so import never breaks. + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + + const psd = asRecord(connection.providerSpecificData); + // The /models endpoint requires the short-lived Copilot token (same as the + // chat executor), not the raw GitHub OAuth access token. + const copilotToken = + toNonEmptyString(psd.copilotToken) || toNonEmptyString(accessToken) || null; + + const discovery = await fetchGitHubCopilotModels({ + token: copilotToken, + fetchImpl: (url, init) => + safeOutboundFetch(url as string, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + ...(init as Record), + }), + fallbackModels: toLocalCatalogModels(), + }); + + if (discovery.source === "api") { + return buildApiDiscoveryResponse(discovery.models); + } + + // Live discovery unavailable — preserve cached/static catalog behavior. + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "Copilot models API unavailable — using cached catalog", + localWarning: "Copilot models API unavailable — using local catalog", + }); + if (fallback) return fallback; + return buildResponse({ + provider, + connectionId, + models: discovery.models, + source: "local_catalog", + warning: "Copilot models API unavailable — using local catalog", + }); + } + if (isAnthropicCompatibleProvider(provider)) { const cachedResponse = maybeReturnCachedDiscovery(); if (cachedResponse) return cachedResponse; diff --git a/tests/unit/github-copilot-model-discovery.test.ts b/tests/unit/github-copilot-model-discovery.test.ts new file mode 100644 index 0000000000..d925631dda --- /dev/null +++ b/tests/unit/github-copilot-model-discovery.test.ts @@ -0,0 +1,143 @@ +/** + * Issues #3120 / #3121 — GitHub Copilot model discovery (thanks @gabrielmoreira). + * + * #3120: "Import Models" never refreshes the Copilot model list because the + * `github` (Copilot) provider had a STATIC hardcoded catalog and no live + * discovery source. + * #3121: That static catalog advertised models (e.g. gemini previews) that the + * account is not entitled to, so testing them returned upstream 400s. + * + * Fix: discover the catalog live from https://api.githubcopilot.com/models using + * the Copilot bearer + Copilot chat headers, parse `data[].id` into managed + * models, and fall back to the static catalog only when the live fetch fails. + * + * These tests target the discovery helper directly (injected fetch) so they need + * no HTTP server or DB. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + GITHUB_COPILOT_MODELS_URL, + parseGitHubCopilotModels, + fetchGitHubCopilotModels, +} = await import("../../open-sse/services/githubCopilotModels.ts"); + +// A representative slice of a real Copilot /models response. Crucially it +// includes models the account IS entitled to, and OMITS gemini previews to +// prove non-entitled models are not advertised (#3121). +const MOCK_COPILOT_MODELS_RESPONSE = { + data: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + model_picker_enabled: true, + policy: { state: "enabled" }, + capabilities: { type: "chat", limits: { max_context_window_tokens: 128000 } }, + }, + { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + model_picker_enabled: true, + capabilities: { type: "chat" }, + }, + { + // embeddings model — not a chat model; discovery should still surface the id + id: "text-embedding-3-small", + name: "Embedding V3 small", + capabilities: { type: "embeddings" }, + }, + ], +}; + +test("#3120 parseGitHubCopilotModels maps data[].id into managed models", () => { + const models = parseGitHubCopilotModels(MOCK_COPILOT_MODELS_RESPONSE); + const ids = models.map((m) => m.id); + assert.ok(ids.includes("gpt-5.4"), "gpt-5.4 must be discovered"); + assert.ok(ids.includes("claude-sonnet-4.5"), "claude-sonnet-4.5 must be discovered"); + const gpt = models.find((m) => m.id === "gpt-5.4"); + assert.ok(gpt, "gpt-5.4 entry present"); + assert.equal(gpt.name, "GPT-5.4"); + assert.equal(gpt.owned_by, "github"); +}); + +test("#3121 a model NOT in the live response is not advertised (entitlement filtering)", () => { + const models = parseGitHubCopilotModels(MOCK_COPILOT_MODELS_RESPONSE); + const ids = models.map((m) => m.id); + // gemini-3.1-pro-preview is in the OLD static catalog but NOT entitled here. + assert.ok( + !ids.includes("gemini-3.1-pro-preview"), + "non-entitled gemini preview must NOT be advertised" + ); +}); + +test("#3120 fetchGitHubCopilotModels does a live fetch and returns parsed models", async () => { + let capturedUrl = ""; + let capturedHeaders: Record = {}; + const fakeFetch = (async (url: string, init: RequestInit) => { + capturedUrl = String(url); + capturedHeaders = (init?.headers as Record) || {}; + return new Response(JSON.stringify(MOCK_COPILOT_MODELS_RESPONSE), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const result = await fetchGitHubCopilotModels({ + token: "copilot-tok-abc", + fetchImpl: fakeFetch, + }); + + assert.equal(capturedUrl, GITHUB_COPILOT_MODELS_URL); + assert.equal( + capturedHeaders.Authorization, + "Bearer copilot-tok-abc", + "must authenticate with the Copilot bearer token" + ); + // Copilot chat headers must be present (e.g. copilot-integration-id). + assert.ok(capturedHeaders["copilot-integration-id"], "must send Copilot integration header"); + assert.equal(result.source, "api"); + const ids = result.models.map((m) => m.id); + assert.ok(ids.includes("gpt-5.4")); + assert.ok(!ids.includes("gemini-3.1-pro-preview")); +}); + +test("#3120/#3121 fetch falls back to static catalog when the live fetch fails", async () => { + const fakeFetch = (async () => + new Response("nope", { status: 503 })) as unknown as typeof fetch; + const fallback = [ + { id: "gpt-5.4", name: "GPT-5.4" }, + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, + ]; + + const result = await fetchGitHubCopilotModels({ + token: "copilot-tok-abc", + fetchImpl: fakeFetch, + fallbackModels: fallback, + }); + + assert.equal(result.source, "fallback"); + assert.deepEqual( + result.models.map((m) => m.id), + ["gpt-5.4", "gemini-3.1-pro-preview"], + "offline/failed discovery must preserve the static catalog" + ); +}); + +test("fetch falls back when no token is provided (unauthed refresh stays safe)", async () => { + let called = false; + const fakeFetch = (async () => { + called = true; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + const result = await fetchGitHubCopilotModels({ + token: "", + fetchImpl: fakeFetch, + fallbackModels: [{ id: "gpt-5.4", name: "GPT-5.4" }], + }); + + assert.equal(called, false, "must not fetch without a token"); + assert.equal(result.source, "fallback"); + assert.deepEqual(result.models.map((m) => m.id), ["gpt-5.4"]); +});