diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8d5bc29e04..7495e6d531 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1204,7 +1204,7 @@ export async function handleChatCore({ // Phase 4A: unified output styles (supersedes cavemanOutputMode via the back-compat shim). let outputStyleResult: import("../services/compression/outputStyles/apply.ts").OutputStylesResult | null = null; - if (config.enabled) { + if (config.enabled && compressionHeader?.trim().toLowerCase() !== "off") { try { const { resolveOutputStyleSelection } = await import("../services/compression/outputStyles/backCompat.ts"); diff --git a/src/lib/api/modelTestRunner.ts b/src/lib/api/modelTestRunner.ts index 175a46b167..87a512fa30 100644 --- a/src/lib/api/modelTestRunner.ts +++ b/src/lib/api/modelTestRunner.ts @@ -113,7 +113,7 @@ async function findCustomModelMetadata(providerId: string, modelId: string) { } } -function buildInternalChatRequest(testBody: Record, signal: AbortSignal) { +export function buildInternalChatRequest(testBody: Record, signal: AbortSignal) { return new Request(`${INTERNAL_ORIGIN}/v1/chat/completions`, { method: "POST", headers: { @@ -121,6 +121,9 @@ function buildInternalChatRequest(testBody: Record, signal: Abo // Reuse the existing strict-mode internal bypass for live health checks. "X-Internal-Test": "combo-health-check", "X-OmniRoute-No-Cache": "true", + // #6240: a connection test must be clean — never let the operator's globally-enabled + // Output Styles (e.g. "Ultra terse") leak a system prompt into a test-model call. + "X-OmniRoute-Compression": "off", "X-Request-Id": `model-test-${randomUUID()}`, }, body: JSON.stringify(testBody), @@ -128,13 +131,14 @@ function buildInternalChatRequest(testBody: Record, signal: Abo }); } -function buildInternalRerankRequest(testBody: Record, signal: AbortSignal) { +export function buildInternalRerankRequest(testBody: Record, signal: AbortSignal) { return new Request(`${INTERNAL_ORIGIN}/v1/rerank`, { method: "POST", headers: { "Content-Type": "application/json", "X-Internal-Test": "combo-health-check", "X-OmniRoute-No-Cache": "true", + "X-OmniRoute-Compression": "off", "X-Request-Id": `model-test-${randomUUID()}`, }, body: JSON.stringify(testBody), diff --git a/tests/integration/test-model-compression-off-6240.test.ts b/tests/integration/test-model-compression-off-6240.test.ts new file mode 100644 index 0000000000..b5ad8ce857 --- /dev/null +++ b/tests/integration/test-model-compression-off-6240.test.ts @@ -0,0 +1,157 @@ +// #6240 — the dashboard "Test model" action must be a clean connection test: it must NOT carry +// the operator's globally-enabled Output Styles system-prompt injection (e.g. "Ultra terse"). +// +// Root cause: Phase 4A of handleChatCore (open-sse/handlers/chatCore.ts) injects the Output +// Styles system message whenever the operator's global `compression.enabled` flag is on, +// completely independent of the per-request `x-omniroute-compression` header/mode. The internal +// "Test model" request builder (src/lib/api/modelTestRunner.ts::buildInternalChatRequest) never +// sent that header, so a globally-enabled output style always leaked into test-model calls. +// +// This test locks the *chatCore* half of the fix directly: with Output Styles globally enabled, +// a request carrying `x-omniroute-compression: off` must NOT get the styles system message +// injected, while an otherwise-identical request without the header still does (so we're +// actually testing the new gate, not something else disabling output styles). +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-testmodel-compression-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.REQUIRE_API_KEY = "false"; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-testmodel-compression-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const readCacheDb = await import("../../src/lib/db/readCache.ts"); +const compressionDb = await import("../../src/lib/db/compression.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); +const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + globalThis.fetch = originalFetch; + resetAllCircuitBreakers(); + readCacheDb.invalidateDbCache(); + await new Promise((resolve) => setTimeout(resolve, 20)); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + globalThis.fetch = originalFetch; + core.closeDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +async function runChatCore(opts: { + provider: string; + model: string; + connectionId: string; + headers: Headers; +}) { + let capturedBody: { messages?: Array<{ role?: string; content?: string }> } | null = null; + globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.body) { + capturedBody = JSON.parse(init.body as string) as typeof capturedBody; + } + return new Response( + JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok" } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleChatCore({ + body: { + model: opts.model, + stream: false, + messages: [{ role: "user", content: "ping" }], + }, + modelInfo: { provider: opts.provider, model: opts.model }, + credentials: { apiKey: "test-key" }, + log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + clientRawRequest: { endpoint: "/v1/chat/completions", headers: opts.headers }, + connectionId: opts.connectionId, + onCredentialsRefreshed: () => {}, + onRequestSuccess: () => {}, + onStreamFailure: () => {}, + onDisconnect: () => {}, + userAgent: "test-agent", + comboName: null, + }); + assert.ok(result.success, "Request should succeed"); + return capturedBody; + } finally { + globalThis.fetch = originalFetch; + } +} + +test("chatCore: x-omniroute-compression: off suppresses Output Styles injection even with the operator's global style enabled (#6240)", async () => { + const provider = "openai"; + const model = "gpt-4"; + + await compressionDb.updateCompressionSettings({ + enabled: true, + defaultMode: "off", + autoTriggerTokens: 0, + cavemanOutputMode: { + enabled: true, + intensity: "full", + autoClarity: true, + }, + }); + + const connection = await providersDb.createProviderConnection({ + provider, + apiKey: "test-key", + isActive: true, + }); + + // Sanity check: WITHOUT the opt-out header, the globally-enabled style still injects (proves + // the fixture actually exercises Output Styles, so the assertion below is meaningful). + const withoutOptOut = await runChatCore({ + provider, + model, + connectionId: connection.id, + headers: new Headers(), + }); + const plainFirstMessage = withoutOptOut?.messages?.[0]; + assert.equal(plainFirstMessage?.role, "system"); + assert.match(plainFirstMessage?.content ?? "", /OmniRoute Output Styles/); + + // The "Test model" connection test sends x-omniroute-compression: off — must be clean. + const testModelBody = await runChatCore({ + provider, + model, + connectionId: connection.id, + headers: new Headers({ "x-omniroute-compression": "off" }), + }); + const testModelFirstMessage = testModelBody?.messages?.[0]; + assert.ok( + !testModelFirstMessage || testModelFirstMessage.role !== "system", + "Test-model request (compression:off) must not receive an injected Output Styles system message" + ); + const anyMessageHasMarker = (testModelBody?.messages ?? []).some((m) => + (m?.content ?? "").includes("OmniRoute Output Styles") + ); + assert.equal( + anyMessageHasMarker, + false, + "No message in the compression:off request should carry the Output Styles marker" + ); +}); diff --git a/tests/unit/model-test-runner-compression-off-6240.test.ts b/tests/unit/model-test-runner-compression-off-6240.test.ts new file mode 100644 index 0000000000..b1f410e46d --- /dev/null +++ b/tests/unit/model-test-runner-compression-off-6240.test.ts @@ -0,0 +1,23 @@ +// #6240 — the "Test model" internal request builders must always send +// `X-OmniRoute-Compression: off` so a globally-enabled Output Style (e.g. "Ultra terse") never +// leaks a system-prompt injection into a plain connection test. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildInternalChatRequest, + buildInternalRerankRequest, +} from "@/lib/api/modelTestRunner.ts"; + +test("buildInternalChatRequest sends X-OmniRoute-Compression: off", () => { + const controller = new AbortController(); + const request = buildInternalChatRequest({ model: "openai/gpt-4" }, controller.signal); + assert.equal(request.headers.get("X-OmniRoute-Compression"), "off"); + assert.equal(request.headers.get("X-OmniRoute-No-Cache"), "true"); +}); + +test("buildInternalRerankRequest sends X-OmniRoute-Compression: off", () => { + const controller = new AbortController(); + const request = buildInternalRerankRequest({ model: "openai/rerank-1" }, controller.signal); + assert.equal(request.headers.get("X-OmniRoute-Compression"), "off"); + assert.equal(request.headers.get("X-OmniRoute-No-Cache"), "true"); +});