mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Co-authored-by: felssxs <felssxs@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
513e3b0047
commit
2a20e5ca47
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
191
open-sse/executors/gitlabResponses.ts
Normal file
191
open-sse/executors/gitlabResponses.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
145
tests/unit/gitlab-duo-toolcalls-6051.test.ts
Normal file
145
tests/unit/gitlab-duo-toolcalls-6051.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user