diff --git a/changelog.d/fixes/10365-gitlab-duo-401-fallback.md b/changelog.d/fixes/10365-gitlab-duo-401-fallback.md new file mode 100644 index 0000000000..cc05612a00 --- /dev/null +++ b/changelog.d/fixes/10365-gitlab-duo-401-fallback.md @@ -0,0 +1 @@ +- fix(providers): GitLab Duo falls back to the public Code Suggestions endpoint when direct_access returns 401 (#10365) \ No newline at end of file diff --git a/open-sse/executors/gitlab.ts b/open-sse/executors/gitlab.ts index 594dfa7e47..fa0b22c5c1 100644 --- a/open-sse/executors/gitlab.ts +++ b/open-sse/executors/gitlab.ts @@ -583,10 +583,20 @@ export class GitlabExecutor extends BaseExecutor { } if (response.status === 401) { + if (input.log) { + input.log.warn( + "GITLAB-DUO", + "direct_access exchange rejected (401); falling back to public completions endpoint" + ); + } return { - target: null, + target: { + mode: "monolith", + url: endpoints.publicCompletionsUrl, + headers: buildMonolithHeaders(credentials.accessToken || null), + }, credentials, - errorResponse: toOpenAIError(401, "GitLab Duo direct access token request was rejected"), + errorResponse: null, }; } diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 66cf2f6df9..23c79ab5ab 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -19,7 +19,13 @@ import { saveCallLog } from "@/lib/usageDb"; import { shouldHideLogs } from "@/lib/tokenHealthCheck"; import { logProxyEvent } from "@/lib/proxyLogger"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; -import { isGitLabDirectAccessDisabled } from "@/lib/oauth/gitlab"; +import { + buildGitLabDuoProbeBody, + buildGitLabDuoProbeHeaders, + buildGitLabOAuthEndpoints, + resolveGitLabOAuthBaseUrl, + shouldFallbackToPublicCodeSuggestions, +} from "@/lib/oauth/gitlab"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; @@ -306,6 +312,39 @@ function isTokenExpired(connection: any) { return expiresAt <= Date.now() + buffer; } +/** + * #10365 / #10499: the real chat path (open-sse/executors/gitlab.ts) treats a rejected + * `direct_access` exchange (401) or an explicitly disabled direct-connections tenant + * (403) as recoverable — it falls back to the public Code Suggestions completions + * endpoint and keeps serving. "Test Connection" / Retest must apply the SAME contract: + * a `direct_access` failure alone is not proof the token is bad, so probe the fallback + * endpoint before reporting the connection unhealthy. Only a fallback-probe 401/403 + * means the token itself is rejected; any other status (including validation errors on + * the deliberately minimal probe body) means auth was accepted. + */ +async function probeGitLabDuoPublicFallback( + connection: any, + accessToken: string, + timeoutMs: number +): Promise { + const endpoints = buildGitLabOAuthEndpoints( + resolveGitLabOAuthBaseUrl(connection?.providerSpecificData) + ); + try { + const fallbackRes = await fetch(endpoints.publicCompletionsUrl, { + method: "POST", + headers: buildGitLabDuoProbeHeaders(accessToken), + body: JSON.stringify(buildGitLabDuoProbeBody()), + signal: AbortSignal.timeout(timeoutMs), + }); + return fallbackRes.status !== 401 && fallbackRes.status !== 403; + } catch { + // Network/timeout failures on the probe are not an auth verdict either way — + // fall through to the caller's existing 401/403 handling instead of masking them. + return false; + } +} + /** * Sync to cloud if enabled */ @@ -487,14 +526,17 @@ export async function testOAuthConnection( if (connection.provider === "gitlab-duo") { const gitlabText = await res.text(); - if (isGitLabDirectAccessDisabled(res.status, gitlabText)) { - return { - valid: true, - error: null, - refreshed, - newTokens, - diagnosis: makeDiagnosis("ok", "upstream", null, null), - }; + if (shouldFallbackToPublicCodeSuggestions(res.status, gitlabText)) { + const fallbackOk = await probeGitLabDuoPublicFallback(connection, accessToken, timeoutMs); + if (fallbackOk) { + return { + valid: true, + error: null, + refreshed, + newTokens, + diagnosis: makeDiagnosis("ok", "upstream", null, null), + }; + } } } @@ -541,9 +583,33 @@ export async function testOAuthConnection( }; } + const retryBody = await retryRes.text().catch(() => ""); + + // #10365 / #10499: same fallback contract as the first attempt above — a + // rejected direct_access exchange with a freshly-refreshed token is still + // recoverable via the public Code Suggestions endpoint. + if ( + connection.provider === "gitlab-duo" && + shouldFallbackToPublicCodeSuggestions(retryRes.status, retryBody) + ) { + const fallbackOk = await probeGitLabDuoPublicFallback( + connection, + tokens.accessToken, + timeoutMs + ); + if (fallbackOk) { + return { + valid: true, + error: null, + refreshed: true, + newTokens: tokens, + diagnosis: makeDiagnosis("ok", "upstream", null, null), + }; + } + } + // #1444: a fresh token that still gets a 401 because the account itself was // deactivated must be labeled account_deactivated, not a generic auth error. - const retryBody = await retryRes.text().catch(() => ""); const error = isAccountDeactivatedMessage(retryBody) ? "Account deactivated by the provider" : `API returned ${retryRes.status} after token refresh`; diff --git a/src/lib/oauth/gitlab.ts b/src/lib/oauth/gitlab.ts index cbc7cbf1b4..e9bf961a10 100644 --- a/src/lib/oauth/gitlab.ts +++ b/src/lib/oauth/gitlab.ts @@ -101,3 +101,43 @@ export function getCachedGitLabDirectAccess( 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 { + 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 { + return { + current_file: { + file_name: "connection-test.txt", + content_above_cursor: "", + content_below_cursor: "", + }, + intent: "generation", + generation_type: "small_file", + stream: false, + }; +} diff --git a/tests/unit/executor-gitlab.test.ts b/tests/unit/executor-gitlab.test.ts index af4a0753f8..e873805b4a 100644 --- a/tests/unit/executor-gitlab.test.ts +++ b/tests/unit/executor-gitlab.test.ts @@ -261,3 +261,54 @@ test("GitlabExecutor falls back to the public Code Suggestions endpoint when dir globalThis.fetch = originalFetch; } }); + +// #10365: a 401 from the direct_access exchange must ALSO fall back to the public +// Code Suggestions completions endpoint (same resilience as the 403-disabled case +// above), instead of surfacing an opaque 401 token error with no fallback. +test("GitlabExecutor falls back to the public Code Suggestions endpoint when direct_access returns 401", async () => { + const executor = getExecutor("gitlab-duo") as GitlabExecutor; + const originalFetch = globalThis.fetch; + const calls: string[] = []; + + globalThis.fetch = async (url) => { + calls.push(String(url)); + + if (String(url) === "https://gitlab.example.com/api/v4/code_suggestions/direct_access") { + return jsonResponse({ error: "invalid_token" }, 401); + } + + return jsonResponse({ + model: { name: "code-gecko" }, + choices: [{ text: "monolith fallback works" }], + }); + }; + + try { + const result = await executor.execute({ + model: "gitlab-duo-code-suggestions", + body: { + messages: [{ role: "user", content: "Say hello" }], + }, + stream: false, + credentials: { + accessToken: "oauth-access", + providerSpecificData: { + baseUrl: "https://gitlab.example.com", + }, + }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + assert.deepEqual(calls, [ + "https://gitlab.example.com/api/v4/code_suggestions/direct_access", + "https://gitlab.example.com/api/v4/code_suggestions/completions", + ]); + + const body = (await result.response.json()) as any; + assert.equal(body.model, "code-gecko"); + assert.match(body.choices[0].message.content, /monolith fallback works/i); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/gitlab-duo-oauth-test-401-fallback.test.ts b/tests/unit/gitlab-duo-oauth-test-401-fallback.test.ts new file mode 100644 index 0000000000..89a6fdd1b5 --- /dev/null +++ b/tests/unit/gitlab-duo-oauth-test-401-fallback.test.ts @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { testOAuthConnection } from "../../src/app/api/providers/[id]/test/route"; + +// #10365 / #10499: 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 "Test Connection" / the +// dashboard's Retest button drove testOAuthConnection() straight against +// `direct_access` and reported the connection unhealthy on a plain 401, even though +// the exact same request would have succeeded through the real chat path via the +// fallback. These tests prove the connection-test path now applies the identical +// fallback contract before declaring the connection invalid. + +const DIRECT_ACCESS_URL = "https://gitlab.example.com/api/v4/code_suggestions/direct_access"; +const PUBLIC_COMPLETIONS_URL = "https://gitlab.example.com/api/v4/code_suggestions/completions"; + +function futureExpiresAt(): string { + return new Date(Date.now() + 60 * 60 * 1000).toISOString(); +} + +function baseConnection(overrides: Record = {}) { + return { + provider: "gitlab-duo", + authType: "oauth", + accessToken: "oauth-access", + refreshToken: "oauth-refresh", + expiresAt: futureExpiresAt(), + providerSpecificData: { baseUrl: "https://gitlab.example.com" }, + ...overrides, + }; +} + +function mockFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fn = (async (url: RequestInfo | URL, init?: RequestInit) => { + const u = typeof url === "string" ? url : url instanceof URL ? url.toString() : String(url); + calls.push({ url: u, init }); + return handler(u, init); + }) as typeof fetch; + return { fn, calls }; +} + +test("gitlab-duo Retest falls back to the public completions endpoint on a direct_access 401 (#10365)", async (t) => { + const original = globalThis.fetch; + const { fn, calls } = mockFetch((url) => { + if (url === DIRECT_ACCESS_URL) { + return new Response(JSON.stringify({ error: "invalid_token" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + if (url === PUBLIC_COMPLETIONS_URL) { + return new Response(JSON.stringify({ model: { name: "code-gecko" }, choices: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch to ${url}`); + }); + globalThis.fetch = fn; + t.after(() => { + globalThis.fetch = original; + }); + + const result = await testOAuthConnection(baseConnection(), 5000); + + assert.equal( + result.valid, + true, + "a direct_access 401 must be recovered via the public completions fallback probe, mirroring the chat path" + ); + assert.deepEqual( + calls.map((c) => c.url), + [DIRECT_ACCESS_URL, PUBLIC_COMPLETIONS_URL], + "must probe direct_access first, then fall back to the public completions endpoint" + ); + const fallbackHeaders = (calls[1].init?.headers ?? {}) as Record; + assert.equal(fallbackHeaders.Authorization, "Bearer oauth-access"); +}); + +test("gitlab-duo Retest reports invalid when BOTH direct_access and the public fallback reject the token", async (t) => { + const original = globalThis.fetch; + const { fn, calls } = mockFetch((url) => { + if (url === DIRECT_ACCESS_URL) { + return new Response(JSON.stringify({ error: "invalid_token" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + if (url === PUBLIC_COMPLETIONS_URL) { + return new Response(JSON.stringify({ error: "invalid_token" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch to ${url}`); + }); + globalThis.fetch = fn; + t.after(() => { + globalThis.fetch = original; + }); + + const result = await testOAuthConnection(baseConnection({ refreshToken: null }), 5000); + + assert.equal( + result.valid, + false, + "a token rejected by BOTH endpoints is genuinely bad — the fallback must not paper over that" + ); + assert.deepEqual( + calls.map((c) => c.url), + [DIRECT_ACCESS_URL, PUBLIC_COMPLETIONS_URL], + "the fallback probe must still run before giving up" + ); +}); + +test("gitlab-duo Retest still falls back on the pre-existing 403 'direct connections are disabled' case", async (t) => { + const original = globalThis.fetch; + const { fn, calls } = mockFetch((url) => { + if (url === DIRECT_ACCESS_URL) { + return new Response("Direct connections are disabled for this instance", { + status: 403, + headers: { "content-type": "text/plain" }, + }); + } + if (url === PUBLIC_COMPLETIONS_URL) { + return new Response(JSON.stringify({ model: { name: "code-gecko" }, choices: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch to ${url}`); + }); + globalThis.fetch = fn; + t.after(() => { + globalThis.fetch = original; + }); + + const result = await testOAuthConnection(baseConnection(), 5000); + + assert.equal(result.valid, true); + assert.equal(calls.length, 2); +});