fix(providers): emulate OpenAI tool_calls in GitLab Duo executor (#6051) (#6111)

Co-authored-by: felssxs <felssxs@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-03 12:49:56 -03:00
committed by GitHub
parent 513e3b0047
commit 2a20e5ca47
4 changed files with 377 additions and 98 deletions

View File

@@ -35,6 +35,8 @@
- **combo (per-model-quota providers exhausted on a single model 500):** for per-model-quota providers (gemini, github, passthrough, compatible) that multiplex many models behind one connection, a model-level `500` (e.g. Gemini "Internal error encountered") wrongly marked the whole connection exhausted, so sibling models on the same connection were skipped and the combo could 502 instead of falling back. `markConnectionLevelExhaustion` now leaves the connection eligible on a `500` for per-model-quota providers (other connection-level statuses — 408/502/503/504/524 — still exhaust correctly), and the retry loop early-returns when a model is already in lockout. Regression guard: `tests/unit/combo/combo-target-exhaustion.test.ts`. ([#5976](https://github.com/diegosouzapw/OmniRoute/pull/5976) — thanks @hartmark)
- fix(providers): GitLab Duo executor now emulates OpenAI tool_calls (#6051)
### 📝 Maintenance
- **ci (env-doc base-red):** document `BIFROST_PORT` in `.env.example` + `docs/reference/ENVIRONMENT.md`. The Bifrost embedded-service merge referenced `process.env.BIFROST_PORT` (`src/lib/services/bootstrap.ts`, default `8080`) without documenting it, so `check:env-doc-sync` failed on the release tip — reddening the `Fast Quality Gates` job for **every** open PR→release regardless of its content. Docs-only; no runtime change.

View File

@@ -10,6 +10,13 @@ import {
} 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,
@@ -112,101 +119,6 @@ function toOpenAIError(status: number, message: string): Response {
);
}
function buildSseChunk(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
function buildStreamingResponse(
content: string,
model: string,
id: string,
created: number
): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})
)
);
if (content) {
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content }, finish_reason: null }],
})
)
);
}
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
function buildJsonCompletion(
content: string,
model: string,
id: string,
created: number
): Response {
const estimated = Math.max(1, Math.ceil(content.length / 4));
return new Response(
JSON.stringify({
id,
object: "chat.completion",
created,
model,
choices: [
{
index: 0,
message: { role: "assistant", content },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: estimated,
completion_tokens: estimated,
total_tokens: estimated * 2,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
function mergeCredentials(
current: ProviderCredentials,
patch: Partial<ProviderCredentials> | null | undefined
@@ -572,9 +484,20 @@ export class GitlabExecutor extends BaseExecutor {
}
async execute(input: ExecuteInput) {
const prompt = buildPrompt(
(input.body as Record<string, unknown>)?.messages as OpenAIMessage[]
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"),
@@ -598,7 +521,7 @@ export class GitlabExecutor extends BaseExecutor {
const transformedBody = this.transformRequest(
input.model,
(input.body as Record<string, unknown>) || {},
{ ...bodyObj, messages: effectiveMessages },
false,
activeCredentials
);
@@ -684,6 +607,24 @@ export class GitlabExecutor extends BaseExecutor {
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);

View File

@@ -0,0 +1,191 @@
// OpenAI-shaped response builders for the GitLab Duo executor. Extracted from
// gitlab.ts (leaf module — must not import from gitlab.ts) so the executor stays
// under the file-size cap. Covers plain text (streaming + JSON) and the
// tool_calls emulation variants added for #6051.
function buildSseChunk(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
export function buildStreamingResponse(
content: string,
model: string,
id: string,
created: number
): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})
)
);
if (content) {
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content }, finish_reason: null }],
})
)
);
}
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
export function buildJsonCompletion(
content: string,
model: string,
id: string,
created: number
): Response {
const estimated = Math.max(1, Math.ceil(content.length / 4));
return new Response(
JSON.stringify({
id,
object: "chat.completion",
created,
model,
choices: [
{
index: 0,
message: { role: "assistant", content },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: estimated,
completion_tokens: estimated,
total_tokens: estimated * 2,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
export function buildToolJsonCompletion(
message: Record<string, unknown>,
finishReason: string,
model: string,
id: string,
created: number
): Response {
const contentForEstimate = typeof message.content === "string" ? message.content : "";
const estimated = Math.max(1, Math.ceil(contentForEstimate.length / 4));
return new Response(
JSON.stringify({
id,
object: "chat.completion",
created,
model,
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
usage: {
prompt_tokens: estimated,
completion_tokens: estimated,
total_tokens: estimated * 2,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
export function buildToolStreamingResponse(
message: Record<string, unknown>,
finishReason: string,
model: string,
id: string,
created: number
): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})
)
);
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: message, finish_reason: null }],
})
)
);
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}

View File

@@ -0,0 +1,145 @@
import test from "node:test";
import assert from "node:assert/strict";
import { GitlabExecutor } from "../../open-sse/executors/gitlab.ts";
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
const WEATHER_TOOL = {
type: "function",
function: {
name: "get_weather",
description: "Get the current weather for a city",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
};
test("GitlabExecutor emulates OpenAI tool_calls when body.tools is present (#6051)", async () => {
const executor = new GitlabExecutor();
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
// Upstream (GitLab code_suggestions) replies with the tool invocation as raw
// text, exactly how a web/completion model would when handed the tool contract.
globalThis.fetch = async (url, init = {}) => {
calls.push({ url: String(url), body: JSON.parse(String(init.body || "{}")) });
return jsonResponse({
model: { name: "code-gecko" },
choices: [
{
text: 'Sure, let me check.\n<tool>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool>',
},
],
});
};
try {
const result = await executor.execute({
model: "gitlab-duo-code-suggestions",
body: {
messages: [{ role: "user", content: "What's the weather in Paris?" }],
tools: [WEATHER_TOOL],
tool_choice: "auto",
},
stream: false,
credentials: { apiKey: "glpat-test" },
signal: AbortSignal.timeout(10_000),
log: null,
} as any);
// The serialized tool contract must reach the GitLab prompt.
assert.equal(calls.length, 1);
assert.match(
String((calls[0].body.current_file as any)?.content_above_cursor ?? ""),
/get_weather/,
"tool contract must be serialized into the GitLab prompt"
);
const body = (await result.response.json()) as any;
const choice = body.choices[0];
assert.equal(choice.finish_reason, "tool_calls");
assert.ok(Array.isArray(choice.message.tool_calls), "tool_calls array must be present");
assert.equal(choice.message.tool_calls.length, 1);
assert.equal(choice.message.tool_calls[0].type, "function");
assert.equal(choice.message.tool_calls[0].function.name, "get_weather");
assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), {
city: "Paris",
});
assert.equal(choice.message.content, null);
} finally {
globalThis.fetch = originalFetch;
}
});
test("GitlabExecutor streams a tool_calls chunk when tools are present (#6051)", async () => {
const executor = new GitlabExecutor();
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
jsonResponse({
model: { name: "code-gecko" },
choices: [{ text: '<tool>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool>' }],
});
try {
const result = await executor.execute({
model: "gitlab-duo-code-suggestions",
body: {
messages: [{ role: "user", content: "weather in Paris?" }],
tools: [WEATHER_TOOL],
},
stream: true,
credentials: { apiKey: "glpat-test" },
signal: AbortSignal.timeout(10_000),
log: null,
} as any);
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
const text = await result.response.text();
assert.match(text, /"tool_calls"/, "SSE stream must carry a tool_calls delta");
assert.match(text, /get_weather/);
assert.match(text, /"finish_reason":"tool_calls"/);
assert.match(text, /data: \[DONE\]/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("GitlabExecutor keeps plain-text finish_reason:stop for non-tool requests (#6051)", async () => {
const executor = new GitlabExecutor();
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
jsonResponse({
model: { name: "code-gecko" },
choices: [{ text: "def hello():\n return 'world'" }],
});
try {
const result = await executor.execute({
model: "gitlab-duo-code-suggestions",
body: { messages: [{ role: "user", content: "Write a hello world function" }] },
stream: false,
credentials: { apiKey: "glpat-test" },
signal: AbortSignal.timeout(10_000),
log: null,
} as any);
const body = (await result.response.json()) as any;
const choice = body.choices[0];
assert.equal(choice.finish_reason, "stop");
assert.equal(choice.message.tool_calls, undefined);
assert.match(choice.message.content, /hello/);
} finally {
globalThis.fetch = originalFetch;
}
});