Files
OmniRoute/src/lib/oauth/gitlab.ts
Diego Rodrigues de Sa e Souza da42ed6d2e fix(providers): fall back to public Code Suggestions endpoint on GitLab Duo direct_access 401 (#10365) (#10499)
* fix(providers): fall back to public Code Suggestions endpoint on GitLab Duo direct_access 401 (#10365)

* fix(providers): extend GitLab Duo 401 fallback to the connection-test path (#10365)

The chat-completion path (open-sse/executors/gitlab.ts) already falls back to
the public Code Suggestions completions endpoint when the direct_access
exchange is rejected with 401, but testOAuthConnection() / the dashboard
Retest button still reported the connection unhealthy on the same 401 —
even though a real chat request through that connection would have
succeeded via the fallback. Apply the identical fallback contract to the
connection-test path (first attempt and the post-refresh retry), sharing the
predicate with the executor via shouldFallbackToPublicCodeSuggestions.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:51:12 -03:00

144 lines
4.7 KiB
TypeScript

type JsonRecord = Record<string, unknown>;
export type GitLabDirectAccessDetails = {
token: string;
baseUrl: string;
expiresAt: string | null;
headers: Record<string, string>;
};
export const GITLAB_DUO_DEFAULT_BASE_URL =
process.env.GITLAB_DUO_BASE_URL || process.env.GITLAB_BASE_URL || "https://gitlab.com";
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
export function normalizeGitLabBaseUrl(baseUrl?: unknown): string {
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
return (raw || GITLAB_DUO_DEFAULT_BASE_URL).replace(/\/$/, "");
}
export function resolveGitLabOAuthBaseUrl(providerSpecificData?: unknown): string {
const data = asRecord(providerSpecificData);
return normalizeGitLabBaseUrl(data.baseUrl);
}
export function buildGitLabOAuthEndpoints(baseUrl?: unknown) {
const root = normalizeGitLabBaseUrl(baseUrl);
return {
root,
authorizeUrl: `${root}/oauth/authorize`,
tokenUrl: `${root}/oauth/token`,
userUrl: `${root}/api/v4/user`,
directAccessUrl: `${root}/api/v4/code_suggestions/direct_access`,
publicCompletionsUrl: `${root}/api/v4/code_suggestions/completions`,
};
}
export function buildGitLabDirectGatewayUrl(baseUrl: string): string {
const normalized = normalizeGitLabBaseUrl(baseUrl);
if (normalized.endsWith("/ai/v2/completions")) {
return normalized;
}
if (normalized.endsWith("/ai/v2")) {
return `${normalized}/completions`;
}
return `${normalized}/ai/v2/completions`;
}
export function parseGitLabDirectAccessDetails(payload: unknown): GitLabDirectAccessDetails | null {
const data = asRecord(payload);
const token = typeof data.token === "string" ? data.token.trim() : "";
const baseUrl = typeof data.base_url === "string" ? data.base_url.trim() : "";
if (!token || !baseUrl) {
return null;
}
const rawHeaders = asRecord(data.headers);
const headers = Object.fromEntries(
Object.entries(rawHeaders).filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" && typeof entry[1] === "string"
)
);
const expiresAt =
typeof data.expires_at === "number" && Number.isFinite(data.expires_at)
? new Date(data.expires_at * 1000).toISOString()
: null;
return {
token,
baseUrl: normalizeGitLabBaseUrl(baseUrl),
expiresAt,
headers,
};
}
export function getCachedGitLabDirectAccess(
providerSpecificData?: unknown,
minValidityMs = 60_000
): GitLabDirectAccessDetails | null {
const data = asRecord(providerSpecificData);
const cache = data.gitlabDirectAccess ?? data.directAccessCache;
const parsed = parseGitLabDirectAccessDetails(cache);
if (!parsed) {
return null;
}
if (!parsed.expiresAt) {
return parsed;
}
const expiresAtMs = new Date(parsed.expiresAt).getTime();
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + minValidityMs) {
return null;
}
return parsed;
}
export function isGitLabDirectAccessDisabled(status: number, bodyText: string): boolean {
return status === 403 && bodyText.toLowerCase().includes("direct connections are disabled");
}
/**
* #10365 / #10499: same predicate the chat-path executor (open-sse/executors/gitlab.ts)
* uses to decide whether a failed `direct_access` exchange should fall back to the
* public Code Suggestions completions endpoint instead of surfacing a hard error.
* A rejected exchange (401 — invalid/expired direct_access grant) or an explicitly
* disabled direct-connections tenant (403 with the GitLab-specific message) both mean
* "direct mode unavailable, but the public monolith endpoint may still work" — never a
* definitive "the token itself is bad" signal on their own.
*/
export function shouldFallbackToPublicCodeSuggestions(status: number, bodyText: string): boolean {
return status === 401 || isGitLabDirectAccessDisabled(status, bodyText);
}
/** Headers for a public Code Suggestions completions probe (chat path and connection test). */
export function buildGitLabDuoProbeHeaders(token: string | null): Record<string, string> {
return {
"Content-Type": "application/json",
Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
/**
* Minimal, side-effect-free body for the public Code Suggestions completions probe.
* Only used to confirm the token is accepted by the fallback endpoint — never sent
* as a real completion request.
*/
export function buildGitLabDuoProbeBody(): Record<string, unknown> {
return {
current_file: {
file_name: "connection-test.txt",
content_above_cursor: "",
content_below_cursor: "",
},
intent: "generation",
generation_type: "small_file",
stream: false,
};
}