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>
This commit is contained in:
hppsc1215
2026-07-20 02:22:37 +02:00
committed by GitHub
parent d8ff51874c
commit b3d3dd5954
28 changed files with 1105 additions and 12 deletions

View File

@@ -0,0 +1 @@
- **feat(providers):** Complete GHE Copilot OAuth provider implementation with device-code flow, validation, error sanitization, and DNS provisioning mapping ([#7546](https://github.com/diegosouzapw/OmniRoute/pull/7546))

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_07_19_7546_ghe_copilot_route": "PR #7546 (GHE Copilot OAuth provider) own growth: oauth/[provider]/[action]/route.ts 960->963 (gate units, +3 = ghe-copilot device-code wiring at the existing multi-provider device-code branch — reading + HTTPS-validating the gheUrl search param (isValidGheUrl guards at both raw entry points, security-review hardening, 963->970), adding ghe-copilot to the no-PKCE provider set, and building the provider config override / threading gheUrl through poll->postExchange extraData). Mirrors the existing kiro/amazon-q startUrl override pattern right above it in the same branch; cohesive with the existing device-code dispatch chokepoint, not separately extractable without splitting a single provider-switch mid-branch. Frozen so can only shrink; structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_6846_nvidia_concurrency_gate": "Issue #6846 Phase 1 (nvidia NIM local RPM budget + per-model lockout + per-connection concurrency cap) own growth: open-sse/executors/default.ts 877->890 (+13 = the irreducible call-site wiring at DefaultExecutor.execute(), the only place nvidia requests dispatch through — the existing session-pool body was extracted verbatim into a new private executeWithSessionPool() so the outer execute() can wrap it in the nvidia concurrency-gate acquire/finally-release). All actual gating logic (semaphore key + cap resolution) lives in the new leaf open-sse/executors/default/nvidiaConcurrencyGate.ts (not frozen, well under cap). Covered by tests/unit/nvidia-quota-phase1.test.ts.",
"_rebaseline_2026_07_18_v3849_provider_detail_wiring": "Merge campaign R2/R3 (2026-07-18): three authorized PRs each add irreducible call-site wiring to ProviderDetailPageClient.tsx — #7360 +5 (ProviderQuotaVisibilityToggle render, component extracted), #7419 +4 (NoAuthProviderControls wiring), #7062 +3 (Dahl provider hook) = 786->798. All three follow the extracted-component pattern (AgentrouterConsoleFields precedent); the frozen file only takes the wiring. Structural shrink tracked in #3501.",
"_rebaseline_2026_07_18_pr7653_chat_tracker_import": "PR #7653 merge-interaction growth: release moved chat.ts to its 1796 cap while this PR adds the single side-effect import 'quotaTrackersBatch.ts' (line 130) — chat.ts IS the canonical quota-fetcher registration point (codex/bailian/deepseek/openrouter/opencode/generic all import+register there), so the +1 is irreducible call-site wiring. 1796->1797. Covered by tests/unit/{agentrouter,v0,freemodel}-quota-fetcher.test.ts.",
@@ -239,7 +240,7 @@
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016,
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1127,
"src/app/api/oauth/[provider]/[action]/route.ts": 960,
"src/app/api/oauth/[provider]/[action]/route.ts": 970,
"src/app/api/providers/[id]/models/route.ts": 2593,
"src/app/api/providers/[id]/test/route.ts": 940,
"src/app/api/usage/analytics/route.ts": 948,
@@ -265,7 +266,8 @@
"_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.",
"_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_6636_codex_session_json": "#6636 own growth: OAuthModal.tsx 998->1030 (gate units, split(\"\\n\").length incl. trailing newline; +32 = session-JSON paste branch for handleManualSubmit plus a shared submitCodexAccessToken() helper extracted from the pre-existing bare-JWT branch, mirroring the #5203 oauthBlobSubmit.ts extraction precedent; the normalizer logic itself lives in the new src/lib/oauth/utils/codexSessionImport.ts leaf module, not here). Fourth irreducible wiring bump on this modal (969->989->993->998->1030); structural shrink tracked in #3501.",
"src/shared/components/OAuthModal.tsx": 1030,
"_rebaseline_2026_07_19_7546_ghe_copilot_modal": "PR #7546 (GHE Copilot OAuth provider) own growth: OAuthModal.tsx 1030->1056 (gate units). Adds a gheUrl input state, routes ghe-copilot through the existing device-code branch, and threads gheUrl into the device-code request/poll extraData at the existing provider-switch chokepoints (+~24 lines, cohesive with the same pattern as #7399/#6636). The standalone GHE enterprise-URL config step JSX (originally +31 lines inline) was extracted to the new src/shared/components/oauthModal/GheConfigStep.tsx leaf component to minimize the bump; what remains is the irreducible provider-branch wiring. Fifth bump on this modal (969->989->993->998->1030->1056); structural shrink tracked in #3501.",
"src/shared/components/OAuthModal.tsx": 1056,
"src/shared/components/RequestLoggerV2.tsx": 1629,
"src/shared/components/analytics/charts.tsx": 1558,
"_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).",

View File

@@ -26,6 +26,7 @@ import { lmarenaProvider } from "./registry/lmarena/index.ts";
import { kilocodeProvider } from "./registry/kilocode/index.ts";
import { github_modelsProvider } from "./registry/github/models/index.ts";
import { githubProvider } from "./registry/github/index.ts";
import { gheCopilotProvider } from "./registry/ghe-copilot/index.ts";
import { difyProvider } from "./registry/dify/index.ts";
import { ovhcloudProvider } from "./registry/ovhcloud/index.ts";
import { claudeProvider } from "./registry/claude/index.ts";
@@ -224,6 +225,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
kilocode: kilocodeProvider,
"github-models": github_modelsProvider,
github: githubProvider,
"ghe-copilot": gheCopilotProvider,
dify: difyProvider,
ovhcloud: ovhcloudProvider,
claude: claudeProvider,

View File

@@ -0,0 +1,156 @@
import type { RegistryEntry } from "../../shared.ts";
import {
GPT_5_5_CODEX_CAPABILITIES,
getGitHubCopilotChatHeaders,
resolvePublicCred,
} from "../../shared.ts";
export const gheCopilotProvider: RegistryEntry = {
id: "ghe-copilot",
alias: "ghe-copilot",
format: "openai",
executor: "ghe-copilot",
// GHE Copilot proxy is streaming-only: it rejects `stream: false` (and an
// absent stream flag). forceStream makes chatCore send stream:true upstream
// and drain the SSE back into a single JSON response for non-stream clients.
forceStream: true,
baseUrl: "https://api.githubcopilot.com/chat/completions",
responsesBaseUrl: "https://api.githubcopilot.com/responses",
authType: "oauth",
authHeader: "bearer",
// GHE Copilot requires a custom gheUrl (set per-connection via providerSpecificData).
// The executor overrides URL building + token refresh to hit the GHE host.
oauth: {
clientIdEnv: "GITHUB_OAUTH_CLIENT_ID",
clientIdDefault: resolvePublicCred("github_copilot_id"),
},
defaultContextLength: 128000,
headers: getGitHubCopilotChatHeaders(),
models: [
{
id: "claude-fable-5",
name: "Claude Fable 5",
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "claude-opus-4.8-fast",
name: "Claude Opus 4.8 (fast mode)",
contextLength: 1000000,
maxOutputTokens: 64000,
unsupportedParams: ["temperature", "top_p", "top_k"],
},
{
id: "claude-opus-4.8",
name: "Claude Opus 4.8",
contextLength: 1000000,
maxOutputTokens: 64000,
unsupportedParams: ["temperature", "top_p", "top_k"],
},
{
id: "claude-opus-4.7",
name: "Claude Opus 4.7",
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "claude-opus-4.5",
name: "Claude Opus 4.5",
contextLength: 200000,
maxOutputTokens: 32000,
},
{
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
contextLength: 200000,
maxOutputTokens: 32000,
},
{
id: "claude-haiku-4.5",
name: "Claude Haiku 4.5",
contextLength: 200000,
maxOutputTokens: 32000,
},
{
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro",
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "gemini-3.5-flash",
name: "Gemini 3.5 Flash",
contextLength: 1000000,
maxOutputTokens: 64000,
},
{ id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES, maxOutputTokens: 128000 },
{
id: "gpt-5.4",
name: "GPT-5.4",
supportsXHighEffort: true,
contextLength: 1050000,
maxOutputTokens: 128000,
},
{
id: "gpt-5.4-mini",
name: "GPT-5.4 mini",
contextLength: 400000,
maxOutputTokens: 128000,
},
{
id: "gpt-5.3-codex",
name: "GPT-5.3-Codex",
contextLength: 400000,
maxOutputTokens: 128000,
},
{
id: "gpt-5-mini",
name: "GPT-5 mini",
contextLength: 264000,
maxOutputTokens: 64000,
},
{
id: "gpt-4o-2024-11-20",
name: "GPT-4o",
contextLength: 128000,
maxOutputTokens: 16384,
},
{ id: "gpt-4o-mini", name: "GPT-4o mini", contextLength: 128000, maxOutputTokens: 4096 },
{
id: "gpt-4-0125-preview",
name: "GPT 4 Turbo",
contextLength: 128000,
maxOutputTokens: 4096,
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code",
contextLength: 256000,
maxOutputTokens: 32000,
},
{
id: "mai-code-1-flash",
name: "MAI-Code-1-Flash",
contextLength: 256000,
maxOutputTokens: 128000,
},
{
id: "oswe-vscode-prime",
name: "Raptor mini",
contextLength: 264000,
maxOutputTokens: 64000,
},
],
};

View File

@@ -0,0 +1,252 @@
import { GithubExecutor } from "./github.ts";
import type { ProviderCredentials, ExecuteInput, ExecutorLog } from "./base.ts";
import { getModelTargetFormat } from "../config/providerModels.ts";
/** Result of a successful GHE Copilot internal token exchange. */
type CopilotTokenResult = {
token: string;
expiresAt: string | number;
endpoints?: { proxy?: string; api?: string };
};
export class GheCopilotExecutor extends GithubExecutor {
constructor(config?: Record<string, unknown>) {
super("ghe-copilot", {
format: "openai",
baseUrl: "https://api.githubcopilot.com/chat/completions",
responsesBaseUrl: "https://api.githubcopilot.com/responses",
authType: "oauth",
authHeader: "bearer",
...config,
});
}
/**
* Derive the base URL for chat/completions from gheUrl in providerSpecificData.
* Appends /chat/completions if not already present.
*/
private getChatCompletionsBase(credentials: ProviderCredentials | null): string {
// The GHE token endpoint returns TWO hosts:
// endpoints.api → copilotApiUrl (chat/completions + the /models catalog)
// endpoints.proxy → copilotProxyUrl (NES / autocomplete / instant-apply only)
// Chat MUST go to the api host. The proxy host only serves the completion
// models (copilot-nes-*, instant-apply, suggestions) and 404s/errors for
// real chat models. Prefer copilotApiUrl, fall back to copilotProxyUrl for
// legacy connections, then the static gheUrl/chat/completions path.
const psd = credentials?.providerSpecificData;
const apiOrProxy =
(typeof psd?.copilotApiUrl === "string" ? psd.copilotApiUrl : undefined) ||
(typeof psd?.copilotProxyUrl === "string" ? psd.copilotProxyUrl : undefined);
if (apiOrProxy) {
const base = apiOrProxy.replace(/\/+$/, "");
return base.endsWith("/chat/completions") ? base : `${base}/chat/completions`;
}
const gheUrl = psd?.gheUrl as string | undefined;
if (!gheUrl) {
throw new Error("GHE Copilot executor requires gheUrl in providerSpecificData");
}
const base = gheUrl.replace(/\/$/, "");
return base.endsWith("/chat/completions") ? base : `${base}/chat/completions`;
}
/**
* Strip the `ghe-copilot/` provider prefix from a model id so the upstream
* GHE Copilot proxy receives the bare id (e.g. `gpt-5-mini`).
*/
private stripPrefix(model: string): string {
return typeof model === "string" && model.startsWith("ghe-copilot/")
? model.slice("ghe-copilot/".length)
: model;
}
override buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: ProviderCredentials | null = null): string {
// GHE Copilot proxy only reliably serves /chat/completions. Route every
// model there (including ones flagged openai-responses) and let the
// Responses→Chat transformer handle the format. Going to /responses on the
// GHE proxy returns a bare 404 ("404 page not found").
return this.getChatCompletionsBase(credentials);
}
/**
* Strip the `ghe-copilot/` provider prefix from the model before sending to
* the upstream GHE Copilot proxy, which expects bare model ids.
*/
override transformRequest(
model: string,
body: unknown,
stream: boolean,
credentials: ProviderCredentials
): unknown {
const bareModel = this.stripPrefix(model);
const transformed = super.transformRequest(bareModel, body, stream, credentials);
if (transformed && typeof transformed === "object") {
const record = transformed as Record<string, unknown>;
if (typeof record.model === "string") {
record.model = this.stripPrefix(record.model);
}
// GHE Copilot proxy is streaming-only: force stream:true upstream
// (chatCore drains the SSE back to JSON for non-stream clients).
record.stream = true;
}
return transformed;
}
override async refreshCopilotToken(
githubAccessToken: string,
log?: { info?: (cat: string, msg: string) => void; error?: (cat: string, msg: string) => void },
credentials?: ProviderCredentials | null
): Promise<CopilotTokenResult | null> {
const gheUrl = credentials?.providerSpecificData?.gheUrl as string | undefined;
if (!gheUrl) return null;
try {
const baseUrl = gheUrl.replace(/\/chat\/completions\/?$/, "").replace(/\/responses\/?$/, "");
const tokenUrl = `${baseUrl}/api/v3/copilot_internal/v2/token`;
const response = await fetch(tokenUrl, {
headers: {
Authorization: `Bearer ${githubAccessToken}`,
Accept: "application/json",
},
});
if (!response.ok) return null;
const data = await response.json();
log?.info?.("TOKEN", "GHE Copilot token refreshed");
// GHE returns a dynamic `endpoints` object; the chat/responses proxy host
// lives at endpoints.proxy (NOT a static path on the GHE web host).
const endpoints = data.endpoints
? { proxy: data.endpoints.proxy, api: data.endpoints.api }
: undefined;
return {
token: data.token,
expiresAt: data.expires_at,
...(endpoints ? { endpoints } : {}),
};
} catch (error) {
log?.error?.("TOKEN", `GHE Copilot refresh error: ${error.message}`);
return null;
}
}
override async refreshGitHubToken(
refreshToken: string,
log?: { info?: (cat: string, msg: string) => void; error?: (cat: string, msg: string) => void },
credentials?: ProviderCredentials | null
): Promise<{
accessToken: string;
refreshToken: string;
expiresIn: number;
} | null> {
const gheUrl = credentials?.providerSpecificData?.gheUrl as string | undefined;
if (!gheUrl) return null;
try {
// GHE OAuth token endpoint
const baseUrl = gheUrl.replace(/\/chat\/completions\/?$/, "").replace(/\/responses\/?$/, "");
const tokenUrl = `${baseUrl}/login/oauth/access_token`;
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: this.config.clientId,
});
if (this.config.clientSecret) {
params.set("client_secret", this.config.clientSecret);
}
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params,
});
if (!response.ok) return null;
const tokens = await response.json();
log?.info?.("TOKEN", "GHE GitHub token refreshed");
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN", `GHE GitHub refresh error: ${error.message}`);
return null;
}
}
/**
* Merge a fresh Copilot token result into providerSpecificData, preserving
* existing fields and updating the token/expiry/endpoint bookkeeping GHE
* Copilot needs (copilotApiUrl for chat/models, copilotProxyUrl legacy
* fallback, gheUrl for the next refresh round-trip).
*/
private buildRefreshedProviderSpecificData(
credentials: ProviderCredentials,
copilotResult: CopilotTokenResult
): Record<string, unknown> {
return {
...credentials?.providerSpecificData,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
copilotApiUrl: copilotResult.endpoints?.api,
copilotProxyUrl: copilotResult.endpoints?.proxy,
gheUrl: credentials?.providerSpecificData?.gheUrl,
};
}
/**
* Fallback path when the cached GitHub access token can no longer mint a
* Copilot token directly: refresh the GitHub OAuth token first, then retry
* the Copilot token exchange with the new access token.
*/
private async refreshViaGitHubToken(credentials: ProviderCredentials, log?: ExecutorLog | null) {
const githubTokens = await this.refreshGitHubToken(
credentials.refreshToken as string,
log,
credentials
);
if (!githubTokens?.accessToken) return null;
const copilotResult = await this.refreshCopilotToken(githubTokens.accessToken, log, credentials);
if (!copilotResult) return githubTokens;
return {
...githubTokens,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
providerSpecificData: this.buildRefreshedProviderSpecificData(credentials, copilotResult),
};
}
/**
* Refresh credentials and capture the GHE Copilot proxy URL (endpoints.proxy)
* returned by the token endpoint, storing it in providerSpecificData so
* buildUrl routes chat/responses traffic to the correct enterprise host.
*/
override async refreshCredentials(credentials: ProviderCredentials, log?: ExecutorLog | null) {
const copilotResult = await this.refreshCopilotToken(credentials?.accessToken, log, credentials);
if (!copilotResult && credentials?.refreshToken) {
return this.refreshViaGitHubToken(credentials, log);
}
if (copilotResult) {
return {
accessToken: credentials?.accessToken,
refreshToken: credentials?.refreshToken,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
providerSpecificData: this.buildRefreshedProviderSpecificData(credentials, copilotResult),
};
}
return null;
}
}
export default GheCopilotExecutor;

View File

@@ -1,5 +1,6 @@
import { AntigravityExecutor } from "./antigravity.ts";
import { GithubExecutor } from "./github.ts";
import { GheCopilotExecutor } from "./ghe-copilot.ts";
import { QoderExecutor } from "./qoder.ts";
import { KiroExecutor } from "./kiro.ts";
import { CodexExecutor } from "./codex.ts";
@@ -68,6 +69,7 @@ const executors = {
antigravity: new AntigravityExecutor(),
agy: new AntigravityExecutor(),
github: new GithubExecutor(),
"ghe-copilot": new GheCopilotExecutor(),
qoder: new QoderExecutor(),
kiro: new KiroExecutor(),
"amazon-q": new KiroExecutor("amazon-q"),

View File

@@ -160,3 +160,95 @@ export async function fetchGitHubCopilotModels(
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 [];
}
}

View File

@@ -22,6 +22,7 @@ import {
resolveProxyForProvider,
} from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
import { syncToCloud } from "@/lib/cloudSync";
import { startLocalServer } from "@/lib/oauth/utils/server";
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
@@ -190,6 +191,10 @@ export async function GET(
const authData = generateAuthData(provider, null);
const startUrl = searchParams.get("startUrl");
const region = searchParams.get("region") || "us-east-1";
const gheUrl = searchParams.get("gheUrl");
if (gheUrl && !isValidGheUrl(gheUrl)) {
return NextResponse.json({ error: "gheUrl must be a valid HTTPS URL" }, { status: 400 });
}
// Resolve proxy for this provider (provider-level → global → direct)
const proxy = await resolveProxyForProvider(provider);
@@ -202,10 +207,20 @@ export async function GET(
provider === "amazon-q" ||
provider === "kimi-coding" ||
provider === "kilocode" ||
provider === "codebuddy-cn"
provider === "codebuddy-cn" ||
provider === "ghe-copilot"
) {
// GitHub, Kiro/Amazon Q, Kimi Coding, and KiloCode don't use PKCE for device code
if ((provider === "kiro" || provider === "amazon-q") && startUrl) {
// GitHub, Kiro/Amazon Q, Kimi Coding, KiloCode, and GHE Copilot don't use PKCE for device code
if (provider === "ghe-copilot" && gheUrl) {
// GHE Copilot targets the enterprise host configured via gheUrl
const providerOverrideConfig = {
...providerData.config,
gheUrl,
};
deviceData = await runWithProxyContextOrDirect(proxy, () =>
(requestDeviceCode as any)(provider, null, providerOverrideConfig)
);
} else if ((provider === "kiro" || provider === "amazon-q") && startUrl) {
const providerOverrideConfig = {
...providerData.config,
startUrl,
@@ -564,6 +579,16 @@ export async function POST(
result = await runWithProxyContextOrDirect(proxy, () =>
(pollForToken as any)(provider, deviceCode)
);
} else if (provider === "ghe-copilot") {
// GHE Copilot needs gheUrl threaded through poll → postExchange
const gheUrl =
extraData && typeof extraData === "object" ? (extraData as any).gheUrl : undefined;
if (typeof gheUrl === "string" && gheUrl && !isValidGheUrl(gheUrl)) {
return NextResponse.json({ error: "gheUrl must be a valid HTTPS URL" }, { status: 400 });
}
result = await runWithProxyContextOrDirect(proxy, () =>
(pollForToken as any)(provider, deviceCode, null, gheUrl ? { gheUrl } : undefined)
);
} else if (provider === "kiro" || provider === "amazon-q") {
// Kiro needs extraData (clientId, clientSecret) from device code response
result = await runWithProxyContextOrDirect(proxy, () =>

View File

@@ -29,7 +29,10 @@ import {
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";
import { deriveConfigFromRegistryModelsUrl } from "./discoveryConfig";
import { fetchGitHubCopilotModels } from "@omniroute/open-sse/services/githubCopilotModels.ts";
import {
fetchGitHubCopilotModels,
fetchGheCopilotModels,
} from "@omniroute/open-sse/services/githubCopilotModels.ts";
import { fetchKiroAvailableModels } from "@omniroute/open-sse/services/kiroModels.ts";
import {
buildGlmCodingHeaders,
@@ -1562,6 +1565,51 @@ export async function GET(
});
}
if (provider === "ghe-copilot") {
// GHE Copilot exposes a per-enterprise chat model catalog at
// <copilotApiUrl>/models (endpoints.api from the token endpoint) — NOT the
// proxy host, which only serves NES/autocomplete models. The IDs are
// enterprise-specific (no static allowlist applies), so discover them live
// from copilotApiUrl with the Copilot bearer token.
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
const psd = asRecord(connection.providerSpecificData);
const copilotToken =
toNonEmptyString(psd.copilotToken) || toNonEmptyString(accessToken) || null;
// endpoints.api serves the real chat model catalog; endpoints.proxy only
// has NES/autocomplete models. Prefer the api host, fall back to proxy for
// legacy connections that predate copilotApiUrl capture.
const copilotApiUrl =
toNonEmptyString(psd.copilotApiUrl) || toNonEmptyString(psd.copilotProxyUrl) || null;
const models = await fetchGheCopilotModels({
apiUrl: copilotApiUrl,
token: copilotToken,
fetchImpl: (url, init) => fetch(url as string, init as RequestInit),
});
if (models.length > 0) {
return buildApiDiscoveryResponse(models);
}
const fallback = buildDiscoveryFallbackResponse({
cacheWarning: "GHE Copilot models API unavailable — using cached catalog",
localWarning: "GHE Copilot models API unavailable — using local catalog",
});
if (fallback) return fallback;
return buildResponse({
provider,
connectionId,
models: [],
source: "local_catalog",
warning: "GHE Copilot models API unavailable — using local catalog",
});
}
if (provider === "kiro") {
// Kiro's catalog is per-account / per-tier (free vs Pro vs Power) and, for
// IAM Identity Center orgs, an admin-curated approved list. The static

View File

@@ -126,4 +126,19 @@ export const OAUTH_TEST_CONFIG = {
checkExpiry: true,
refreshable: true,
},
"ghe-copilot": {
// GHE Copilot: probe the enterprise user-info endpoint derived from gheUrl
// (stored in providerSpecificData).
getUrl: (connection: any) => {
const gheUrl =
connection?.providerSpecificData?.gheUrl || connection?.gheUrl || "";
const base = gheUrl.replace(/\/+$/, "");
return `${base}/api/v3/user`;
},
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" },
refreshable: true,
},
};

View File

@@ -891,6 +891,42 @@ export function autoMigrateLegacyEncryptedConnections(): number {
return migratedCount;
}
export function getGheCopilotHosts(): string[] {
const hosts = new Set<string>();
try {
const db = getDbInstance();
const rows = db
.prepare(
"SELECT provider_specific_data FROM provider_connections WHERE provider = 'ghe-copilot' AND is_active = 1"
)
.all() as { provider_specific_data: string | null }[];
for (const row of rows) {
if (!row.provider_specific_data) continue;
try {
const psd = JSON.parse(row.provider_specific_data);
const urls = [psd.gheUrl, psd.copilotApiUrl, psd.copilotProxyUrl];
for (const urlStr of urls) {
if (typeof urlStr === "string" && urlStr.trim()) {
try {
const url = new URL(urlStr);
if (url.hostname) {
hosts.add(url.hostname.toLowerCase());
}
} catch {
// Ignore invalid URLs
}
}
}
} catch {
// Ignore JSON parse errors
}
}
} catch (err) {
console.error("[DB] getGheCopilotHosts: failed to read GHE Copilot connections", err);
}
return [...hosts];
}
// ──────────────── Re-exports from leaf modules ────────────────
export {

View File

@@ -261,6 +261,22 @@ export const GITHUB_CONFIG = {
editorPluginVersion: GITHUB_COPILOT_CHAT_PLUGIN_VERSION,
};
// GitHub Enterprise (GHE) Copilot OAuth Configuration (Device Code Flow)
export const GHE_COPILOT_CONFIG = {
clientId:
process.env.GHE_COPILOT_OAUTH_CLIENT_ID ||
resolvePublicCred("github_copilot_id", "GITHUB_OAUTH_CLIENT_ID"),
deviceCodeUrl: "", // Derived dynamically in provider flow
tokenUrl: "", // Derived dynamically in provider flow
userInfoUrl: "", // Derived dynamically in provider flow
scopes: "read:user",
apiVersion: GITHUB_COPILOT_API_VERSION,
copilotTokenUrl: "", // Derived dynamically in provider flow
userAgent: GITHUB_COPILOT_CHAT_USER_AGENT,
editorVersion: GITHUB_COPILOT_EDITOR_VERSION,
editorPluginVersion: GITHUB_COPILOT_CHAT_PLUGIN_VERSION,
};
const GITLAB_DUO_ENDPOINTS = buildGitLabOAuthEndpoints(GITLAB_DUO_DEFAULT_BASE_URL);
export const GITLAB_DUO_CONFIG = {

View File

@@ -250,7 +250,7 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra
if (result.data.access_token) {
let extra = null;
if (provider.postExchange) {
extra = await provider.postExchange(result.data);
extra = await provider.postExchange(result.data, extraData || undefined);
}
return { success: true, tokens: provider.mapTokens(result.data, extra) };
} else {

View File

@@ -0,0 +1,115 @@
import { GHE_COPILOT_CONFIG } from "../constants/oauth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
/**
* GHE Copilot OAuth provider.
*
* Reuses the GitHub device-code flow but targets the GitHub Enterprise host
* configured per-connection via `gheUrl` (stored in providerSpecificData).
* The device-code / token / user-info / copilot-token endpoints are derived
* from gheUrl at request time.
*/
function normalizeGheUrl(value: unknown): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error("gheUrl is required for GHE Copilot OAuth");
}
return value.trim().replace(/\/+$/, "");
}
export const gheCopilot = {
config: GHE_COPILOT_CONFIG,
flowType: "device_code" as const,
requestDeviceCode: async (config: any) => {
const gheUrl = normalizeGheUrl(config.gheUrl);
const response = await fetch(`${gheUrl}/login/device/code`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scopes,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config: any, deviceCode: string, _codeVerifier?: string, extraData?: any) => {
const gheUrl = normalizeGheUrl(extraData?.gheUrl || config.gheUrl);
const response = await fetch(`${gheUrl}/login/oauth/access_token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: sanitizeErrorMessage(text) };
}
return {
ok: response.ok,
data: data,
};
},
postExchange: async (tokens: any, extra?: any) => {
const gheUrl = normalizeGheUrl(extra?.gheUrl);
const copilotRes = await fetch(`${gheUrl}/api/v3/copilot_internal/v2/token`, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GHE_COPILOT_CONFIG.apiVersion,
"User-Agent": GHE_COPILOT_CONFIG.userAgent,
},
});
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
const userRes = await fetch(`${gheUrl}/api/v3/user`, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GHE_COPILOT_CONFIG.apiVersion,
"User-Agent": GHE_COPILOT_CONFIG.userAgent,
},
});
const userInfo = userRes.ok ? await userRes.json() : {};
return {
copilotToken,
userInfo,
gheUrl: extra?.gheUrl,
// endpoints.api → chat/completions + /models catalog (real chat models).
// endpoints.proxy → NES/autocomplete only. Capture both; chat + discovery
// use the api host.
copilotApiUrl: copilotToken?.endpoints?.api,
copilotProxyUrl: copilotToken?.endpoints?.proxy,
};
},
mapTokens: (tokens: any, extra?: any) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
gheUrl: extra?.gheUrl,
copilotApiUrl: extra?.copilotApiUrl || extra?.copilotToken?.endpoints?.api,
copilotProxyUrl: extra?.copilotProxyUrl || extra?.copilotToken?.endpoints?.proxy,
copilotToken: extra?.copilotToken?.token,
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
githubUserId: extra?.userInfo?.id,
githubLogin: extra?.userInfo?.login,
githubName: extra?.userInfo?.name,
githubEmail: extra?.userInfo?.email,
},
}),
};

View File

@@ -18,6 +18,7 @@ import { qoder } from "./qoder";
import { qwen } from "./qwen";
import { kimiCoding } from "./kimi-coding";
import { github } from "./github";
import { gheCopilot } from "./ghe-copilot";
import { gitlabDuo } from "./gitlab-duo";
import { kiro } from "./kiro";
import { cursor } from "./cursor";
@@ -40,6 +41,7 @@ export const PROVIDERS = {
qwen,
"kimi-coding": kimiCoding,
github,
"ghe-copilot": gheCopilot,
"gitlab-duo": gitlabDuo,
kiro,
"amazon-q": kiro,

View File

@@ -8,6 +8,7 @@ import { isRoot } from "../systemCommands.ts";
import { ALL_TARGETS } from "../targets/index.ts";
import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState.ts";
import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts";
import { getGheCopilotHosts } from "@/lib/db/providers.ts";
import { createLogger } from "@/shared/utils/logger.ts";
const defaultLog = createLogger("mitm-dns-provision");

View File

@@ -13,6 +13,7 @@ import { detectAgent } from "./detection/index.ts";
import type { AgentId, DetectionResult, MitmTarget } from "./types.ts";
import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState.ts";
import { getUserBypassPatterns } from "@/lib/db/agentBridgeBypass.ts";
import { getGheCopilotHosts } from "@/lib/db/providers.ts";
import { configureUpstreamCa } from "./upstreamTrust.ts";
import { createLogger } from "@/shared/utils/logger.ts";
import {
@@ -162,7 +163,7 @@ export function writeTargetsJson(targets: MitmTarget[] = ALL_TARGETS): void {
targets: targets.map((t) => ({
id: t.id,
name: t.name,
hosts: t.hosts,
hosts: t.id === "ghe-copilot" ? [...new Set([...t.hosts, ...getGheCopilotHosts()])] : t.hosts,
endpointPatterns: t.endpointPatterns,
viability: t.viability ?? "supported",
})),

View File

@@ -5,6 +5,7 @@ import { removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts";
import { uninstallCert } from "./cert/install.ts";
import { ALL_TARGETS } from "./targets/index.ts";
import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts";
import { getGheCopilotHosts } from "@/lib/db/providers.ts";
import { createLogger } from "@/shared/utils/logger.ts";
const log = createLogger("mitm-repair");
@@ -21,6 +22,9 @@ export function collectManagedHosts(): string[] {
const hosts = new Set<string>();
for (const target of ALL_TARGETS) {
for (const h of target.hosts) hosts.add(h);
if (target.id === "ghe-copilot") {
for (const h of getGheCopilotHosts()) hosts.add(h);
}
}
try {
for (const ch of listCustomHosts()) hosts.add(ch.host);

View File

@@ -0,0 +1,35 @@
/**
* GitHub Enterprise (GHE) Copilot — MITM target descriptor.
*/
import type { MitmTarget } from "../types";
export const GHE_COPILOT_TARGET: MitmTarget = {
id: "ghe-copilot",
name: "GitHub Enterprise Copilot",
icon: "code",
color: "#10B981",
// Hosts will be dynamically matched via the configured gheUrl
// Empty array means "match via provider config" in the MITM layer
hosts: [],
port: 443,
endpointPatterns: ["/chat/completions", "/v1/chat/completions", "/responses"],
defaultModels: [
{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" },
{ id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet", alias: "claude-3.5-sonnet" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", alias: "gemini-2.0-flash" },
],
setupTutorial: {
steps: [
"Configure your GHE Copilot endpoint URL in OmniRoute provider settings",
"Ensure your GHE instance has Copilot enabled",
"Sign in to GitHub Enterprise with a Copilot-enabled account",
"Enable DNS routing for this agent",
"Restart your IDE (VS Code, JetBrains, etc.)",
"Done — GHE Copilot now routes via OmniRoute",
],
detection: { command: "code --list-extensions", platform: "all" },
},
handler: () =>
import("../handlers/copilot").then((m) => ({ default: m.CopilotHandler })),
riskNoticeKey: "providers.riskNotice.oauth",
};

View File

@@ -13,6 +13,7 @@ import type { MitmTarget } from "../types";
import { ANTIGRAVITY_TARGET } from "./antigravity";
import { KIRO_TARGET } from "./kiro";
import { COPILOT_TARGET } from "./copilot";
import { GHE_COPILOT_TARGET } from "./ghe-copilot";
import { CODEX_TARGET } from "./codex";
import { CURSOR_TARGET } from "./cursor";
import { ZED_TARGET } from "./zed";
@@ -20,10 +21,13 @@ import { CLAUDE_CODE_TARGET } from "./claudeCode";
import { OPEN_CODE_TARGET } from "./openCode";
import { TRAE_TARGET } from "./trae";
export { GHE_COPILOT_TARGET } from "./ghe-copilot";
export const ALL_TARGETS: MitmTarget[] = [
ANTIGRAVITY_TARGET,
KIRO_TARGET,
COPILOT_TARGET,
GHE_COPILOT_TARGET,
CODEX_TARGET,
CURSOR_TARGET,
ZED_TARGET,

View File

@@ -9,7 +9,8 @@ export type AgentId =
| "zed"
| "claude-code"
| "open-code"
| "trae";
| "trae"
| "ghe-copilot";
/**
* Minimal abstract interface for MitmHandlerBase.
@@ -48,7 +49,7 @@ export type MitmTargetView = Omit<MitmTarget, "handler">;
export const MitmTargetSchema = z.object({
id: z.enum([
"antigravity", "kiro", "copilot", "codex", "cursor", "zed",
"claude-code", "open-code", "trae",
"claude-code", "open-code", "trae", "ghe-copilot",
]),
name: z.string(),
icon: z.string(),

View File

@@ -13,6 +13,7 @@ import {
looksLikeCodexSessionJson,
parseCodexSessionJson,
} from "@/lib/oauth/utils/codexSessionImport";
import GheConfigStep from "@/shared/components/oauthModal/GheConfigStep";
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy"]);
@@ -79,6 +80,7 @@ export default function OAuthModal({
const [error, setError] = useState(null);
const [isDeviceCode, setIsDeviceCode] = useState(false);
const [deviceData, setDeviceData] = useState(null);
const [gheUrl, setGheUrl] = useState("");
const [polling, setPolling] = useState(false);
// API-key paste mode: for providers that accept a token directly (windsurf, devin-cli)
const [showPasteToken, setShowPasteToken] = useState(
@@ -288,9 +290,10 @@ export default function OAuthModal({
try {
setError(null);
// Device code flow (GitHub, Qwen, Kiro, Kimi Coding, KiloCode)
// Device code flow (GitHub, Qwen, Kiro, Kimi Coding, KiloCode, GHE Copilot)
if (
provider === "github" ||
provider === "ghe-copilot" ||
provider === "qwen" ||
provider === "kiro" ||
provider === "amazon-q" ||
@@ -301,6 +304,12 @@ export default function OAuthModal({
setIsDeviceCode(true);
setStep("waiting");
// GHE Copilot needs the enterprise URL collected first (see ghe-config step)
if (provider === "ghe-copilot" && !gheUrl.trim()) {
setStep("ghe-config");
return;
}
const deviceCodeUrl = new URL(`/api/oauth/${provider}/device-code`, window.location.origin);
if (
(provider === "kiro" || provider === "amazon-q") &&
@@ -315,6 +324,9 @@ export default function OAuthModal({
deviceCodeUrl.searchParams.set("region", idc.region.trim());
}
}
if (provider === "ghe-copilot" && gheUrl.trim()) {
deviceCodeUrl.searchParams.set("gheUrl", gheUrl.trim());
}
const res = await fetch(deviceCodeUrl.toString());
const data = (await parseResponseBody(res)) as Record<string, unknown>;
@@ -337,7 +349,9 @@ export default function OAuthModal({
_clientSecret: data._clientSecret,
_region: data._region,
}
: null;
: provider === "ghe-copilot" && gheUrl.trim()
? { gheUrl: gheUrl.trim() }
: null;
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
return;
}
@@ -505,6 +519,7 @@ export default function OAuthModal({
onSuccess,
reauthConnection,
idcConfig,
gheUrl,
]);
// Reset guard when modal closes
@@ -826,6 +841,17 @@ export default function OAuthModal({
{/* OAuth flow steps — hidden when paste-token mode is active */}
{(!supportsTokenPaste || !showPasteToken) && (
<>
{/* GHE Copilot: collect the GitHub Enterprise base URL before starting */}
{provider === "ghe-copilot" && step === "ghe-config" && (
<GheConfigStep
gheUrl={gheUrl}
setGheUrl={setGheUrl}
error={error}
setError={setError}
startOAuthFlow={startOAuthFlow}
/>
)}
{/* Waiting Step (Localhost - popup mode) */}
{step === "waiting" && !isDeviceCode && (
<div className="text-center py-6">

View File

@@ -371,6 +371,7 @@ const LOBE_PROVIDER_ALIASES = {
github: "GithubCopilot",
"github-models": "Github",
"github-copilot": "GithubCopilot",
"ghe-copilot": "GithubCopilot",
glm: "Zhipu",
"glm-cn": "Zhipu",
glmt: "Zhipu",

View File

@@ -0,0 +1,51 @@
"use client";
import Button from "@/shared/components/Button";
import Input from "@/shared/components/Input";
type GheConfigStepProps = {
gheUrl: string;
setGheUrl: (value: string) => void;
error: unknown;
setError: (value: string) => void;
startOAuthFlow: () => void;
};
/** GHE Copilot: collect the GitHub Enterprise base URL before starting the OAuth flow. */
export default function GheConfigStep({
gheUrl,
setGheUrl,
error,
setError,
startOAuthFlow,
}: GheConfigStepProps) {
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-text-muted">
Enter the base URL of your GitHub Enterprise instance (e.g.{" "}
<code className="font-mono">https://ghe.yourcompany.com</code>).
</p>
<Input
value={gheUrl}
onChange={(e) => setGheUrl(e.target.value)}
placeholder="https://ghe.yourcompany.com"
label="GitHub Enterprise URL"
type="url"
/>
<Button
onClick={() => {
if (!gheUrl.trim()) {
setError("GitHub Enterprise URL is required");
return;
}
startOAuthFlow();
}}
fullWidth
disabled={!gheUrl.trim()}
>
Connect
</Button>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
);
}

View File

@@ -3,6 +3,17 @@
* Pure data literal; re-exported by the providers.ts barrel. No behavior change.
*/
export const OAUTH_PROVIDERS = {
"ghe-copilot": {
id: "ghe-copilot",
alias: "ghe-copilot",
name: "GitHub Enterprise Copilot",
icon: "code",
color: "#10B981",
subscriptionRisk: true,
riskNoticeVariant: "oauth",
authHint:
"Enter your GHE instance URL (e.g., https://ghe.company.com) in provider settings, then authenticate via device flow.",
},
"xai-oauth": {
id: "xai-oauth",
alias: "xao",

View File

@@ -54,12 +54,53 @@ function validateCacheBlock(data: Record<string, unknown>, ctx: z.RefinementCtx)
}
}
/**
* gheUrl must parse and use HTTPS. Shared by the Zod refinement above and by the
* OAuth route's raw entry points (searchParams / device-flow extraData), so a
* malformed or non-HTTPS enterprise URL is rejected before any upstream fetch.
*/
export function isValidGheUrl(raw: string): boolean {
try {
return new URL(raw).protocol === "https:";
} catch {
return false;
}
}
export function validateProviderSpecificData(
data: Record<string, unknown> | undefined,
ctx: z.RefinementCtx
): void {
if (!data) return;
const gheUrl = data.gheUrl;
if (gheUrl !== undefined) {
if (typeof gheUrl !== "string") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.gheUrl must be a string",
path: ["gheUrl"],
});
} else {
try {
const parsed = new URL(gheUrl);
if (parsed.protocol !== "https:") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.gheUrl must use HTTPS",
path: ["gheUrl"],
});
}
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.gheUrl must be a valid HTTPS URL",
path: ["gheUrl"],
});
}
}
}
const baseUrl = data.baseUrl;
if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) {
ctx.addIssue({

View File

@@ -93,6 +93,21 @@ export const createProviderSchema = z
path: ["providerSpecificData", "cx"],
});
}
const gheUrl =
data.providerSpecificData && typeof data.providerSpecificData === "object"
? (data.providerSpecificData as Record<string, unknown>).gheUrl
: undefined;
if (
data.provider === "ghe-copilot" &&
(typeof gheUrl !== "string" || gheUrl.trim().length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "GitHub Enterprise URL (gheUrl) is required",
path: ["providerSpecificData", "gheUrl"],
});
}
});
export const bulkCreateProviderSchema = z
@@ -133,6 +148,19 @@ export const bulkCreateProviderSchema = z
});
}
}
if (data.provider === "ghe-copilot") {
const gheUrl =
data.providerSpecificData && typeof data.providerSpecificData === "object"
? (data.providerSpecificData as Record<string, unknown>).gheUrl
: undefined;
if (typeof gheUrl !== "string" || gheUrl.trim().length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "GitHub Enterprise URL (gheUrl) is required",
path: ["providerSpecificData", "gheUrl"],
});
}
}
if (data.provider === "cloudflare-ai") {
// Cloudflare Workers AI builds its per-connection URL from accountId, so every
// bulk entry must carry its own non-empty account id (name|accountId|apiKey).

View File

@@ -0,0 +1,125 @@
import test from "node:test";
import assert from "node:assert/strict";
import { GheCopilotExecutor } from "../../open-sse/executors/ghe-copilot.ts";
import { GHE_COPILOT_TARGET } from "../../src/mitm/targets/ghe-copilot.ts";
import type { ProviderCredentials } from "../../open-sse/executors/base.ts";
test("GHE_COPILOT_TARGET has correct id and patterns", () => {
assert.strictEqual(GHE_COPILOT_TARGET.id, "ghe-copilot");
assert.deepStrictEqual(GHE_COPILOT_TARGET.endpointPatterns, [
"/chat/completions",
"/v1/chat/completions",
"/responses",
]);
});
test("buildUrl uses gheUrl for chat/completions with credentials", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
};
const url = executor.buildUrl("gpt-4o", true, 0, credentials);
assert.strictEqual(url, "https://ghe.company.com/chat/completions");
});
test("buildUrl uses gheUrl for responses endpoint with codex model", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
};
const url = executor.buildUrl("gpt-4o-codex", true, 0, credentials);
assert.strictEqual(url, "https://ghe.company.com/chat/completions");
});
test("buildUrl handles gheUrl with trailing slash", () => {
const exec = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com/",
clientId: "test",
clientSecret: "test",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com/" },
};
const url = exec.buildUrl("gpt-4o", true, 0, credentials);
assert.strictEqual(url, "https://ghe.company.com/chat/completions");
});
test("buildUrl handles gheUrl already containing /chat/completions", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com/chat/completions" },
};
const url = executor.buildUrl("gpt-4o", true, 0, credentials);
assert.strictEqual(url, "https://ghe.company.com/chat/completions");
});
test("buildUrl throws without gheUrl in credentials", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = { providerSpecificData: {} };
assert.throws(() => executor.buildUrl("gpt-4o", true, 0, credentials), {
message: "GHE Copilot executor requires gheUrl in providerSpecificData",
});
});
test("refreshCopilotToken delegates to GHE token endpoint", async () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
copilotToken: "test-token",
};
const result = await executor.refreshCopilotToken("github-access-token", undefined, credentials);
assert.strictEqual(result, null);
});
test("refreshGitHubToken delegates to GHE OAuth endpoint", async () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
};
const result = await executor.refreshGitHubToken("refresh-token", undefined, credentials);
assert.strictEqual(result, null);
});
test("executor extends GithubExecutor", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
assert.strictEqual(executor.constructor.name, "GheCopilotExecutor");
});
test("isValidGheUrl accepts https enterprise hosts and rejects malformed or non-https input", async () => {
const { isValidGheUrl } = await import("../../src/shared/validation/providerSpecificData.ts");
assert.equal(isValidGheUrl("https://github.mycorp.example"), true);
assert.equal(isValidGheUrl("https://10.0.0.5"), true); // on-prem GHE on private IP is the primary use case
assert.equal(isValidGheUrl("http://github.mycorp.example"), false);
assert.equal(isValidGheUrl("javascript:alert(1)"), false);
assert.equal(isValidGheUrl("not a url"), false);
});