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:
rinseaid
2026-08-11 03:30:10 -04:00
committed by GitHub
parent 835de9d749
commit 1f45cf885c
11 changed files with 134 additions and 2 deletions

View File

@@ -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

View File

@@ -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")}

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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({

View 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 }),
})
);
});
});

View File

@@ -30,6 +30,7 @@ test("VISION_BRIDGE_SETTINGS_KEYS exports all expected keys", () => {
"visionBridgePrompt",
"visionBridgeTimeout",
"visionBridgeMaxImages",
"visionBridgeRerouteTextOnly",
]);
});