mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 13:14:56 +03:00
Compare commits
2 Commits
refactor/e
...
fix/10365-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c2a9a8d9 | ||
|
|
012e91d5b3 |
1
changelog.d/fixes/10365-gitlab-duo-401-fallback.md
Normal file
1
changelog.d/fixes/10365-gitlab-duo-401-fallback.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(providers): GitLab Duo falls back to the public Code Suggestions endpoint when direct_access returns 401 (#10365)
|
||||||
@@ -583,10 +583,20 @@ export class GitlabExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
if (input.log) {
|
||||||
|
input.log.warn(
|
||||||
|
"GITLAB-DUO",
|
||||||
|
"direct_access exchange rejected (401); falling back to public completions endpoint"
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
target: null,
|
target: {
|
||||||
|
mode: "monolith",
|
||||||
|
url: endpoints.publicCompletionsUrl,
|
||||||
|
headers: buildMonolithHeaders(credentials.accessToken || null),
|
||||||
|
},
|
||||||
credentials,
|
credentials,
|
||||||
errorResponse: toOpenAIError(401, "GitLab Duo direct access token request was rejected"),
|
errorResponse: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import { saveCallLog } from "@/lib/usageDb";
|
|||||||
import { shouldHideLogs } from "@/lib/tokenHealthCheck";
|
import { shouldHideLogs } from "@/lib/tokenHealthCheck";
|
||||||
import { logProxyEvent } from "@/lib/proxyLogger";
|
import { logProxyEvent } from "@/lib/proxyLogger";
|
||||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
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 { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
||||||
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
||||||
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
|
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
|
||||||
@@ -304,6 +310,39 @@ function isTokenExpired(connection: any) {
|
|||||||
return expiresAt <= Date.now() + buffer;
|
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<boolean> {
|
||||||
|
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
|
* Sync to cloud if enabled
|
||||||
*/
|
*/
|
||||||
@@ -471,14 +510,17 @@ export async function testOAuthConnection(
|
|||||||
|
|
||||||
if (connection.provider === "gitlab-duo") {
|
if (connection.provider === "gitlab-duo") {
|
||||||
const gitlabText = await res.text();
|
const gitlabText = await res.text();
|
||||||
if (isGitLabDirectAccessDisabled(res.status, gitlabText)) {
|
if (shouldFallbackToPublicCodeSuggestions(res.status, gitlabText)) {
|
||||||
return {
|
const fallbackOk = await probeGitLabDuoPublicFallback(connection, accessToken, timeoutMs);
|
||||||
valid: true,
|
if (fallbackOk) {
|
||||||
error: null,
|
return {
|
||||||
refreshed,
|
valid: true,
|
||||||
newTokens,
|
error: null,
|
||||||
diagnosis: makeDiagnosis("ok", "upstream", null, null),
|
refreshed,
|
||||||
};
|
newTokens,
|
||||||
|
diagnosis: makeDiagnosis("ok", "upstream", null, null),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,9 +561,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
|
// #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.
|
// deactivated must be labeled account_deactivated, not a generic auth error.
|
||||||
const retryBody = await retryRes.text().catch(() => "");
|
|
||||||
const error = isAccountDeactivatedMessage(retryBody)
|
const error = isAccountDeactivatedMessage(retryBody)
|
||||||
? "Account deactivated by the provider"
|
? "Account deactivated by the provider"
|
||||||
: `API returned ${retryRes.status} after token refresh`;
|
: `API returned ${retryRes.status} after token refresh`;
|
||||||
|
|||||||
@@ -101,3 +101,43 @@ export function getCachedGitLabDirectAccess(
|
|||||||
export function isGitLabDirectAccessDisabled(status: number, bodyText: string): boolean {
|
export function isGitLabDirectAccessDisabled(status: number, bodyText: string): boolean {
|
||||||
return status === 403 && bodyText.toLowerCase().includes("direct connections are disabled");
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -261,3 +261,54 @@ test("GitlabExecutor falls back to the public Code Suggestions endpoint when dir
|
|||||||
globalThis.fetch = originalFetch;
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
144
tests/unit/gitlab-duo-oauth-test-401-fallback.test.ts
Normal file
144
tests/unit/gitlab-duo-oauth-test-401-fallback.test.ts
Normal file
@@ -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<string, unknown> = {}) {
|
||||||
|
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<string, string>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user