diff --git a/CHANGELOG.md b/CHANGELOG.md index 34565d9463..870b3a5b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/open-sse/executors/gitlab.ts b/open-sse/executors/gitlab.ts index 9ccec9a46b..888e10b94a 100644 --- a/open-sse/executors/gitlab.ts +++ b/open-sse/executors/gitlab.ts @@ -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({ - 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 | null | undefined @@ -572,9 +484,20 @@ export class GitlabExecutor extends BaseExecutor { } async execute(input: ExecuteInput) { - const prompt = buildPrompt( - (input.body as Record)?.messages as OpenAIMessage[] + const bodyObj = (input.body as Record) || {}; + 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 `{...}` 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) || {}, + { ...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 = { 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); diff --git a/open-sse/executors/gitlabResponses.ts b/open-sse/executors/gitlabResponses.ts new file mode 100644 index 0000000000..0e98bb216c --- /dev/null +++ b/open-sse/executors/gitlabResponses.ts @@ -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({ + 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, + 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, + finishReason: string, + model: string, + id: string, + created: number +): Response { + const encoder = new TextEncoder(); + + const body = new ReadableStream({ + 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" }, + }); +} diff --git a/tests/unit/gitlab-duo-toolcalls-6051.test.ts b/tests/unit/gitlab-duo-toolcalls-6051.test.ts new file mode 100644 index 0000000000..3bf8df51ef --- /dev/null +++ b/tests/unit/gitlab-duo-toolcalls-6051.test.ts @@ -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 }> = []; + + // 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{"name": "get_weather", "arguments": {"city": "Paris"}}', + }, + ], + }); + }; + + 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: '{"name": "get_weather", "arguments": {"city": "Paris"}}' }], + }); + + 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; + } +});