diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 64749fe63f..9606629799 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -18,7 +18,7 @@ import { isProviderExhaustedReason, hasPerModelQuota, } from "./accountFallback.ts"; -import { FETCH_TIMEOUT_MS, RateLimitReason } from "../config/constants.ts"; +import { RateLimitReason } from "../config/constants.ts"; import { errorResponse, unavailableResponse } from "../utils/error.ts"; import { recordComboIntent, @@ -29,14 +29,11 @@ import { import { resolveComboConfig, getDefaultComboConfig, - resolveComboTargetTimeoutMs, } from "./comboConfig.ts"; import { maybeGenerateHandoff, - resolveContextRelayConfig, maybeGenerateUniversalHandoff, injectUniversalHandoffBody, - resolveUniversalHandoffConfig, SKIP_UNIVERSAL_HANDOFF_FLAG, type MessageLike, } from "./contextHandoff.ts"; @@ -53,7 +50,8 @@ import * as semaphore from "./rateLimitSemaphore.ts"; import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker"; import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck"; import { parseModel } from "./model.ts"; -import { applyComboAgentMiddleware } from "./comboAgentMiddleware.ts"; +import { createComboContext } from "./combo/context.ts"; +import { phaseComboSetup } from "./combo/comboSetup.ts"; import { checkCredentialGate, logCredentialSkip } from "./credentialGate.ts"; import { emit } from "../../src/lib/events/eventBus"; import { notifyWebhookEvent } from "../../src/lib/webhookDispatcher"; @@ -76,7 +74,6 @@ import type { RoutingHint } from "./manifestAdapter"; import { buildComplexityRoutingHint } from "./autoCombo/complexityRouter"; import type { CompressionMode } from "./compression/types.ts"; import { getProviderConnections } from "../../src/lib/db/providers"; -import { normalizeRoutingStrategy } from "../../src/shared/constants/routingStrategies.ts"; import { isProviderInCooldown, recordProviderCooldown, @@ -148,7 +145,6 @@ import { applyRequestTagRouting, scoreAutoTargets, expandAutoComboCandidatePool, - deriveComboSessionKey, } from "./combo/autoStrategy.ts"; import { resolveResetWindowConfig, @@ -426,67 +422,24 @@ export async function handleComboChat({ signal, apiKeyAllowedConnections = null, }: HandleComboChatOptions): Promise { - const strategy = normalizeRoutingStrategy(combo.strategy || "priority"); - const relayConfig = - strategy === "context-relay" ? resolveContextRelayConfig(relayOptions?.config || null) : null; - - const resilienceSettings: ResilienceSettings = settings - ? resolveResilienceSettings(settings) - : resolveResilienceSettings(null); - - const universalHandoffConfig = resolveUniversalHandoffConfig( - (combo.universal_handoff || combo.universalHandoff) as - | Record - | null - | undefined, - relayOptions?.universalHandoffConfig as Record | null | undefined - ); - // ── Server-side context cache pinning (replaces tag roundtrip) ─ - // Uses session_model_history — no client-side tag injection, no visible output pollution. - // - // #3825: when the client sends no session id (most OpenAI-compatible clients), fall - // back to a stable conversation fingerprint derived from the body so the combo still - // re-pins to the same model across turns. ONLY engaged when context_cache_protection - // is truthy — when the toggle is off, behavior is unchanged (combos rotate as before, - // no pin read/write, no tag). - const effectiveSessionId: string | null = combo.context_cache_protection - ? (relayOptions?.sessionId ?? deriveComboSessionKey(body)) - : null; - let pinnedModel: string | null = null; - if ( - combo.context_cache_protection && - effectiveSessionId && - !(body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] - ) { - const pinned = getLastSessionModel(effectiveSessionId, combo.name); - if (pinned) { - body = { ...body, model: pinned }; - pinnedModel = pinned; - log.info("COMBO", `[#401] Context cache: pinned model=${pinned} (server-side)`); - } - } - - // ── Combo Agent Middleware (#399 + #401) ──────────────────────────────── - // Apply system_message override, tool_filter_regex. - // Context cache pinning is handled above via session_model_history. - const { body: agentBody } = applyComboAgentMiddleware( - body, - combo, - "" // provider/model not yet known — resolved per-model in loop - ); - body = agentBody; - const clientRequestedStream = body?.stream === true; - // Context cache pinning is handled above via server-side session_model_history. - // No tag injection on response — use handleSingleModel directly. - // ───────────────────────────────────────────────────────────────────────── - - // Use config cascade before dispatch so all strategies, pinned context routes, - // and round-robin targets share the same timeout policy. - const config = settings - ? resolveComboConfig(combo, settings) - : { ...getDefaultComboConfig(), ...(combo.config || {}) }; - const comboTargetTimeoutMs = resolveComboTargetTimeoutMs(config, FETCH_TIMEOUT_MS); - const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false; + // Combo setup phase (god-file decomposition fase 1): strategy / relay / resilience / + // universal-handoff / context-cache pinning / agent middleware / config cascade / timeout. + // phaseComboSetup rewrites ctx.body (pinning + middleware); rebind `body` from it so the + // rest of handleComboChat is unchanged. See combo/comboSetup.ts + combo/context.ts. + const comboCtx = createComboContext({ body, combo, settings, relayOptions, log }); + const { + strategy, + relayConfig, + resilienceSettings, + universalHandoffConfig, + effectiveSessionId, + pinnedModel, + clientRequestedStream, + config, + comboTargetTimeoutMs, + reasoningTokenBufferEnabled, + } = phaseComboSetup(comboCtx); + body = comboCtx.body; // ── Per-model timeout wrapper ──────────────────────────────────────────── // Combo target timeouts inherit FETCH_TIMEOUT_MS by default. Operators can diff --git a/open-sse/services/combo/comboSetup.ts b/open-sse/services/combo/comboSetup.ts new file mode 100644 index 0000000000..c18f5e9698 --- /dev/null +++ b/open-sse/services/combo/comboSetup.ts @@ -0,0 +1,115 @@ +/** + * phaseComboSetup — the first extracted phase of handleComboChat (combo.ts). + * + * Resolves the per-request combo setup BEFORE dispatch: routing strategy, context-relay + * config, resilience settings, universal-handoff config, server-side context-cache pinning + * (which rewrites ctx.body), combo agent middleware (also rewrites ctx.body), the config + * cascade, the per-target timeout, and the reasoning-token-buffer flag. Behaviour is + * byte-identical to the inline block it replaces — it just reads/writes via ComboContext. + * + * See _tasks/quality/2026-06-19-DESIGN-godfiles-decomposition.md §4. + */ +import { normalizeRoutingStrategy } from "../../../src/shared/constants/routingStrategies.ts"; +import { + resolveContextRelayConfig, + resolveUniversalHandoffConfig, + SKIP_UNIVERSAL_HANDOFF_FLAG, +} from "../contextHandoff.ts"; +import { getLastSessionModel } from "../../../src/lib/db/contextHandoffs.ts"; +import { applyComboAgentMiddleware } from "../comboAgentMiddleware.ts"; +import { + resolveComboSetupConfig, + resolveComboTargetTimeoutMs, +} from "../comboConfig.ts"; +import { resolveResilienceSettings } from "../../../src/lib/resilience/settings"; +import { FETCH_TIMEOUT_MS } from "../../config/constants.ts"; +import { deriveComboSessionKey } from "./autoStrategy.ts"; +import type { ComboContext } from "./context.ts"; + +export interface ComboSetup { + strategy: ReturnType; + relayConfig: ReturnType | null; + resilienceSettings: ReturnType; + universalHandoffConfig: ReturnType; + effectiveSessionId: string | null; + pinnedModel: string | null; + clientRequestedStream: boolean; + config: ReturnType; + comboTargetTimeoutMs: number; + reasoningTokenBufferEnabled: boolean; +} + +export function phaseComboSetup(ctx: ComboContext): ComboSetup { + const { combo, settings, relayOptions, log } = ctx; + + const strategy = normalizeRoutingStrategy(combo.strategy || "priority"); + const relayConfig = + strategy === "context-relay" ? resolveContextRelayConfig(relayOptions?.config || null) : null; + + const resilienceSettings = settings + ? resolveResilienceSettings(settings) + : resolveResilienceSettings(null); + + const universalHandoffConfig = resolveUniversalHandoffConfig( + (combo.universal_handoff || combo.universalHandoff) as + | Record + | null + | undefined, + relayOptions?.universalHandoffConfig as Record | null | undefined + ); + + // ── Server-side context cache pinning (replaces tag roundtrip) ─ + // Uses session_model_history — no client-side tag injection, no visible output pollution. + // + // #3825: when the client sends no session id (most OpenAI-compatible clients), fall + // back to a stable conversation fingerprint derived from the body so the combo still + // re-pins to the same model across turns. ONLY engaged when context_cache_protection + // is truthy — when the toggle is off, behavior is unchanged (combos rotate as before, + // no pin read/write, no tag). + const effectiveSessionId: string | null = combo.context_cache_protection + ? (relayOptions?.sessionId ?? deriveComboSessionKey(ctx.body)) + : null; + let pinnedModel: string | null = null; + if ( + combo.context_cache_protection && + effectiveSessionId && + !(ctx.body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] + ) { + const pinned = getLastSessionModel(effectiveSessionId, combo.name); + if (pinned) { + ctx.body = { ...ctx.body, model: pinned }; + pinnedModel = pinned; + log.info("COMBO", `[#401] Context cache: pinned model=${pinned} (server-side)`); + } + } + + // ── Combo Agent Middleware (#399 + #401) ──────────────────────────────── + // Apply system_message override, tool_filter_regex. + // Context cache pinning is handled above via session_model_history. + const { body: agentBody } = applyComboAgentMiddleware( + ctx.body, + combo, + "" // provider/model not yet known — resolved per-model in loop + ); + ctx.body = agentBody; + const clientRequestedStream = ctx.body?.stream === true; + + // Use config cascade before dispatch so all strategies, pinned context routes, + // and round-robin targets share the same timeout policy. + const config = resolveComboSetupConfig(combo, settings); + const comboTargetTimeoutMs = resolveComboTargetTimeoutMs(config, FETCH_TIMEOUT_MS); + const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false; + + return { + strategy, + relayConfig, + resilienceSettings, + universalHandoffConfig, + effectiveSessionId, + pinnedModel, + clientRequestedStream, + config, + comboTargetTimeoutMs, + reasoningTokenBufferEnabled, + }; +} diff --git a/open-sse/services/combo/context.ts b/open-sse/services/combo/context.ts new file mode 100644 index 0000000000..dff8c39793 --- /dev/null +++ b/open-sse/services/combo/context.ts @@ -0,0 +1,46 @@ +/** + * ComboContext — the per-request mutable carrier for the combo dispatch pipeline + * (Quality Gate v2 / Fase 9 — god-file decomposition, fase 1). + * + * handleComboChat in combo.ts is a ~1600-LOC orchestrator whose phases share a set of + * "dispatch frontier" locals — chief among them the request `body`, which the setup phase + * rewrites (context-cache pinning + combo agent middleware) and later dispatch phases keep + * sharing. Carrying that mutable state on a single ctx object lets each phase be extracted + * into a small, independently testable/mutatable function instead of living inside the + * monolith. This is the first extracted slice; subsequent phases will add their own shared + * mutable fields (executor, runUpstreamStream, semaphore, retry accumulators) to ComboContext. + * + * See _tasks/quality/2026-06-19-DESIGN-godfiles-decomposition.md §4. + */ +import type { ComboLike, ComboLogger, ComboRelayOptions } from "./types.ts"; + +export interface ComboContext { + /** + * Request body — MUTABLE. phaseComboSetup rewrites it for server-side context-cache + * pinning and combo agent middleware; later dispatch phases keep reading/mutating it. + */ + body: Record; + readonly combo: ComboLike; + readonly settings: Record | null; + readonly relayOptions: ComboRelayOptions | null; + readonly log: ComboLogger; +} + +export function createComboContext(opts: { + body: Record; + combo: ComboLike; + settings?: Record | null; + relayOptions?: ComboRelayOptions | null; + log: ComboLogger; +}): ComboContext { + // body is carried by reference (not copied) so phaseComboSetup's reassignments are + // byte-identical to the original inline code: it only ever replaces ctx.body with a NEW + // object on a pin ({ ...body, model }) or via the middleware result, never mutates in place. + return { + body: opts.body, + combo: opts.combo, + settings: opts.settings ?? null, + relayOptions: opts.relayOptions ?? null, + log: opts.log, + }; +} diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index 990dc11853..788f5496e3 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -180,3 +180,15 @@ export function resolveComboConfig( export function getDefaultComboConfig() { return { ...DEFAULT_COMBO_CONFIG }; } + +/** + * Resolve the effective combo config the same way handleComboChat does: cascade via + * resolveComboConfig when settings exist, else the defaults merged with the combo's own + * config. Encapsulated here so the ternary lives in one place (DRY) and its inferred union + * return type is the single source of truth for ComboContext.config (combo/context.ts). + */ +export function resolveComboSetupConfig(combo: ComboConfigLike, settings: ComboSettingsLike) { + return settings + ? resolveComboConfig(combo, settings) + : { ...getDefaultComboConfig(), ...((combo?.config as Record) || {}) }; +} diff --git a/tests/unit/combo/combo-context.test.ts b/tests/unit/combo/combo-context.test.ts new file mode 100644 index 0000000000..1895e042aa --- /dev/null +++ b/tests/unit/combo/combo-context.test.ts @@ -0,0 +1,65 @@ +// tests/unit/combo/combo-context.test.ts +// Unit tests for the first extracted combo phase (god-file decomposition fase 1): +// createComboContext (combo/context.ts) + phaseComboSetup (combo/comboSetup.ts). +// The pinning-ON path (getLastSessionModel) is covered end-to-end by the existing combo +// characterization suite (combo-sessionless-pin-3825, combo-config, etc.); here we exercise +// the pure path (context_cache_protection OFF, no settings -> no DB) and the body carrier. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createComboContext } from "../../../open-sse/services/combo/context.ts"; +import { phaseComboSetup } from "../../../open-sse/services/combo/comboSetup.ts"; + +const log = { info() {}, warn() {}, error() {}, debug() {} }; + +test("createComboContext carries inputs and the body BY REFERENCE", () => { + const body = { model: "auto", messages: [], stream: true }; + const combo = { name: "c1", models: ["a", "b"] }; + const ctx = createComboContext({ body, combo, log }); + assert.equal(ctx.body, body, "body must be the same reference (not copied) for byte-identical pinning"); + assert.equal(ctx.combo, combo); + assert.equal(ctx.settings, null); + assert.equal(ctx.relayOptions, null); + assert.equal(ctx.log, log); +}); + +test("phaseComboSetup resolves strategy/config/stream with pinning OFF (pure, no DB)", () => { + const body = { model: "auto", messages: [], stream: true }; + const combo = { name: "c1", models: ["a"], strategy: "priority" }; + const ctx = createComboContext({ body, combo, log }); + + const setup = phaseComboSetup(ctx); + + assert.equal(setup.strategy, "priority"); + assert.equal(setup.pinnedModel, null, "no pin when context_cache_protection is off"); + assert.equal(setup.effectiveSessionId, null); + assert.equal(setup.clientRequestedStream, true, "body.stream === true"); + assert.equal(typeof setup.comboTargetTimeoutMs, "number"); + assert.equal(typeof setup.reasoningTokenBufferEnabled, "boolean"); + assert.ok(setup.config && typeof setup.config === "object", "config cascade resolved"); + assert.ok( + setup.resilienceSettings && typeof setup.resilienceSettings === "object", + "resilience settings resolved" + ); +}); + +test("phaseComboSetup: clientRequestedStream is false when body.stream is not true", () => { + const ctx = createComboContext({ + body: { model: "auto", messages: [] }, + combo: { name: "c", models: ["a"] }, + log, + }); + const setup = phaseComboSetup(ctx); + assert.equal(setup.clientRequestedStream, false); +}); + +test("phaseComboSetup normalizes an unknown strategy to a valid routing strategy", () => { + const ctx = createComboContext({ + body: { model: "auto", messages: [] }, + combo: { name: "c", models: ["a"], strategy: "not-a-real-strategy" }, + log, + }); + const setup = phaseComboSetup(ctx); + // normalizeRoutingStrategy falls back to the default for unknown values. + assert.equal(typeof setup.strategy, "string"); + assert.ok(setup.strategy.length > 0); +});