mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
fix(vision): preserve images for text-only routes (#9037)
* fix(vision): preserve images for text-only routes * fix(i18n): complete Vietnamese vision bridge copy * fix(ci): drain prerelease tag input --------- Co-authored-by: rinseaid <rinseaid@rinseaid.net>
This commit is contained in:
@@ -26,6 +26,9 @@ VERSION="${1:?version required}"
|
||||
# `main` and `next`, plus every pre-release identifier, fail closed here even if
|
||||
# a caller forgets to short-circuit them first.
|
||||
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
# Consume the caller's tag stream before exiting. A pre-release decision is
|
||||
# immediate, but closing stdin early can give a piped producer EPIPE.
|
||||
cat >/dev/null
|
||||
echo "false"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -19,6 +19,7 @@ interface VisionState {
|
||||
modalityBridgeVisionMode: VisionBridgeMode;
|
||||
modalityBridgeVisionModel: string;
|
||||
modalityBridgeVisionTaskAware: boolean;
|
||||
visionBridgeRerouteTextOnly: boolean;
|
||||
modalityBridgeVisionPrompt: string;
|
||||
modalityBridgeVisionTimeout: number;
|
||||
modalityBridgeVisionMaxImages: number;
|
||||
@@ -34,6 +35,7 @@ function fromApi(data: Record<string, unknown>): VisionState {
|
||||
modalityBridgeVisionMode: runtime.mode,
|
||||
modalityBridgeVisionModel: runtime.model,
|
||||
modalityBridgeVisionTaskAware: runtime.taskAware,
|
||||
visionBridgeRerouteTextOnly: data.visionBridgeRerouteTextOnly === true,
|
||||
modalityBridgeVisionPrompt: runtime.prompt,
|
||||
modalityBridgeVisionTimeout: runtime.timeoutMs,
|
||||
modalityBridgeVisionMaxImages: runtime.maxImages,
|
||||
@@ -119,6 +121,13 @@ export default function ModalityBridgeVisionTab() {
|
||||
description={t("visionBridgeEnabledDesc")}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
checked={settings.visionBridgeRerouteTextOnly}
|
||||
onChange={(checked) => void update({ visionBridgeRerouteTextOnly: checked })}
|
||||
label={t("visionBridgeRerouteTextOnlyLabel")}
|
||||
description={t("visionBridgeRerouteTextOnlyDesc")}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="modality-bridge-mode">
|
||||
{t("modalityBridgeMode")}
|
||||
|
||||
@@ -7250,6 +7250,8 @@
|
||||
"visionBridgeDesc": "Run an automatic vision-to-text fallback before routing image requests to text-only models.",
|
||||
"visionBridgeEnabledLabel": "Enabled",
|
||||
"visionBridgeEnabledDesc": "Toggle the pre-call bridge that replaces image parts with extracted text.",
|
||||
"visionBridgeRerouteTextOnlyLabel": "Reroute text-only models",
|
||||
"visionBridgeRerouteTextOnlyDesc": "Send the complete request, including images, to the bridge model when the selected model has no image input.",
|
||||
"visionBridgeModel": "Bridge Model",
|
||||
"visionBridgeModelPlaceholder": "openai/gpt-4o-mini",
|
||||
"visionBridgeModelHint": "Any OmniRoute model ID that supports vision can be used here.",
|
||||
|
||||
@@ -7237,6 +7237,8 @@
|
||||
"visionBridgeDesc": "Executa um fallback automático de visão para texto antes de rotear solicitações de imagem para modelos que aceitam apenas texto.",
|
||||
"visionBridgeEnabledLabel": "Ativado",
|
||||
"visionBridgeEnabledDesc": "Ativa a ponte pré-chamada que substitui partes de imagem pelo texto extraído.",
|
||||
"visionBridgeRerouteTextOnlyLabel": "Redirecionar modelos somente de texto",
|
||||
"visionBridgeRerouteTextOnlyDesc": "Envia a solicitação completa, incluindo imagens, ao modelo de ponte quando o modelo selecionado não aceita entrada de imagem.",
|
||||
"visionBridgeModel": "Modelo de ponte",
|
||||
"visionBridgeModelPlaceholder": "openai/gpt-4o-mini",
|
||||
"visionBridgeModelHint": "Qualquer ID de modelo OmniRoute que suporte visão pode ser usado aqui.",
|
||||
|
||||
@@ -7231,6 +7231,8 @@
|
||||
"visionBridgeDesc": "Tự động chạy phương án dự phòng chuyển đổi hình ảnh sang văn bản trước khi định tuyến các yêu cầu hình ảnh đến các mô hình chỉ hỗ trợ văn bản.",
|
||||
"visionBridgeEnabledLabel": "Đã bật",
|
||||
"visionBridgeEnabledDesc": "Bật/tắt cầu nối trước cuộc gọi để thay thế các phần hình ảnh bằng văn bản được trích xuất.",
|
||||
"visionBridgeRerouteTextOnlyLabel": "Định tuyến lại các mô hình chỉ có văn bản",
|
||||
"visionBridgeRerouteTextOnlyDesc": "Gửi yêu cầu hoàn chỉnh, bao gồm hình ảnh, tới mô hình cầu nối khi mô hình được chọn không hỗ trợ đầu vào hình ảnh.",
|
||||
"visionBridgeModel": "Mô hình cầu nối",
|
||||
"visionBridgeModelPlaceholder": "openai/gpt-4o-mini",
|
||||
"visionBridgeModelHint": "Có thể sử dụng bất kỳ ID mô hình OmniRoute nào hỗ trợ xử lý hình ảnh ở đây.",
|
||||
|
||||
@@ -258,8 +258,13 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
|
||||
// request with model=auto would land on a text-only model (#7871). Keeping
|
||||
// "auto" is never the answer there, so the keep-credentialed-model skip
|
||||
// below does not apply to auto — only the reroute-target credential guard.
|
||||
const rerouteTextOnly = settings.visionBridgeRerouteTextOnly === true;
|
||||
// Reroute when the operator opted in to direct VLM routing for every text-only
|
||||
// route (keeps image bytes instead of a lossy bridge description), or when the
|
||||
// auto heuristic deems the request eligible.
|
||||
const rerouteEligible =
|
||||
(comboVisionBridgeDecision === "not-combo" || isAuto) && !forceVisionBridge;
|
||||
rerouteTextOnly ||
|
||||
((comboVisionBridgeDecision === "not-combo" || isAuto) && !forceVisionBridge);
|
||||
// Forced modes short-circuit BEFORE the auto heuristic (#6640/#7204 untouched):
|
||||
// - "describe" skips the whole reroute block → straight to the describe path.
|
||||
// - "reroute" skips only the keep-credentialed-model guard; the reroute-target
|
||||
@@ -269,7 +274,7 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
|
||||
const checkCreds = this.deps.hasUsableCredentials ?? hasUsableCredentialsForModel;
|
||||
const originalUsable = runtime.mode === "reroute" ? false : await checkCreds(model);
|
||||
|
||||
if (originalUsable === true && !isAuto) {
|
||||
if (originalUsable === true && !isAuto && !rerouteTextOnly) {
|
||||
// Keep the credentialed model; describe images below if needed.
|
||||
context.log?.debug?.(
|
||||
"VISION_BRIDGE",
|
||||
|
||||
@@ -53,6 +53,7 @@ export const VISION_BRIDGE_SETTINGS_KEYS = [
|
||||
"visionBridgePrompt",
|
||||
"visionBridgeTimeout",
|
||||
"visionBridgeMaxImages",
|
||||
"visionBridgeRerouteTextOnly",
|
||||
] as const;
|
||||
|
||||
export type VisionBridgeSettings = {
|
||||
@@ -61,6 +62,8 @@ export type VisionBridgeSettings = {
|
||||
visionBridgePrompt?: string;
|
||||
visionBridgeTimeout?: number;
|
||||
visionBridgeMaxImages?: number;
|
||||
/** Route text-only image requests to the configured VLM instead of describing them. */
|
||||
visionBridgeRerouteTextOnly?: boolean;
|
||||
};
|
||||
|
||||
export type VisionBridgeConfig = {
|
||||
|
||||
@@ -347,6 +347,7 @@ export const updateSettingsSchema = z.object({
|
||||
modalityBridgeCacheEnabled: z.boolean().optional(),
|
||||
modalityBridgeCacheTtlMinutes: z.number().int().min(1).max(1440).optional(),
|
||||
modalityBridgeCacheMaxEntries: z.number().int().min(10).max(5000).optional(),
|
||||
visionBridgeRerouteTextOnly: z.boolean().optional(),
|
||||
// Missing settings
|
||||
lkgpEnabled: z.boolean().optional(),
|
||||
// #1311: echo the requested alias/combo name in the response model field (opt-in)
|
||||
|
||||
@@ -761,6 +761,46 @@ test("VB-CRED-01: does NOT whole-request-reroute when original model has usable
|
||||
assert.notStrictEqual(meta?.rerouted, true, "must not set rerouted meta for credentialed model");
|
||||
});
|
||||
|
||||
test("VB-CRED-01A: reroutes a credentialed text-only model when configured to preserve images", async () => {
|
||||
mockSettings.visionBridgeRerouteTextOnly = true;
|
||||
const guardrail = createGuardrail({
|
||||
deps: {
|
||||
hasUsableCredentials: async (m: string) =>
|
||||
m === "zai/glm-5.2" || m === "openai/gpt-4o-mini" ? true : null,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = createPayload({
|
||||
model: "zai/glm-5.2",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What is in this screenshot?" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: "https://example.com/shot.png" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await guardrail.preCall(payload, createContext({ model: "zai/glm-5.2" }));
|
||||
assert.strictEqual(result.block, false);
|
||||
|
||||
const modified = result.modifiedPayload as {
|
||||
model?: string;
|
||||
messages: Array<{ content: Array<{ type: string; image_url?: { url: string } }> }>;
|
||||
};
|
||||
assert.strictEqual(modified.model, "openai/gpt-4o-mini");
|
||||
assert.deepStrictEqual(modified.messages[0].content[1], {
|
||||
type: "image_url",
|
||||
image_url: { url: "https://example.com/shot.png" },
|
||||
});
|
||||
assert.strictEqual(visionCallCount, 0, "the bridge must not replace the image with text");
|
||||
});
|
||||
|
||||
test("VB-CRED-02: does NOT reroute to a vision model known to lack credentials", async () => {
|
||||
mockSettings.visionBridgeModel = "opencode-zen/gpt-5.4";
|
||||
const guardrail = createGuardrail({
|
||||
|
||||
64
tests/unit/ui/visionBridgeSettingsTab.test.tsx
Normal file
64
tests/unit/ui/visionBridgeSettingsTab.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ModalityBridgeVisionTab from "@/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVisionTab";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
function jsonResponse(data: unknown) {
|
||||
return { ok: true, json: async () => data } as Response;
|
||||
}
|
||||
|
||||
describe("VisionBridgeSettingsTab (Modality Bridge vision tab)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/models")) {
|
||||
return jsonResponse({ models: [] });
|
||||
}
|
||||
if (url.includes("/api/modality-bridge/stats")) {
|
||||
return jsonResponse({ vision: { bridged: 0, cacheHits: 0, failures: 0 }, audio: {} });
|
||||
}
|
||||
if (init?.method === "PATCH") return jsonResponse({});
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<ModalityBridgeVisionTab />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("exposes the text-only reroute setting and persists a toggle", async () => {
|
||||
expect(container.textContent).toContain("visionBridgeRerouteTextOnlyLabel");
|
||||
const toggles = container.querySelectorAll('[role="switch"]');
|
||||
const rerouteToggle = toggles.item(1) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
rerouteToggle.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ visionBridgeRerouteTextOnly: true }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ test("VISION_BRIDGE_SETTINGS_KEYS exports all expected keys", () => {
|
||||
"visionBridgePrompt",
|
||||
"visionBridgeTimeout",
|
||||
"visionBridgeMaxImages",
|
||||
"visionBridgeRerouteTextOnly",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user