diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 0544a90db3..dd733d926c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -249,11 +249,7 @@ import { getTokenLimit, resolveComboContextLimit, } from "../services/contextManager.ts"; -import { - getBackgroundTaskReason, - getDegradedModel, - getBackgroundDegradationConfig, -} from "../services/backgroundTaskDetector.ts"; +import { resolveBackgroundTaskRedirect } from "./chatCore/backgroundRedirect.ts"; import type { CompressionConfig, CompressionPipelineStep } from "../services/compression/types.ts"; import { prepareWebSearchFallbackBody } from "../services/webSearchFallback.ts"; import { @@ -904,35 +900,35 @@ export async function handleChatCore({ // Detect source format and get target format // Model-specific targetFormat takes priority over provider default - // ── Background Task Redirection (T41) ── - const bgConfig = getBackgroundDegradationConfig(); - const backgroundReason = bgConfig.enabled - ? getBackgroundTaskReason(body, clientRawRequest?.headers) - : null; - if (backgroundReason) { - const degradedModel = getDegradedModel(model); - if (degradedModel !== model) { - const originalModel = model; - log?.info?.( - "BACKGROUND", - `Background task redirect (${backgroundReason}): ${originalModel} → ${degradedModel}` - ); - model = degradedModel; - if (body && typeof body === "object") { - body.model = model; - } - - logAuditEvent({ - action: "routing.background_task_redirect", - actor: apiKeyInfo?.name || "system", - target: connectionId || provider || "chat", - details: { - original_model: originalModel, - redirected_to: degradedModel, - reason: backgroundReason, - }, - }); + // ── Background Task Redirection (T41) — decision extracted to chatCore/backgroundRedirect.ts (#3501) + // backgroundReason is the detection signal (threaded into memory/skills injection below); redirect + // is the actual model downgrade to apply, if any. + const { backgroundReason, redirect: bgRedirect } = resolveBackgroundTaskRedirect({ + body, + headers: clientRawRequest?.headers, + model, + }); + if (bgRedirect) { + const originalModel = model; + log?.info?.( + "BACKGROUND", + `Background task redirect (${bgRedirect.reason}): ${originalModel} → ${bgRedirect.degradedModel}` + ); + model = bgRedirect.degradedModel; + if (body && typeof body === "object") { + body.model = model; } + + logAuditEvent({ + action: "routing.background_task_redirect", + actor: apiKeyInfo?.name || "system", + target: connectionId || provider || "chat", + details: { + original_model: originalModel, + redirected_to: bgRedirect.degradedModel, + reason: bgRedirect.reason, + }, + }); } // Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315, #472) diff --git a/open-sse/handlers/chatCore/backgroundRedirect.ts b/open-sse/handlers/chatCore/backgroundRedirect.ts new file mode 100644 index 0000000000..3aa6c645a9 --- /dev/null +++ b/open-sse/handlers/chatCore/backgroundRedirect.ts @@ -0,0 +1,41 @@ +/** + * chatCore Background Task Redirection decision (T41) (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Decides whether a request should be downgraded to a cheaper "degraded" model: only when the + * feature is enabled, the request looks like a background/utility task, AND the model has a + * degradation mapping. Returns the target model + the detection reason, or null for no redirect. + * The handler keeps the side effects byte-identically (the BACKGROUND log, the model + body.model + * mutation, and the audit event). Reads the live backgroundTaskDetector config, mirroring the + * previous inline block. + */ + +import { + getBackgroundDegradationConfig, + getBackgroundTaskReason, + getDegradedModel, +} from "../../services/backgroundTaskDetector.ts"; + +export function resolveBackgroundTaskRedirect(opts: { + body: unknown; + headers: Record | null | undefined; + model: string; +}): { + /** The background-task detection reason (truthy iff the request looks like a background task), + * threaded downstream into memory/skills injection — independent of whether a redirect happens. */ + backgroundReason: string | null; + /** The actual model redirect to apply, or null when there is none (disabled, not a background + * task, or the model has no — or a self — degradation mapping). */ + redirect: { degradedModel: string; reason: string } | null; +} { + const bgConfig = getBackgroundDegradationConfig(); + const backgroundReason = bgConfig.enabled + ? getBackgroundTaskReason(opts.body, opts.headers ?? null) + : null; + if (!backgroundReason) return { backgroundReason: null, redirect: null }; + + const degradedModel = getDegradedModel(opts.model); + if (degradedModel === opts.model) return { backgroundReason, redirect: null }; + + return { backgroundReason, redirect: { degradedModel, reason: backgroundReason } }; +} diff --git a/tests/unit/chatcore-background-redirect.test.ts b/tests/unit/chatcore-background-redirect.test.ts new file mode 100644 index 0000000000..4873c0172a --- /dev/null +++ b/tests/unit/chatcore-background-redirect.test.ts @@ -0,0 +1,69 @@ +// tests/unit/chatcore-background-redirect.test.ts +// Characterization of resolveBackgroundTaskRedirect — the Background Task Redirection (T41) +// decision extracted from handleChatCore (chatCore god-file decomposition, #3501). Decides whether +// a request should be downgraded to a cheaper model: only when the feature is enabled, the request +// looks like a background task, AND the model has a degradation mapping. The handler keeps the +// effects (log, model/body mutation, audit). Uses the real backgroundTaskDetector config via its +// setter so the decision is exercised end-to-end. +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { resolveBackgroundTaskRedirect } from "../../open-sse/handlers/chatCore/backgroundRedirect.ts"; +import { setBackgroundDegradationConfig } from "../../open-sse/services/backgroundTaskDetector.ts"; + +afterEach(() => { + setBackgroundDegradationConfig({ enabled: false, degradationMap: {} }); +}); + +test("disabled config → no detection, no redirect (even for a clear background task)", () => { + setBackgroundDegradationConfig({ enabled: false, degradationMap: { "gpt-5": "gpt-5-mini" } }); + assert.deepEqual( + resolveBackgroundTaskRedirect({ body: { max_tokens: 10 }, headers: null, model: "gpt-5" }), + { backgroundReason: null, redirect: null } + ); +}); + +test("enabled + low-max-tokens background + mapped model → detection + redirect", () => { + setBackgroundDegradationConfig({ enabled: true, degradationMap: { "gpt-5": "gpt-5-mini" } }); + const r = resolveBackgroundTaskRedirect({ + body: { max_tokens: 10 }, + headers: null, + model: "gpt-5", + }); + assert.deepEqual(r, { + backgroundReason: "low_max_tokens", + redirect: { degradedModel: "gpt-5-mini", reason: "low_max_tokens" }, + }); +}); + +test("enabled + x-task-type:background header → reason header_background", () => { + setBackgroundDegradationConfig({ enabled: true, degradationMap: { "gpt-5": "gpt-5-mini" } }); + const r = resolveBackgroundTaskRedirect({ + body: { messages: [{ role: "user", content: "hi" }] }, + headers: { "x-task-type": "background" }, + model: "gpt-5", + }); + assert.deepEqual(r, { + backgroundReason: "header_background", + redirect: { degradedModel: "gpt-5-mini", reason: "header_background" }, + }); +}); + +test("enabled but the request is not a background task → no detection, no redirect", () => { + setBackgroundDegradationConfig({ enabled: true, degradationMap: { "gpt-5": "gpt-5-mini" } }); + assert.deepEqual( + resolveBackgroundTaskRedirect({ + body: { max_tokens: 4096, messages: [{ role: "user", content: "write an essay" }] }, + headers: null, + model: "gpt-5", + }), + { backgroundReason: null, redirect: null } + ); +}); + +test("enabled + background but the model has no degradation mapping → detection but no redirect", () => { + setBackgroundDegradationConfig({ enabled: true, degradationMap: { "gpt-5": "gpt-5-mini" } }); + assert.deepEqual( + resolveBackgroundTaskRedirect({ body: { max_tokens: 10 }, headers: null, model: "claude-opus-4" }), + { backgroundReason: "low_max_tokens", redirect: null } + ); +});