Files
OmniRoute/open-sse/executors/copilotIdentityFallback.ts
Tuan Dinh 6fec29ca2d fix(copilot): fallback to copilot-chat on 403 identity denial for standard provider (#13705)
* fix(copilot): fallback to copilot-chat on 403 identity denial for standard provider

* fix(copilot): document COPILOT_INTEGRATION_ID, extract identity fallback, add changelog

Adds the missing COPILOT_INTEGRATION_ID entry to .env.example (fixes
tests/unit/issue-7793-env-doc-sync-repro.test.ts), extracts the GitHub
Copilot 403 identity fallback out of open-sse/executors/base.ts into its
own module (open-sse/executors/copilotIdentityFallback.ts) to bring the
file back under the frozen file-size ratchet, and adds a changelog.d/fixes
fragment for the PR.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: tuandinh0801 <tuandinh0801@users.noreply.github.com>
2026-09-18 11:58:48 -03:00

110 lines
3.8 KiB
TypeScript

import { HTTP_STATUS } from "../config/constants.ts";
import {
GITHUB_COPILOT_CLI_INTEGRATION_ID,
GITHUB_COPILOT_CHAT_INTEGRATION_ID,
resolveCopilotIntegrationIdOverride,
} from "../config/providerHeaderProfiles.ts";
import type { ExecutorLog } from "./base.ts";
export function readHeaderCaseInsensitive(
headers: Record<string, string> | null | undefined,
name: string
): string | null {
if (!headers) return null;
const target = name.toLowerCase();
const direct = headers[name] ?? headers[target];
if (typeof direct === "string") return direct;
for (const key in headers) {
if (key.toLowerCase() === target && typeof headers[key] === "string") {
return headers[key];
}
}
return null;
}
export type CopilotIdentityFallbackArgs = {
response: Response;
url: string;
fetchOptions: RequestInit;
clientHeaders?: Record<string, string> | null;
fetchWithStartTimeout: (requestUrl: string, requestOptions: RequestInit) => Promise<Response>;
};
export type CopilotIdentityFallbackResult = {
response: Response;
finalHeaders: Record<string, string>;
};
/**
* GitHub Copilot 403 identity fallback: business/org accounts may reject the CLI
* identity (copilot-developer-cli) while allowing copilot-chat. Returns a bound
* per-`execute()`-call helper (provider + log fixed, retry state kept in the
* closure) so at most one identity retry happens across every fallback URL,
* gated strictly to standard github (not ghe-copilot), and only when identity
* was not explicitly pinned by the client or the operator.
*/
export function createCopilotIdentityFallback(provider: string, log?: ExecutorLog | null) {
let retried = false;
return async function applyCopilotIdentityFallback(
args: CopilotIdentityFallbackArgs
): Promise<CopilotIdentityFallbackResult> {
const { response, url, fetchOptions, clientHeaders, fetchWithStartTimeout } = args;
const finalHeaders = fetchOptions.headers as Record<string, string>;
if (
retried ||
provider !== "github" ||
response.status !== HTTP_STATUS.FORBIDDEN ||
resolveCopilotIntegrationIdOverride() ||
process.env.COPILOT_INTEGRATION_ID?.trim() ||
readHeaderCaseInsensitive(clientHeaders, "copilot-integration-id")?.trim()
) {
return { response, finalHeaders };
}
const currentIntegrationId = readHeaderCaseInsensitive(finalHeaders, "copilot-integration-id");
if (currentIntegrationId !== GITHUB_COPILOT_CLI_INTEGRATION_ID) {
return { response, finalHeaders };
}
const errText = await response
.clone()
.text()
.catch(() => "");
const isQuotaError = /quota|rate[_-]?limit|exceeded|insufficient_quota/i.test(errText);
const hasIdentityEvidence =
!isQuotaError &&
(/access denied/i.test(errText) ||
(/copilot/i.test(errText) && /403/.test(errText)) ||
/integration[_-]?id/i.test(errText) ||
/not (?:permitted|allowed|authorized)/i.test(errText));
if (!hasIdentityEvidence) {
return { response, finalHeaders };
}
await response.text().catch(() => "");
log?.warn?.(
"COPILOT_IDENTITY",
`Standard GitHub Copilot identity ${GITHUB_COPILOT_CLI_INTEGRATION_ID} denied (403) — retrying once with ${GITHUB_COPILOT_CHAT_INTEGRATION_ID}`
);
const retryHeaders: Record<string, string> = {
...finalHeaders,
"copilot-integration-id": GITHUB_COPILOT_CHAT_INTEGRATION_ID,
};
for (const key of Object.keys(retryHeaders)) {
if (key.toLowerCase() === "copilot-integration-id" && key !== "copilot-integration-id") {
delete retryHeaders[key];
}
}
retried = true;
const retryResponse = await fetchWithStartTimeout(url, {
...fetchOptions,
headers: retryHeaders,
});
return { response: retryResponse, finalHeaders: retryHeaders };
};
}