Files
OmniRoute/open-sse/executors/gitlab.ts
2026-07-03 12:49:56 -03:00

637 lines
19 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
BaseExecutor,
mergeAbortSignals,
mergeUpstreamExtraHeaders,
type ExecuteInput,
type ExecutorLog,
type ProviderCredentials,
} from "./base.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { getAccessToken } from "../services/tokenRefresh.ts";
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
import {
buildStreamingResponse,
buildJsonCompletion,
buildToolJsonCompletion,
buildToolStreamingResponse,
} from "./gitlabResponses.ts";
import {
buildGitLabDirectGatewayUrl,
buildGitLabOAuthEndpoints,
getCachedGitLabDirectAccess,
isGitLabDirectAccessDisabled,
parseGitLabDirectAccessDetails,
resolveGitLabOAuthBaseUrl,
type GitLabDirectAccessDetails,
} from "@/lib/oauth/gitlab";
type OpenAIMessage = {
role?: string;
content?: unknown;
};
type JsonRecord = Record<string, unknown>;
type GitLabRequestTarget = {
mode: "monolith" | "direct";
url: string;
headers: Record<string, string>;
};
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function extractTextContent(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((part) => {
if (!part || typeof part !== "object") return "";
const item = part as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") {
return item.text;
}
if (item.type === "input_text" && typeof item.text === "string") {
return item.text;
}
return "";
})
.filter((text) => text.trim().length > 0)
.join("\n")
.trim();
}
function buildPrompt(messages: OpenAIMessage[] | undefined): string {
if (!Array.isArray(messages)) return "";
const systemParts: string[] = [];
const userParts: string[] = [];
for (const message of messages) {
const role = String(message?.role || "user").toLowerCase();
const text = extractTextContent(message?.content);
if (!text) continue;
if (role === "system" || role === "developer") {
systemParts.push(text);
continue;
}
if (role === "user") {
userParts.push(text);
}
}
const latestUserPrompt = userParts.at(-1) || "";
if (!systemParts.length) {
return latestUserPrompt;
}
return `System instructions:\n${systemParts.join("\n\n")}\n\n${latestUserPrompt}`.trim();
}
function toOpenAIError(status: number, message: string): Response {
return new Response(
JSON.stringify({
error: {
message,
type:
status === 401 || status === 403
? "authentication_error"
: status === 429
? "rate_limit_error"
: "api_error",
},
}),
{
status,
headers: { "Content-Type": "application/json" },
}
);
}
function mergeCredentials(
current: ProviderCredentials,
patch: Partial<ProviderCredentials> | null | undefined
): ProviderCredentials {
if (!patch) return current;
return {
...current,
...patch,
providerSpecificData: {
...(current.providerSpecificData || {}),
...(patch.providerSpecificData || {}),
},
};
}
function resolveGitLabRoot(credentials: ExecuteInput["credentials"]): string {
return resolveGitLabOAuthBaseUrl(credentials?.providerSpecificData);
}
function resolveResponseModel(payload: JsonRecord, fallbackModel: string): string {
const modelField = payload.model;
if (typeof modelField === "string" && modelField.trim().length > 0) {
return modelField.trim();
}
const modelRecord = asRecord(modelField);
const modelName =
typeof modelRecord.name === "string" && modelRecord.name.trim().length > 0
? modelRecord.name.trim()
: typeof modelRecord.id === "string" && modelRecord.id.trim().length > 0
? modelRecord.id.trim()
: null;
if (modelName) {
return modelName;
}
const metadata = asRecord(payload.metadata);
const metadataModelDetails = asRecord(metadata.model_details);
const payloadModelDetails = asRecord(payload.model_details);
const nestedCandidates = [metadataModelDetails, payloadModelDetails];
for (const candidate of nestedCandidates) {
const value =
typeof candidate.model_name === "string" && candidate.model_name.trim().length > 0
? candidate.model_name.trim()
: typeof candidate.name === "string" && candidate.name.trim().length > 0
? candidate.name.trim()
: null;
if (value) {
return value;
}
}
return fallbackModel;
}
function buildMonolithHeaders(token: string | null): Record<string, string> {
return {
"Content-Type": "application/json",
Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
function buildDirectHeaders(directAccess: GitLabDirectAccessDetails): Record<string, string> {
return {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${directAccess.token}`,
...directAccess.headers,
};
}
function isGitLabDuoOAuthProvider(providerId: string): boolean {
return providerId === "gitlab-duo";
}
async function persistGitLabDirectAccessCache(
input: ExecuteInput,
credentials: ProviderCredentials,
root: string,
directAccess: GitLabDirectAccessDetails
) {
if (!input.onCredentialsRefreshed) return;
await input.onCredentialsRefreshed({
providerSpecificData: {
...(credentials.providerSpecificData || {}),
baseUrl: root,
gitlabDirectAccess: {
token: directAccess.token,
baseUrl: directAccess.baseUrl,
expiresAt: directAccess.expiresAt,
headers: directAccess.headers,
},
},
});
}
export class GitlabExecutor extends BaseExecutor {
constructor(providerId = "gitlab") {
super(providerId, {
id: providerId,
baseUrl: "https://gitlab.com/api/v4/code_suggestions/completions",
headers: { "Content-Type": "application/json" },
});
}
buildUrl(
_model: string,
_stream: boolean,
_urlIndex = 0,
credentials: ExecuteInput["credentials"] | null = null
): string {
const endpoints = buildGitLabOAuthEndpoints(resolveGitLabRoot(credentials || {}));
return endpoints.publicCompletionsUrl;
}
buildHeaders(credentials: ExecuteInput["credentials"], _stream = false): Record<string, string> {
const token = credentials?.apiKey || credentials?.accessToken || null;
return buildMonolithHeaders(token);
}
transformRequest(
_model: string,
body: Record<string, unknown>,
_stream: boolean,
credentials: ExecuteInput["credentials"]
): Record<string, unknown> {
const prompt = buildPrompt(body.messages as OpenAIMessage[] | undefined);
const providerData =
credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object"
? credentials.providerSpecificData
: {};
const projectPath =
typeof providerData.projectPath === "string" && providerData.projectPath.trim().length > 0
? providerData.projectPath.trim()
: undefined;
const fileName =
typeof providerData.fileName === "string" && providerData.fileName.trim().length > 0
? providerData.fileName.trim()
: "snippet.txt";
return {
current_file: {
file_name: fileName,
content_above_cursor: prompt,
content_below_cursor: "",
},
intent: "generation",
generation_type: "small_file",
stream: false,
...(projectPath ? { project_path: projectPath } : {}),
...(prompt ? { user_instruction: prompt } : {}),
};
}
async refreshCredentials(credentials: ProviderCredentials, log: ExecutorLog | null) {
if (!isGitLabDuoOAuthProvider(this.provider) || !credentials.refreshToken) {
return null;
}
try {
return await getAccessToken(this.provider, credentials, log);
} catch (error) {
log?.error?.(
"TOKEN",
`GitLab Duo refresh error: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
needsRefresh(credentials?: ProviderCredentials | null) {
if (
isGitLabDuoOAuthProvider(this.provider) &&
!credentials?.accessToken &&
credentials?.refreshToken
) {
return true;
}
return super.needsRefresh(credentials);
}
private async fetchGitLabDirectAccess(
root: string,
accessToken: string,
signal: AbortSignal | null | undefined
): Promise<{
directAccess: GitLabDirectAccessDetails | null;
response: Response | null;
bodyText: string;
}> {
const endpoints = buildGitLabOAuthEndpoints(root);
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal;
const response = await fetch(endpoints.directAccessUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
signal: combinedSignal,
});
const bodyText = await response.text();
if (!response.ok) {
return { directAccess: null, response, bodyText };
}
const parsed = bodyText ? JSON.parse(bodyText) : {};
return {
directAccess: parseGitLabDirectAccessDetails(parsed),
response,
bodyText,
};
}
private async resolveRequestTarget(
input: ExecuteInput,
credentials: ProviderCredentials
): Promise<{
target: GitLabRequestTarget | null;
credentials: ProviderCredentials;
errorResponse: Response | null;
}> {
const root = resolveGitLabRoot(credentials);
const endpoints = buildGitLabOAuthEndpoints(root);
if (!isGitLabDuoOAuthProvider(this.provider)) {
return {
target: {
mode: "monolith",
url: endpoints.publicCompletionsUrl,
headers: buildMonolithHeaders(credentials.apiKey || credentials.accessToken || null),
},
credentials,
errorResponse: null,
};
}
if (!credentials.accessToken) {
return {
target: null,
credentials,
errorResponse: toOpenAIError(401, "GitLab Duo OAuth connection is missing an access token"),
};
}
const cachedDirectAccess = getCachedGitLabDirectAccess(credentials.providerSpecificData);
if (cachedDirectAccess) {
return {
target: {
mode: "direct",
url: buildGitLabDirectGatewayUrl(cachedDirectAccess.baseUrl),
headers: buildDirectHeaders(cachedDirectAccess),
},
credentials,
errorResponse: null,
};
}
try {
const { directAccess, response, bodyText } = await this.fetchGitLabDirectAccess(
root,
credentials.accessToken,
input.signal
);
if (directAccess) {
await persistGitLabDirectAccessCache(input, credentials, root, directAccess);
const mergedCredentials = mergeCredentials(credentials, {
providerSpecificData: {
...(credentials.providerSpecificData || {}),
baseUrl: root,
gitlabDirectAccess: {
token: directAccess.token,
baseUrl: directAccess.baseUrl,
expiresAt: directAccess.expiresAt,
headers: directAccess.headers,
},
},
});
return {
target: {
mode: "direct",
url: buildGitLabDirectGatewayUrl(directAccess.baseUrl),
headers: buildDirectHeaders(directAccess),
},
credentials: mergedCredentials,
errorResponse: null,
};
}
if (!response) {
return {
target: {
mode: "monolith",
url: endpoints.publicCompletionsUrl,
headers: buildMonolithHeaders(credentials.accessToken),
},
credentials,
errorResponse: null,
};
}
if (response.status === 401) {
return {
target: null,
credentials,
errorResponse: toOpenAIError(401, "GitLab Duo direct access token request was rejected"),
};
}
if (response.status === 403 && !isGitLabDirectAccessDisabled(response.status, bodyText)) {
return {
target: null,
credentials,
errorResponse: toOpenAIError(403, "GitLab Duo direct access scope is unavailable"),
};
}
return {
target: {
mode: "monolith",
url: endpoints.publicCompletionsUrl,
headers: buildMonolithHeaders(credentials.accessToken),
},
credentials,
errorResponse: null,
};
} catch (error) {
return {
target: {
mode: "monolith",
url: endpoints.publicCompletionsUrl,
headers: buildMonolithHeaders(credentials.accessToken),
},
credentials,
errorResponse: null,
};
}
}
private async performRequest(
input: ExecuteInput,
target: GitLabRequestTarget,
transformedBody: Record<string, unknown>
) {
const headers = { ...target.headers };
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const combinedSignal = input.signal
? mergeAbortSignals(input.signal, timeoutSignal)
: timeoutSignal;
const response = await fetch(target.url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal: combinedSignal,
});
return { response, headers };
}
async execute(input: ExecuteInput) {
const bodyObj = (input.body as Record<string, unknown>) || {};
const rawMessages = (bodyObj.messages as OpenAIMessage[]) || [];
// Emulate OpenAI tool calling for GitLab Duo (which has no native function
// calling). When `tools` are present we serialize the tool contract into the
// prompt and parse `<tool>{...}</tool>` blocks back out of the completion text
// into OpenAI `tool_calls` — the same web-tool-emulation idiom used by the
// qwen-web / duckduckgo-web executors (#6051).
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
bodyObj,
rawMessages as Array<{ role: string; content: unknown }>
);
const prompt = buildPrompt(effectiveMessages as OpenAIMessage[]);
if (!prompt) {
return {
response: toOpenAIError(400, "GitLab Duo requires at least one user message"),
};
}
let activeCredentials = input.credentials;
if (this.needsRefresh(activeCredentials)) {
const refreshed = await this.refreshCredentials(activeCredentials, input.log || null);
if (refreshed) {
activeCredentials = mergeCredentials(activeCredentials, refreshed);
await input.onCredentialsRefreshed?.({
...refreshed,
providerSpecificData: {
...(input.credentials.providerSpecificData || {}),
...(refreshed.providerSpecificData || {}),
},
});
}
}
const transformedBody = this.transformRequest(
input.model,
{ ...bodyObj, messages: effectiveMessages },
false,
activeCredentials
);
const {
target,
credentials: resolvedCredentials,
errorResponse,
} = await this.resolveRequestTarget(input, activeCredentials);
if (errorResponse || !target) {
return {
response: errorResponse || toOpenAIError(500, "GitLab Duo target resolution failed"),
};
}
activeCredentials = resolvedCredentials;
let upstream: Response;
let requestHeaders: Record<string, string>;
let activeTarget = target;
try {
const requestResult = await this.performRequest(input, target, transformedBody);
upstream = requestResult.response;
requestHeaders = requestResult.headers;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
response: toOpenAIError(502, `GitLab Duo connection failed: ${message}`),
url: target.url,
headers: target.headers,
transformedBody,
};
}
if (!upstream.ok && target.mode === "direct") {
const fallbackTarget: GitLabRequestTarget = {
mode: "monolith",
url: buildGitLabOAuthEndpoints(resolveGitLabRoot(activeCredentials)).publicCompletionsUrl,
headers: buildMonolithHeaders(activeCredentials.accessToken || null),
};
try {
const fallbackResult = await this.performRequest(input, fallbackTarget, transformedBody);
upstream = fallbackResult.response;
requestHeaders = fallbackResult.headers;
activeTarget = fallbackTarget;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
response: toOpenAIError(502, `GitLab Duo connection failed: ${message}`),
url: fallbackTarget.url,
headers: fallbackTarget.headers,
transformedBody,
};
}
}
if (!upstream.ok) {
const text = await upstream.text();
const message =
upstream.status === 401 || upstream.status === 403
? `GitLab Duo auth failed: ${upstream.status}`
: upstream.status === 429
? "GitLab Duo rate limited the request"
: text || `GitLab Duo request failed: ${upstream.status}`;
return {
response: toOpenAIError(upstream.status, message),
url: activeTarget.url,
headers: requestHeaders,
transformedBody,
};
}
const payload = (await upstream.json()) as JsonRecord;
const choices = Array.isArray(payload.choices) ? payload.choices : [];
const firstChoice =
choices[0] && typeof choices[0] === "object" ? (choices[0] as JsonRecord) : {};
const content =
typeof firstChoice.text === "string"
? firstChoice.text
: typeof payload.content === "string"
? payload.content
: "";
const resolvedModel = resolveResponseModel(payload, input.model);
const responseId = `chatcmpl-gitlab-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000);
if (hasTools) {
const {
content: toolContent,
toolCalls,
finishReason,
} = buildToolAwareResult(content, requestedTools, "gitlab");
const message: Record<string, unknown> = { role: "assistant", content: toolContent };
if (toolCalls) {
message.tool_calls = toolCalls;
message.content = null;
}
const response = input.stream
? buildToolStreamingResponse(message, finishReason, resolvedModel, responseId, created)
: buildToolJsonCompletion(message, finishReason, resolvedModel, responseId, created);
return { response, url: activeTarget.url, headers: requestHeaders, transformedBody };
}
const response = input.stream
? buildStreamingResponse(content, resolvedModel, responseId, created)
: buildJsonCompletion(content, resolvedModel, responseId, created);
return { response, url: activeTarget.url, headers: requestHeaders, transformedBody };
}
}
export default GitlabExecutor;