diff --git a/changelog.d/features/7546-ghe-copilot.md b/changelog.d/features/7546-ghe-copilot.md new file mode 100644 index 0000000000..fadd45ddde --- /dev/null +++ b/changelog.d/features/7546-ghe-copilot.md @@ -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)) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index c75f434896..6fff4ea428 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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).", diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 1729ddb951..fba6e696b0 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -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 = { kilocode: kilocodeProvider, "github-models": github_modelsProvider, github: githubProvider, + "ghe-copilot": gheCopilotProvider, dify: difyProvider, ovhcloud: ovhcloudProvider, claude: claudeProvider, diff --git a/open-sse/config/providers/registry/ghe-copilot/index.ts b/open-sse/config/providers/registry/ghe-copilot/index.ts new file mode 100644 index 0000000000..fb5c6ca438 --- /dev/null +++ b/open-sse/config/providers/registry/ghe-copilot/index.ts @@ -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, + }, + ], +}; diff --git a/open-sse/executors/ghe-copilot.ts b/open-sse/executors/ghe-copilot.ts new file mode 100644 index 0000000000..694f51fede --- /dev/null +++ b/open-sse/executors/ghe-copilot.ts @@ -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) { + 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; + 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 { + 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 { + 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; \ No newline at end of file diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 0aae47d8d2..ada346d03a 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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"), diff --git a/open-sse/services/githubCopilotModels.ts b/open-sse/services/githubCopilotModels.ts index 5ccbcb623b..be7994bfb9 100644 --- a/open-sse/services/githubCopilotModels.ts +++ b/open-sse/services/githubCopilotModels.ts @@ -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 { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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(); + 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 `/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 { + 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 []; + } +} diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 1d6819cbe8..154f47bbf8 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -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, () => diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 1eae0438ac..b9db36edf6 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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 + // /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 diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index 22c4bdc601..0387cdf895 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -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, + }, }; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 4c0a58a0d4..926f8e1fa1 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -891,6 +891,42 @@ export function autoMigrateLegacyEncryptedConnections(): number { return migratedCount; } +export function getGheCopilotHosts(): string[] { + const hosts = new Set(); + 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 { diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index bf0dee233f..1a75643be7 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -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 = { diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index 38cdba9823..da0b63e552 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -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 { diff --git a/src/lib/oauth/providers/ghe-copilot.ts b/src/lib/oauth/providers/ghe-copilot.ts new file mode 100644 index 0000000000..38f0703cfa --- /dev/null +++ b/src/lib/oauth/providers/ghe-copilot.ts @@ -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, + }, + }), +}; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index a1a82c18dd..801f78a424 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -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, diff --git a/src/mitm/dns/provision.ts b/src/mitm/dns/provision.ts index 8679e37ffb..5e8577f4d3 100644 --- a/src/mitm/dns/provision.ts +++ b/src/mitm/dns/provision.ts @@ -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"); diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index cbfc8f9f85..7c8fcb1a97 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -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", })), diff --git a/src/mitm/repair.ts b/src/mitm/repair.ts index 4ee2f160f2..9dd3d49021 100644 --- a/src/mitm/repair.ts +++ b/src/mitm/repair.ts @@ -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(); 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); diff --git a/src/mitm/targets/ghe-copilot.ts b/src/mitm/targets/ghe-copilot.ts new file mode 100644 index 0000000000..09f24719bf --- /dev/null +++ b/src/mitm/targets/ghe-copilot.ts @@ -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", +}; \ No newline at end of file diff --git a/src/mitm/targets/index.ts b/src/mitm/targets/index.ts index a82760ca77..b845080602 100644 --- a/src/mitm/targets/index.ts +++ b/src/mitm/targets/index.ts @@ -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, diff --git a/src/mitm/types.ts b/src/mitm/types.ts index a153cd6d93..aba8c499ea 100644 --- a/src/mitm/types.ts +++ b/src/mitm/types.ts @@ -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; 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(), diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 9daefd84e9..1b6558a1b6 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -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; @@ -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" && ( + + )} + {/* Waiting Step (Localhost - popup mode) */} {step === "waiting" && !isDeviceCode && (
diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index 435450ca3c..71b751ff07 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -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", diff --git a/src/shared/components/oauthModal/GheConfigStep.tsx b/src/shared/components/oauthModal/GheConfigStep.tsx new file mode 100644 index 0000000000..a80570cd99 --- /dev/null +++ b/src/shared/components/oauthModal/GheConfigStep.tsx @@ -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 ( +
+

+ Enter the base URL of your GitHub Enterprise instance (e.g.{" "} + https://ghe.yourcompany.com). +

+ setGheUrl(e.target.value)} + placeholder="https://ghe.yourcompany.com" + label="GitHub Enterprise URL" + type="url" + /> + + {error &&

{error}

} +
+ ); +} diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index ee13226440..ce23f1dc5d 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -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", diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 69a852bf85..2838b019b5 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -54,12 +54,53 @@ function validateCacheBlock(data: Record, 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 | 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({ diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index 0fd917e4fa..5033ca0abf 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -93,6 +93,21 @@ export const createProviderSchema = z path: ["providerSpecificData", "cx"], }); } + + const gheUrl = + data.providerSpecificData && typeof data.providerSpecificData === "object" + ? (data.providerSpecificData as Record).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).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). diff --git a/tests/unit/ghe-copilot.test.ts b/tests/unit/ghe-copilot.test.ts new file mode 100644 index 0000000000..2347557855 --- /dev/null +++ b/tests/unit/ghe-copilot.test.ts @@ -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); +});