mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
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>
This commit is contained in:
@@ -1420,6 +1420,13 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# Override the advertised GitHub Copilot CLI version independently of
|
||||
# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts.
|
||||
# GITHUB_COPILOT_CLI_VERSION=1.0.82
|
||||
#
|
||||
# Pin the `copilot-integration-id` header sent to standard GitHub Copilot,
|
||||
# overriding the default copilot-developer-cli identity (and disabling the
|
||||
# automatic 403-identity fallback to copilot-chat). Set this only if your
|
||||
# Copilot account/org requires a specific integration id. Used by:
|
||||
# open-sse/config/providerHeaderProfiles.ts, open-sse/executors/copilotIdentityFallback.ts.
|
||||
# COPILOT_INTEGRATION_ID=copilot-chat
|
||||
|
||||
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
|
||||
# from the Codex Responses stream. These frames break the OpenAI SDK's
|
||||
|
||||
1
changelog.d/fixes/13705-copilot-403-identity-fallback.md
Normal file
1
changelog.d/fixes/13705-copilot-403-identity-fallback.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(copilot):** fall back to the `copilot-chat` identity once when a standard GitHub Copilot account rejects the CLI identity with a 403, without breaking Enterprise Copilot ([#13705](https://github.com/diegosouzapw/OmniRoute/pull/13705)) — thanks @tuandinh0801
|
||||
@@ -603,6 +603,7 @@ Built-in credentials for **localhost development**. For remote deployments, regi
|
||||
| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — |
|
||||
| `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | Public client. |
|
||||
| `GHE_COPILOT_OAUTH_CLIENT_ID` | GHE Copilot | Optional override for GitHub Enterprise Copilot's OAuth client id. Falls back to `GITHUB_OAUTH_CLIENT_ID`'s public default when unset. |
|
||||
| `COPILOT_INTEGRATION_ID` | GitHub Copilot | Optional override for the GitHub Copilot client integration ID sent in `Copilot-Integration-Id` and `Editor-Plugin-Version` headers. Defaults to `copilot-developer-cli`. |
|
||||
| `WINDSURF_API_KEY` | Windsurf / Devin (v3.8) | API key fallback used by `open-sse/executors/devin-cli.ts` when no per-connection credential is available. Optional. |
|
||||
| `CLI_DEVIN_BIN` | Devin CLI (v3.8) | Custom path to the Devin CLI binary (`devin`). Resolved by `open-sse/executors/devin-cli.ts`. |
|
||||
| `GITLAB_DUO_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | OAuth client ID for GitLab Duo. Register an app at `https://gitlab.com/-/profile/applications` with redirect URI `<NEXT_PUBLIC_BASE_URL>/callback` and scopes `api, read_user, openid, profile, email`. Falls back to `GITLAB_OAUTH_CLIENT_ID`. |
|
||||
|
||||
@@ -41,12 +41,26 @@ export const GITHUB_COPILOT_REFRESH_USER_AGENT = "GithubCopilot/1.0";
|
||||
export function getGitHubCopilotChatUserAgent(): string {
|
||||
return `GitHubCopilotChat/${getGitHubCopilotCliVersion()}`;
|
||||
}
|
||||
export const GITHUB_COPILOT_INTEGRATION_ID = "copilot-developer-cli";
|
||||
export const GITHUB_COPILOT_CLI_INTEGRATION_ID = "copilot-developer-cli";
|
||||
export const GITHUB_COPILOT_CHAT_INTEGRATION_ID = "copilot-chat";
|
||||
export const GITHUB_COPILOT_INTEGRATION_ID = GITHUB_COPILOT_CLI_INTEGRATION_ID;
|
||||
export const GITHUB_COPILOT_OPENAI_INTENT = "conversation-agent";
|
||||
export const GITHUB_COPILOT_INTERACTION_TYPE = "conversation-user";
|
||||
export const GITHUB_COPILOT_HARNESS_ID = "copilot-sdk";
|
||||
export const GITHUB_COPILOT_DEFAULT_INITIATOR = "user";
|
||||
|
||||
export function normalizeCopilotIntegrationId(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || /[\r\n]/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveCopilotIntegrationIdOverride(): string | null {
|
||||
const raw = typeof process === "undefined" ? undefined : process.env?.COPILOT_INTEGRATION_ID;
|
||||
return normalizeCopilotIntegrationId(raw);
|
||||
}
|
||||
|
||||
// Stable per-install device fingerprint (the CLI's X-Client-Machine-Id). The
|
||||
// real @github/copilot CLI sends ONE stable UUID on every inference + /models
|
||||
// call (verified identical across all captured requests) — a per-call random id
|
||||
@@ -86,7 +100,7 @@ export const CURSOR_REGISTRY_VERSION = "3.9";
|
||||
export function getGitHubCopilotChatHeaders(
|
||||
accept = "application/json",
|
||||
initiator = GITHUB_COPILOT_DEFAULT_INITIATOR,
|
||||
options: { vision?: boolean; intent?: string } = {}
|
||||
options: { vision?: boolean; intent?: string; integrationId?: string } = {}
|
||||
): Record<string, string> {
|
||||
// Matches the live @github/copilot CLI 1.0.81-6 inference request 1:1 (MITM-
|
||||
// captured). NOTE the CLI does NOT send `editor-plugin-version` nor
|
||||
@@ -97,8 +111,12 @@ export function getGitHubCopilotChatHeaders(
|
||||
// is the catalog-unlock lever; the stable X-Client-Machine-Id is the CLI's
|
||||
// per-install device fingerprint.
|
||||
const version = getGitHubCopilotCliVersion();
|
||||
const integrationId =
|
||||
normalizeCopilotIntegrationId(options.integrationId) ||
|
||||
resolveCopilotIntegrationIdOverride() ||
|
||||
GITHUB_COPILOT_CLI_INTEGRATION_ID;
|
||||
const headers: Record<string, string> = {
|
||||
"copilot-integration-id": GITHUB_COPILOT_INTEGRATION_ID,
|
||||
"copilot-integration-id": integrationId,
|
||||
"editor-version": `copilot/${version}`,
|
||||
"user-agent": `copilot/${version}`,
|
||||
"openai-intent": options.intent || GITHUB_COPILOT_OPENAI_INTENT,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
normalizeAnthropicHeaderVariants,
|
||||
} from "../config/anthropicHeaders.ts";
|
||||
import { applyContextEditingToBody } from "../config/contextEditing.ts";
|
||||
import { createCopilotIdentityFallback } from "./copilotIdentityFallback.ts";
|
||||
import {
|
||||
findOffendingField,
|
||||
detectUnsupportedParam,
|
||||
@@ -317,7 +318,6 @@ export type ExecutorExecuteResult =
|
||||
transformedBody?: unknown;
|
||||
transport?: string;
|
||||
};
|
||||
|
||||
export class BaseExecutor {
|
||||
provider: string;
|
||||
config: ProviderConfig;
|
||||
@@ -821,24 +821,17 @@ export class BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// Set by the Context Editing 400-fallback below: once an upstream rejects the
|
||||
// `context_management` param, suppress its re-injection on every later
|
||||
// retry/fallback URL (each iteration rebuilds a fresh `transformedBody`).
|
||||
// Context Editing 400-fallback below: suppresses `context_management` re-injection
|
||||
// on later retry/fallback URLs once an upstream rejects it.
|
||||
let contextEditingDisabled = false;
|
||||
// Tracks which request fields have already been stripped via the generic 400
|
||||
// field-downgrade below, so each known field is stripped at most once across
|
||||
// all fallback URLs (bounded retry loop).
|
||||
// Fields already stripped by the generic 400 field-downgrade below (once each,
|
||||
// across all fallback URLs — bounded retry loop).
|
||||
const strippedFields = new Set<string>();
|
||||
// Set by the thinking_budget 400 clamp-and-retry below: the upstream's
|
||||
// advertised max (parsed from the error) is applied to every later
|
||||
// retry/fallback URL so they don't re-hit the same 400. The clamp itself
|
||||
// fires at most once per URL (guarded inline) so a persistent 400 cannot
|
||||
// loop. The learned cap is also recorded process-wide via
|
||||
// recordLearnedThinkingCap so future requests skip the 400 entirely.
|
||||
// thinking_budget 400 clamp-and-retry below: upstream's learned max applied to
|
||||
// later retry URLs (bounded per URL); also recorded via recordLearnedThinkingCap.
|
||||
let thinkingBudgetClampedMax: number | null = null;
|
||||
// Set by the reasoning_effort 4xx clamp-and-retry below — guards the same
|
||||
// "fires at most once per URL" invariant as thinkingBudgetClampedMax above.
|
||||
let reasoningEffortClamped = false;
|
||||
let reasoningEffortClamped = false; // reasoning_effort 4xx clamp-and-retry below.
|
||||
const applyCopilotIdentityFallback = createCopilotIdentityFallback(this.provider, log);
|
||||
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const requestCredentials = withForcedResponsesUpstream(
|
||||
@@ -1465,12 +1458,9 @@ export class BaseExecutor {
|
||||
body: bodyString,
|
||||
};
|
||||
|
||||
// OpenRouter `:free`-variant local window (#6842): record every real
|
||||
// dispatch attempt (failed attempts still consume a request slot per
|
||||
// OpenRouter's own accounting) and self-correct the local counters
|
||||
// from the upstream `X-RateLimit-*` headers on the response. Scoped
|
||||
// to `:free` models only — no-op (and no extra work) for every other
|
||||
// OpenRouter request or provider.
|
||||
// OpenRouter `:free`-variant local window (#6842): record every dispatch
|
||||
// attempt and self-correct local counters from `X-RateLimit-*` headers.
|
||||
// Scoped to `:free` models only — no-op for every other request/provider.
|
||||
const openrouterFreeWindowAccountKey =
|
||||
this.provider === "openrouter" &&
|
||||
isFreeVariantModel(model) &&
|
||||
@@ -1481,9 +1471,7 @@ export class BaseExecutor {
|
||||
recordFreeWindowAttempt(openrouterFreeWindowAccountKey);
|
||||
}
|
||||
|
||||
// WAF burst guard: agentrouter.org's content filter becomes more
|
||||
// aggressive after rapid requests. Enforce a small inter-request gap
|
||||
// to avoid tripping it. See open-sse/services/wafRateLimit.ts.
|
||||
// WAF burst guard for agentrouter.org's content filter — see wafRateLimit.ts.
|
||||
if (this.provider === "agentrouter") {
|
||||
await gateOutboundRequest(`agentrouter:${url}`);
|
||||
}
|
||||
@@ -1494,6 +1482,14 @@ export class BaseExecutor {
|
||||
correctFromRateLimitHeaders(openrouterFreeWindowAccountKey, response.headers);
|
||||
}
|
||||
|
||||
({ response, finalHeaders } = await applyCopilotIdentityFallback({
|
||||
response,
|
||||
url,
|
||||
fetchOptions,
|
||||
clientHeaders,
|
||||
fetchWithStartTimeout,
|
||||
}));
|
||||
|
||||
// Context Editing 400-fallback for Claude-compatible relays.
|
||||
if (
|
||||
response.status === HTTP_STATUS.BAD_REQUEST &&
|
||||
|
||||
109
open-sse/executors/copilotIdentityFallback.ts
Normal file
109
open-sse/executors/copilotIdentityFallback.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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 };
|
||||
};
|
||||
}
|
||||
@@ -331,12 +331,17 @@ export class GithubExecutor extends BaseExecutor {
|
||||
): Record<string, string> {
|
||||
const token = this.getCopilotToken(credentials) || credentials.accessToken;
|
||||
const initiator = this.resolveInitiatorHeader(clientHeaders);
|
||||
const clientIntegrationId =
|
||||
this.readClientHeader(clientHeaders, "copilot-integration-id") || undefined;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator),
|
||||
...getGitHubCopilotChatHeaders(
|
||||
stream ? "text/event-stream" : "application/json",
|
||||
initiator,
|
||||
clientIntegrationId ? { integrationId: clientIntegrationId } : undefined
|
||||
),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"x-request-id":
|
||||
crypto.randomUUID?.() || randomIdFallback(),
|
||||
"x-request-id": crypto.randomUUID?.() || randomIdFallback(),
|
||||
};
|
||||
|
||||
// Per-call / per-conversation / per-turn correlation ids the @github/copilot
|
||||
@@ -344,13 +349,12 @@ export class GithubExecutor extends BaseExecutor {
|
||||
// id (getGitHubCopilotMachineId) is stable per-install; these three are
|
||||
// fresh uuids. A Copilot-aware client may pin the session/task ids across a
|
||||
// conversation via its own headers — honor those when present, else mint.
|
||||
const genId = () =>
|
||||
crypto.randomUUID?.() || randomIdFallback();
|
||||
headers["x-interaction-id"] = this.readClientHeader(clientHeaders, "x-interaction-id") || genId();
|
||||
const genId = () => crypto.randomUUID?.() || randomIdFallback();
|
||||
headers["x-interaction-id"] =
|
||||
this.readClientHeader(clientHeaders, "x-interaction-id") || genId();
|
||||
headers["x-client-session-id"] =
|
||||
this.readClientHeader(clientHeaders, "x-client-session-id") || genId();
|
||||
headers["x-agent-task-id"] =
|
||||
this.readClientHeader(clientHeaders, "x-agent-task-id") || genId();
|
||||
headers["x-agent-task-id"] = this.readClientHeader(clientHeaders, "x-agent-task-id") || genId();
|
||||
// Repository correlation sentinels. The CLI sends the working repo's nwo/host
|
||||
// or these literals when there is no repository context. OmniRoute is not
|
||||
// repo-scoped, so forward a client-supplied value when present, else sentinel.
|
||||
@@ -376,7 +380,10 @@ export class GithubExecutor extends BaseExecutor {
|
||||
// /v1/messages proxy returns an empty content block for image turns unless
|
||||
// copilot-vision-request:true is present; a Copilot-aware harness that sends
|
||||
// it should have it honored rather than stripped.
|
||||
if ((this.readClientHeader(clientHeaders, "copilot-vision-request") || "").toLowerCase() === "true") {
|
||||
if (
|
||||
(this.readClientHeader(clientHeaders, "copilot-vision-request") || "").toLowerCase() ===
|
||||
"true"
|
||||
) {
|
||||
headers["copilot-vision-request"] = "true";
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { GithubExecutor } from "../../open-sse/executors/github.ts";
|
||||
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.ts";
|
||||
import {
|
||||
GITHUB_COPILOT_CLI_INTEGRATION_ID,
|
||||
GITHUB_COPILOT_CHAT_INTEGRATION_ID,
|
||||
} from "../../open-sse/config/providerHeaderProfiles.ts";
|
||||
|
||||
function registerModel(provider, model) {
|
||||
PROVIDER_MODELS[provider] = [...(PROVIDER_MODELS[provider] || []), model];
|
||||
@@ -593,3 +597,188 @@ test("GithubExecutor.transformRequest strips invalid synthetic Responses reasoni
|
||||
assert.equal(result.input[0].id, undefined);
|
||||
assert.equal(result.input[0].type, "reasoning");
|
||||
});
|
||||
|
||||
test("GithubExecutor.buildHeaders honors case-insensitive client copilot-integration-id", () => {
|
||||
const executor = new GithubExecutor();
|
||||
|
||||
const lowerCase = executor.buildHeaders({ accessToken: "gh" }, true, {
|
||||
"copilot-integration-id": "custom-cli-id",
|
||||
});
|
||||
assert.equal(lowerCase["copilot-integration-id"], "custom-cli-id");
|
||||
|
||||
const mixedCase = executor.buildHeaders({ accessToken: "gh" }, true, {
|
||||
"CoPiLoT-InTeGrAtIoN-iD": "custom-mixed-id",
|
||||
});
|
||||
assert.equal(mixedCase["copilot-integration-id"], "custom-mixed-id");
|
||||
|
||||
const defaultHeaders = executor.buildHeaders({ accessToken: "gh" });
|
||||
assert.equal(defaultHeaders["copilot-integration-id"], GITHUB_COPILOT_CLI_INTEGRATION_ID);
|
||||
});
|
||||
|
||||
test("GithubExecutor.execute retries 403 identity denial once with copilot-chat", async () => {
|
||||
const executor = new GithubExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const seenIntegrationIds: string[] = [];
|
||||
|
||||
globalThis.fetch = async (_url, init: RequestInit = {}) => {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
const integrationId = headers["copilot-integration-id"];
|
||||
seenIntegrationIds.push(integrationId);
|
||||
|
||||
if (integrationId === GITHUB_COPILOT_CLI_INTEGRATION_ID) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: "Access denied: copilot-developer-cli is not permitted by organization policy",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "gh-access-token",
|
||||
providerSpecificData: { copilotToken: "copilot-token" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(seenIntegrationIds, [
|
||||
GITHUB_COPILOT_CLI_INTEGRATION_ID,
|
||||
GITHUB_COPILOT_CHAT_INTEGRATION_ID,
|
||||
]);
|
||||
const res = result as { response: Response; headers: Record<string, string> };
|
||||
assert.equal(res.response.status, 200);
|
||||
assert.equal(res.headers["copilot-integration-id"], GITHUB_COPILOT_CHAT_INTEGRATION_ID);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GithubExecutor.execute repeated 403 identity denial retries at most once", async () => {
|
||||
const executor = new GithubExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const seenIntegrationIds: string[] = [];
|
||||
|
||||
globalThis.fetch = async (_url, init: RequestInit = {}) => {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
seenIntegrationIds.push(headers["copilot-integration-id"]);
|
||||
return new Response(JSON.stringify({ message: "Access denied: Copilot 403 Forbidden" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "gh-access-token",
|
||||
providerSpecificData: { copilotToken: "copilot-token" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(seenIntegrationIds, [
|
||||
GITHUB_COPILOT_CLI_INTEGRATION_ID,
|
||||
GITHUB_COPILOT_CHAT_INTEGRATION_ID,
|
||||
]);
|
||||
const res = result as { response: Response };
|
||||
assert.equal(res.response.status, 403);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GithubExecutor.execute suppresses 403 fallback when client header or env pin is present or on quota error", async () => {
|
||||
const executor = new GithubExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let callCount = 0;
|
||||
const seenIntegrationIds: string[] = [];
|
||||
|
||||
globalThis.fetch = async (_url, init: RequestInit = {}) => {
|
||||
callCount++;
|
||||
const headers = init.headers as Record<string, string>;
|
||||
seenIntegrationIds.push(headers["copilot-integration-id"]);
|
||||
return new Response(JSON.stringify({ message: "Access denied: organization policy" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const originalEnv = process.env.COPILOT_INTEGRATION_ID;
|
||||
try {
|
||||
// 1. Explicit client header pin suppresses fallback
|
||||
callCount = 0;
|
||||
seenIntegrationIds.length = 0;
|
||||
await executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "gh-access-token",
|
||||
providerSpecificData: { copilotToken: "copilot-token" },
|
||||
},
|
||||
clientHeaders: { "copilot-integration-id": GITHUB_COPILOT_CLI_INTEGRATION_ID },
|
||||
});
|
||||
assert.equal(callCount, 1);
|
||||
assert.deepEqual(seenIntegrationIds, [GITHUB_COPILOT_CLI_INTEGRATION_ID]);
|
||||
|
||||
// 2. Explicit env pin suppresses fallback
|
||||
process.env.COPILOT_INTEGRATION_ID = GITHUB_COPILOT_CLI_INTEGRATION_ID;
|
||||
callCount = 0;
|
||||
seenIntegrationIds.length = 0;
|
||||
await executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "gh-access-token",
|
||||
providerSpecificData: { copilotToken: "copilot-token" },
|
||||
},
|
||||
});
|
||||
assert.equal(callCount, 1);
|
||||
assert.deepEqual(seenIntegrationIds, [GITHUB_COPILOT_CLI_INTEGRATION_ID]);
|
||||
delete process.env.COPILOT_INTEGRATION_ID;
|
||||
|
||||
// 3. Quota 403 error does not trigger identity retry
|
||||
callCount = 0;
|
||||
seenIntegrationIds.length = 0;
|
||||
globalThis.fetch = async (_url, init: RequestInit = {}) => {
|
||||
callCount++;
|
||||
const headers = init.headers as Record<string, string>;
|
||||
seenIntegrationIds.push(headers["copilot-integration-id"]);
|
||||
return new Response(JSON.stringify({ message: "Quota exceeded: monthly limit reached" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
await executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "gh-access-token",
|
||||
providerSpecificData: { copilotToken: "copilot-token" },
|
||||
},
|
||||
});
|
||||
assert.equal(callCount, 1);
|
||||
assert.deepEqual(seenIntegrationIds, [GITHUB_COPILOT_CLI_INTEGRATION_ID]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.COPILOT_INTEGRATION_ID;
|
||||
} else {
|
||||
process.env.COPILOT_INTEGRATION_ID = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,3 +190,48 @@ test("isValidGheUrl accepts https enterprise hosts and rejects malformed or non-
|
||||
assert.equal(isValidGheUrl("javascript:alert(1)"), false);
|
||||
assert.equal(isValidGheUrl("not a url"), false);
|
||||
});
|
||||
|
||||
test("GheCopilotExecutor.execute does not trigger identity fallback on 403", async () => {
|
||||
const executor = new GheCopilotExecutor({
|
||||
gheUrl: "https://ghe.company.com",
|
||||
clientId: "test-client",
|
||||
clientSecret: "test-secret",
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
let callCount = 0;
|
||||
const seenIntegrationIds: string[] = [];
|
||||
|
||||
globalThis.fetch = async (_url, init: RequestInit = {}) => {
|
||||
callCount++;
|
||||
const headers = init.headers as Record<string, string>;
|
||||
seenIntegrationIds.push(headers["copilot-integration-id"]);
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Access denied: Enterprise Copilot 403 Forbidden" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const credentials: ProviderCredentials = {
|
||||
accessToken: "ghe-token",
|
||||
providerSpecificData: {
|
||||
gheUrl: "https://ghe.company.com",
|
||||
copilotToken: "copilot-token",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "gpt-4o",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials,
|
||||
});
|
||||
|
||||
assert.equal(callCount, 1, "GHE Copilot must never retry on 403");
|
||||
assert.deepEqual(seenIntegrationIds, ["copilot-developer-cli"]);
|
||||
const res = result as { response: Response };
|
||||
assert.equal(res.response.status, 403);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
GITHUB_COPILOT_CLI_USER_AGENT,
|
||||
GITHUB_COPILOT_CHAT_USER_AGENT,
|
||||
GITHUB_COPILOT_EDITOR_VERSION,
|
||||
GITHUB_COPILOT_CLI_INTEGRATION_ID,
|
||||
GITHUB_COPILOT_CHAT_INTEGRATION_ID,
|
||||
GITHUB_COPILOT_INTEGRATION_ID,
|
||||
GITHUB_COPILOT_INTERACTION_TYPE,
|
||||
GITHUB_COPILOT_HARNESS_ID,
|
||||
@@ -22,6 +24,8 @@ import {
|
||||
getGitHubCopilotRefreshHeaders,
|
||||
getKiroServiceHeaders,
|
||||
getQoderDashscopeCompatHeaders,
|
||||
normalizeCopilotIntegrationId,
|
||||
resolveCopilotIntegrationIdOverride,
|
||||
} from "../../open-sse/config/providerHeaderProfiles.ts";
|
||||
|
||||
test("provider header profiles expose current GitHub chat and internal headers", () => {
|
||||
@@ -107,3 +111,66 @@ test("provider header profiles tolerate browser-like process shims", async () =>
|
||||
Object.defineProperty(process, "version", { value: originalVersion, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Copilot integration ID constants and resolution precedence", () => {
|
||||
assert.equal(GITHUB_COPILOT_CLI_INTEGRATION_ID, "copilot-developer-cli");
|
||||
assert.equal(GITHUB_COPILOT_CHAT_INTEGRATION_ID, "copilot-chat");
|
||||
assert.equal(GITHUB_COPILOT_INTEGRATION_ID, GITHUB_COPILOT_CLI_INTEGRATION_ID);
|
||||
|
||||
// normalizeCopilotIntegrationId validates strings and rejects CR/LF or whitespace
|
||||
assert.equal(normalizeCopilotIntegrationId("custom-client"), "custom-client");
|
||||
assert.equal(normalizeCopilotIntegrationId(" custom-client "), "custom-client");
|
||||
assert.equal(normalizeCopilotIntegrationId(""), null);
|
||||
assert.equal(normalizeCopilotIntegrationId(" "), null);
|
||||
assert.equal(normalizeCopilotIntegrationId("bad\r\nheader"), null);
|
||||
assert.equal(normalizeCopilotIntegrationId("bad\nheader"), null);
|
||||
assert.equal(normalizeCopilotIntegrationId(123), null);
|
||||
assert.equal(normalizeCopilotIntegrationId(null), null);
|
||||
|
||||
// Default headers use CLI integration ID
|
||||
const defaultHeaders = getGitHubCopilotChatHeaders();
|
||||
assert.equal(defaultHeaders["copilot-integration-id"], GITHUB_COPILOT_CLI_INTEGRATION_ID);
|
||||
|
||||
// options.integrationId overrides default
|
||||
const optionHeaders = getGitHubCopilotChatHeaders("application/json", "user", {
|
||||
integrationId: "custom-opt-id",
|
||||
});
|
||||
assert.equal(optionHeaders["copilot-integration-id"], "custom-opt-id");
|
||||
|
||||
// Environment variable override and precedence
|
||||
const originalEnv = process.env.COPILOT_INTEGRATION_ID;
|
||||
try {
|
||||
process.env.COPILOT_INTEGRATION_ID = "env-integration-id";
|
||||
assert.equal(resolveCopilotIntegrationIdOverride(), "env-integration-id");
|
||||
|
||||
// Env overrides default
|
||||
const envHeaders = getGitHubCopilotChatHeaders();
|
||||
assert.equal(envHeaders["copilot-integration-id"], "env-integration-id");
|
||||
|
||||
// options.integrationId takes precedence over env override
|
||||
const optOverEnvHeaders = getGitHubCopilotChatHeaders("application/json", "user", {
|
||||
integrationId: "opt-wins",
|
||||
});
|
||||
assert.equal(optOverEnvHeaders["copilot-integration-id"], "opt-wins");
|
||||
|
||||
// Invalid env value with CR/LF falls back to CLI default
|
||||
process.env.COPILOT_INTEGRATION_ID = "invalid\r\nid";
|
||||
assert.equal(resolveCopilotIntegrationIdOverride(), null);
|
||||
const fallbackHeaders = getGitHubCopilotChatHeaders();
|
||||
assert.equal(fallbackHeaders["copilot-integration-id"], GITHUB_COPILOT_CLI_INTEGRATION_ID);
|
||||
|
||||
// Empty/whitespace env value falls back to CLI default
|
||||
process.env.COPILOT_INTEGRATION_ID = " ";
|
||||
assert.equal(resolveCopilotIntegrationIdOverride(), null);
|
||||
assert.equal(
|
||||
getGitHubCopilotChatHeaders()["copilot-integration-id"],
|
||||
GITHUB_COPILOT_CLI_INTEGRATION_ID
|
||||
);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.COPILOT_INTEGRATION_ID;
|
||||
} else {
|
||||
process.env.COPILOT_INTEGRATION_ID = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user