Files
OmniRoute/open-sse/services/githubCopilotModels.ts
hppsc1215 b3d3dd5954 feat(providers): Complete GHE Copilot OAuth provider implementation (#7546)
* docs: add design spec for GHE Copilot provider

* feat(mitm): add GHE Copilot target descriptor

* feat(executors): add GheCopilotExecutor for GHE Copilot

* feat(executors): register GheCopilotExecutor in factory

* feat(providers): add ghe-copilot provider with gheUrl validation

* feat(providers): add ghe-copilot to OAUTH_PROVIDERS and enforce HTTPS gheUrl validation

* test(ghe-copilot): add unit tests for GheCopilotExecutor and GHE_COPILOT_TARGET

* feat: complete GHE Copilot provider implementation

* feat: register ghe-copilot provider in registry

Add GHE Copilot registry entry (executor: "ghe-copilot") so the
provider is resolvable by the API routes and gets the same model
catalog as github Copilot.

* feat: wire ghe-copilot into OAuth flow with per-connection gheUrl

- Add gheCopilot OAuth provider (device-code flow targeting GHE host)
- Register in OAuth PROVIDERS map
- Thread gheUrl from query param → device-code request → poll →
  postExchange → providerSpecificData so the GHE host is used end-to-end
- Restore corrupted src/lib/oauth/providers/github.ts from HEAD

* feat: add ghe-copilot device-code UI with gheUrl input

- Route ghe-copilot through the device-code OAuth branch (was falling
  through to browser OAuth → "Browser OAuth unavailable" error)
- Add a gheUrl collection step so the enterprise host is supplied before
  the device-code request, and thread it into /device-code + /poll

* fix: thread gheUrl through GHE Copilot pollToken + postExchange

pollToken read gheUrl from config (GITHUB_CONFIG, which has none) and
threw "gheUrl is required" on every poll — the connection hung forever
after device authorization. Now reads gheUrl from extraData (passed by
the route), and postExchange carries it forward into mapTokens so it is
persisted in providerSpecificData for the executor.

* fix: GHE Copilot chat routing + account test

- Capture endpoints.proxy from the GHE token response and store it as
  copilotProxyUrl; route chat/responses traffic to that enterprise host
  instead of the static gheUrl/chat/completions path (was 406/404).
- Always route GHE Copilot to /chat/completions (GHE proxy 404s on
  /responses); the Responses API is served via the chat transformer.
- Strip the ghe-copilot/ prefix from the upstream model id.
- Remove openai-responses targetFormat from GHE models so chatCore does
  not run the Responses transformer (which dropped `messages`).
- Add ghe-copilot to OAUTH_TEST_CONFIG (account test was "unsupported").
- Register executor in eslint suppressions.

* fix: drop stream:false for GHE Copilot

The GHE Copilot proxy rejects `stream: false` ("stream": false is not
supported). Only forward the flag when actually streaming; omit it
otherwise.

* fix: force stream:true upstream for GHE Copilot (streaming-only proxy)

The GHE Copilot proxy rejects `stream: false`. forceStream:true in the
registry makes chatCore pass upstreamStream=true, but GithubExecutor
.transformRequest ignores the stream arg (void stream) and keeps the
client's stream:false. Override transformRequest in GheCopilotExecutor to
force stream:true so the proxy accepts the request; chatCore drains the
SSE back to JSON for non-stream clients.

* fix: GHE Copilot live model discovery from copilotProxyUrl/models

- Add fetchGheCopilotModels/parseGheCopilotModels using enterprise proxy URL
  and { models: [{ name }] } response shape (no static allowlist)
- Wire ghe-copilot into models-import route; use plain fetch (safeOutboundFetch
  header guard strips the copilot bearer token -> 403)
- Import now returns real enterprise models (copilot-nes-oct, etc.) and chat
  resolves them correctly

* fix: GHE Copilot uses endpoints.api host for chat + model discovery

The GHE token endpoint returns two hosts:
  - endpoints.api   (copilotApiUrl)   -> chat/completions + real chat model
    catalog, shape { data: [{ id }] }
  - endpoints.proxy (copilotProxyUrl) -> NES/autocomplete/instant-apply only,
    shape { models: [{ name }] }

We were routing chat AND model discovery to endpoints.proxy, so import only
returned completion models (copilot-nes-*, instant-apply) and never the real
chat models (claude-*, gpt-*, gemini-*).

- Executor: capture endpoints.api as copilotApiUrl; buildUrl prefers it
- OAuth postExchange/mapTokens: persist copilotApiUrl from endpoints.api
- Model discovery: fetch from copilotApiUrl/models, parse { data:[{id}] }
  (and proxy { models:[{name}] }) shapes, no allowlist
- All traffic stays on the configured GHE host (deutschebahn.ghe.com),
  never api.githubcopilot.com

Verified: import returns 28 real chat models; chat with gpt-4o streams OK.

* feat(providers): finalize GHE Copilot implementation and add changelog fragment

* fix(providers): resolve ghe-copilot no-explicit-any + complexity ratchet

- Replace the 6 explicit `any` types in GheCopilotExecutor
  (transformRequest, refreshCredentials) with proper ProviderCredentials /
  unknown / ExecutorLog types, and drop the config/quality/eslint-suppressions.json
  allowlist entry added for them — policy requires new violations be fixed,
  not frozen.
- Extract refreshViaGitHubToken() and buildRefreshedProviderSpecificData()
  helpers out of refreshCredentials() to bring its cyclomatic complexity
  (21) back under the repo's ratchet threshold (15); behavior unchanged.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(oauth): unblock #7546 file-size gate for GHE Copilot OAuth provider

Extracts the GHE enterprise-URL config step from OAuthModal.tsx into a
new leaf component (src/shared/components/oauthModal/GheConfigStep.tsx)
to shrink the frozen file's own growth, and rebaselines the two
remaining irreducible wiring bumps (device-code route.ts 960->963,
OAuthModal.tsx 1030->1056) with justification comments, mirroring the
existing #7399/#6636 precedent on this same file.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(ghe-copilot): drop planning spec from docs/ and revert out-of-scope eslint bump

Planning artifacts live outside the repo tree; package.json/lock restored to the
release state (the eslint patch bump was unrelated drift from the fork's history).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(oauth): validate gheUrl (HTTPS-only) at both raw entry points of the device-code flow

Applies the PR's existing providerSpecificData HTTPS rule to the OAuth route's
searchParams and device-flow extraData entry points, rejecting malformed or
non-HTTPS enterprise URLs with 400 before any upstream fetch. Private-IP hosts
stay allowed by design — on-prem GHE Server is the primary use case.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(quality): extend oauth route file-size note for the gheUrl validation guards (963->970)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Alexander Helm <alexander.helm@deutschebahn.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>
Co-authored-by: hppsc1215 <hppsc1215@users.noreply.github.com>
2026-07-19 21:22:37 -03:00

255 lines
8.5 KiB
TypeScript

/**
* 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 const GITHUB_COPILOT_MODEL_ALLOWLIST = [
"claude-fable-5",
"claude-opus-4.8-fast",
"claude-opus-4.8",
"claude-opus-4.7",
"claude-sonnet-4.6",
"claude-opus-4.5",
"claude-sonnet-5",
"claude-sonnet-4.5",
"claude-haiku-4.5",
"gemini-3.1-pro-preview",
"gemini-3.5-flash",
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5-mini",
"gpt-4o-2024-11-20",
"gpt-4o-mini",
"gpt-4-0125-preview",
"kimi-k2.7-code",
"mai-code-1-flash",
"oswe-vscode-prime",
] as const;
const GITHUB_COPILOT_MODEL_ALLOWLIST_SET = new Set<string>(GITHUB_COPILOT_MODEL_ALLOWLIST);
export type GitHubCopilotModel = {
id: string;
name: string;
owned_by: string;
};
type RawRecord = Record<string, unknown>;
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<string>();
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;
if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.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;
if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.has(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<GitHubCopilotModelsResult> {
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);
}
}
/**
* GHE Copilot live model discovery.
*
* The GHE token endpoint returns TWO hosts in `endpoints`:
* - `endpoints.api` (copilotApiUrl) → chat/completions + the real chat
* model catalog. Response shape is `{ data: [{ id, name, ... }] }` (same
* shape as github.com's api.githubcopilot.com/models).
* - `endpoints.proxy` (copilotProxyUrl) → NES / autocomplete / instant-apply
* models only. Response shape is `{ models: [{ name, ... }] }`.
*
* We discover from the `api` host so the imported catalog is the real chat
* models (claude-*, gpt-*, gemini-*), not the completion-only proxy set. Unlike
* github.com, the IDs are enterprise-specific, so NO static allowlist applies —
* every id in the live response is kept. Both response shapes are parsed so a
* legacy connection that still points at the proxy host keeps working.
*/
function asGheRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function parseGheCopilotModels(data: unknown): GitHubCopilotModel[] {
const payload = asGheRecord(data);
// api host → { data: [{ id }] }; proxy host → { models: [{ name }] }.
const items = Array.isArray(payload.data)
? (payload.data as unknown[])
: Array.isArray(payload.models)
? (payload.models as unknown[])
: [];
const seen = new Set<string>();
const models: GitHubCopilotModel[] = [];
for (const value of items) {
const item = asGheRecord(value);
// api host uses `id`; proxy host uses `name` as the model id.
const id = toNonEmptyString(item.id) || toNonEmptyString(item.name);
if (!id || seen.has(id)) continue;
seen.add(id);
const name =
toNonEmptyString(item.name) ||
toNonEmptyString(item.display_name) ||
toNonEmptyString(item.label) ||
id;
models.push({ id, name, owned_by: toNonEmptyString(item.vendor || item.provider) || "ghe-copilot" });
}
return models;
}
export type FetchGheCopilotModelsOptions = {
/**
* Copilot API base URL (providerSpecificData.copilotApiUrl, from
* endpoints.api). This is the host that serves the real chat model catalog.
*/
apiUrl: string | null | undefined;
/** Copilot bearer token. */
token: string | null | undefined;
/** Injectable fetch (defaults to global fetch). */
fetchImpl?: typeof fetch;
};
/**
* Discover the GHE Copilot model catalog live from `<apiUrl>/models`.
* Returns an empty list (no fallback catalog) when discovery is unavailable —
* GHE model IDs are enterprise-specific, so a static fallback would be wrong.
*/
export async function fetchGheCopilotModels(
options: FetchGheCopilotModelsOptions
): Promise<GitHubCopilotModel[]> {
const { apiUrl, token, fetchImpl = fetch } = options;
const base = toNonEmptyString(apiUrl);
if (!base || !toNonEmptyString(token)) return [];
try {
const response = await fetchImpl(`${base.replace(/\/+$/, "")}/models`, {
method: "GET",
headers: {
...getGitHubCopilotChatHeaders("application/json"),
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) return [];
const data = await response.json();
return parseGheCopilotModels(data);
} catch {
return [];
}
}