fix(combos): auto-promote zeroLatencyOptimizationsEnabled so legacy configs (pre-3.8.33 fallbackCompressionMode="lite") round-trip on the first GUI edit (#4774)

Auto-promote zeroLatencyOptimizationsEnabled + strip v3.8.31-era removed keys so legacy combo configs round-trip through PUT /api/combos/{id} on first GUI edit (closes #4382 followup). Pre-merge: rewrote the now-stale reject test to assert auto-promotion + added passthrough/round-trip regression guards; reconciled combos/page.tsx file-size baseline. Integrated into release/v3.8.35.
This commit is contained in:
KooshaPari
2026-06-23 06:04:54 -07:00
committed by GitHub
parent f759ec74b3
commit 759265877f
6 changed files with 188 additions and 27 deletions

View File

@@ -1,5 +1,6 @@
{
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
"_rebaseline_2026_06_23_4774_combo_legacy_strip": "PR #4774 (KooshaPari, #4382 round-trip) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4434->4456 (+22 = the client-side LEGACY_COMBO_RESILIENCE_KEYS Set gains queueTimeoutMs + the 12 v3.8.31-era removed keys (queueDepth/fallbackDelayMs/handoffProviders/maxComboDepth/manifestRouting/complexityAwareRouting/pipeline_enabled/pipelineConcurrency/shadowRouting/evalRouting/resetAwareEnabled/resetAwareWindow) with explanatory comments, mirroring the server-side strip list in src/app/api/combos/[id]/route.ts so the modal never re-introduces removed keys on Save). Functional strip list, not a movable block; combos/page.tsx structural shrink tracked in #3501. Covered by tests/unit/combo-config.test.ts (auto-promote + passthrough + legacy-key round-trip).",
"_rebaseline_2026_06_21_4421_node_lookup": "Issue #4421 own growth: src/lib/db/providers.ts 1050->1063 (+13 = resolveProviderNodeForConnection at the existing provider-node lookup — resolves a connection node by exact id OR the bare derived type when unambiguous, + import). Pure selection logic in the new src/lib/db/providerNodeSelect.ts (<cap, unit-tested); cohesive DB wiring next to getProviderNodeById, not extractable. Covered by tests/unit/provider-node-select-4421.test.ts.",
"_rebaseline_2026_06_21_v3833_release_basered_fixes": "Release v3.8.33: 5 deterministic base-reds inherited from parallel-session merges (fast-gates PR→release não rodam test:unit completo) consertados no PR de release. src/sse/services/auth.ts 2279->2289 (+10): #4530 só fiou maxCooldownMs nos 3 sites de combo.ts; os 4 sites de markAccountUnavailable (per-model quota, grok-web 403, per-model 403, local 404) nunca passavam o cap → resolvo mlSettings uma vez e passo maxCooldownMs em todos. tests/unit/db-core-init.test.ts 864->867 (+3): comentário explicando o cap intencional busy_timeout 5s->2s do v3.8.32. Wiring necessário ao chokepoint de lockout; não extraível. Coberto por model-lockout-max-cooldown.test.ts + db-core-init.test.ts. (Demais base-reds — áudio mp3 #912/#913 dedup de handler em geminiHelper.ts, e closure #3578 src/models/ no package.json files — não cresceram arquivo congelado.)",
"_rebaseline_2026_06_21_v3833_cycle_open_latent_filesize": "Abertura do ciclo v3.8.33: 4 arquivos cresceram no ciclo v3.8.32 sem bump de baseline e o drift escapou do fast-path do release (check:file-size não roda nas fast-gates p/ release/*, só no PR→main full CI, e o crescimento veio de commits entre fca66c644 e o head do merge 912239f46 — ex. #4475 targetFormat). Medido em origin/main (idêntico, cherry-picks deste ciclo NÃO tocam estes 4): open-sse/services/usage.ts 3408->3414, src/lib/db/core.ts 1820->1825, src/lib/usage/providerLimits.ts 949->950, src/shared/constants/providers.ts 3242->3243. Reconcílio ao valor real de main p/ abrir o .33 verde; shrink estrutural rastreado em #3501.",
@@ -153,7 +154,7 @@
"src/app/(dashboard)/dashboard/cache/page.tsx": 841,
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 900,
"src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922,
"src/app/(dashboard)/dashboard/combos/page.tsx": 4434,
"src/app/(dashboard)/dashboard/combos/page.tsx": 4456,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495,
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007,
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570,

View File

@@ -174,9 +174,31 @@ const ADVANCED_FIELD_HELP_FALLBACK = {
};
const LEGACY_COMBO_RESILIENCE_KEYS = new Set([
// UI-removed knobs (replaced by per-target timeoutMs on each step)
"timeoutMs",
"healthCheckEnabled",
"healthCheckTimeoutMs",
// queueTimeoutMs is still in the schema but the dashboard UI no longer surfaces
// it; carrying it forward through edit+save leaves a stale knob in the modal
// that surprises operators. Strip it pre-PUT so the persisted config matches
// what the UI is currently able to display.
"queueTimeoutMs",
// Keys that were present in v3.8.31-era combo configs but have since been
// removed from comboRuntimeConfigSchema. Mirrors the server-side strip list
// in src/app/api/combos/[id]/route.ts so the modal never re-introduces them
// when the user clicks Save. See #4382 (combo update returns 400).
"queueDepth",
"fallbackDelayMs",
"handoffProviders",
"maxComboDepth",
"manifestRouting",
"complexityAwareRouting",
"pipeline_enabled",
"pipelineConcurrency",
"shadowRouting",
"evalRouting",
"resetAwareEnabled",
"resetAwareWindow",
]);
const MS_PER_SECOND = 1000;

View File

@@ -18,6 +18,47 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { QUOTA_MODEL_PREFIX } from "@/lib/quota/quotaModelNaming";
/**
* Keys that were present in older combo configs (≤ v3.8.31) but have since been
* removed from comboRuntimeConfigSchema. The dashboard modal sanitises the three
* UI-level keys (timeoutMs, healthCheckEnabled, healthCheckTimeoutMs) before PUT,
* but v3.8.31-era stored configs also carry these 12 keys which were spread back
* into the body on edit+save. We strip them server-side so removed keys don't
* accumulate in `combos.data` and so the next read produces a clean config.
*
* Idempotent — running twice is a no-op.
*/
const LEGACY_REMOVED_COMBO_CONFIG_KEYS = Object.freeze([
"queueDepth",
"fallbackDelayMs",
"handoffProviders",
"maxComboDepth",
"manifestRouting",
"complexityAwareRouting",
"pipeline_enabled",
"pipelineConcurrency",
"shadowRouting",
"evalRouting",
"resetAwareEnabled",
"resetAwareWindow",
]);
function stripLegacyComboConfigKeys(rawConfig) {
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
return rawConfig;
}
let mutated = false;
const next = {};
for (const [key, value] of Object.entries(rawConfig)) {
if (LEGACY_REMOVED_COMBO_CONFIG_KEYS.includes(key)) {
mutated = true;
continue;
}
next[key] = value;
}
return mutated ? next : rawConfig;
}
// GET /api/combos/[id] - Get combo by ID
export async function GET(request, { params }) {
const authError = await requireManagementAuth(request);
@@ -97,6 +138,9 @@ export async function PUT(request, { params }) {
normalizedUpdate.config = nextConfig;
delete normalizedUpdate.compressionOverride;
}
if (normalizedUpdate.config && typeof normalizedUpdate.config === "object") {
normalizedUpdate.config = stripLegacyComboConfigKeys(normalizedUpdate.config);
}
const body = normalizedUpdate.models
? {

View File

@@ -0,0 +1,54 @@
-- 103_strip_legacy_combo_config_keys.sql
-- One-shot sweep over `combos.data` to remove v3.8.31-era config keys that were
-- subsequently dropped from comboRuntimeConfigSchema. Without this sweep, a
-- combo created on ≤ v3.8.31 still carries the legacy keys in its persisted
-- JSON; on the next edit+save the modal spreads the existing config back into
-- the PUT body, and comboRuntimeConfigSchema.strict() rejects the unknown
-- keys with a 400. See diegosouzapw/OmniRoute#4382.
--
-- Belt-and-suspenders:
-- - src/shared/validation/schemas/combo.ts now uses .passthrough() so the
-- server accepts unknown legacy keys during the upgrade window
-- - src/app/api/combos/[id]/route.ts strips the same keys before persistence
-- so new writes are clean
-- - src/app/(dashboard)/dashboard/combos/page.tsx strips them client-side
--
-- This migration handles pre-existing rows. It is idempotent: running it again
-- on a clean DB no-ops because json_remove on a missing path is a no-op, and
-- the WHERE clause skips rows that don't carry any of the legacy keys.
-- Strip the 12 known removed keys from any persisted combo config.
UPDATE combos
SET data = json_remove(
data,
'$.config.queueDepth',
'$.config.fallbackDelayMs',
'$.config.handoffProviders',
'$.config.maxComboDepth',
'$.config.manifestRouting',
'$.config.complexityAwareRouting',
'$.config.pipeline_enabled',
'$.config.pipelineConcurrency',
'$.config.shadowRouting',
'$.config.evalRouting',
'$.config.resetAwareEnabled',
'$.config.resetAwareWindow'
)
WHERE EXISTS (
SELECT 1
FROM json_each(data, '$.config') AS cfg
WHERE cfg.key IN (
'queueDepth',
'fallbackDelayMs',
'handoffProviders',
'maxComboDepth',
'manifestRouting',
'complexityAwareRouting',
'pipeline_enabled',
'pipelineConcurrency',
'shadowRouting',
'evalRouting',
'resetAwareEnabled',
'resetAwareWindow'
)
);

View File

@@ -186,28 +186,26 @@ export const comboRuntimeConfigSchema = z
shadowRouting: shadowRoutingSchema.optional(),
evalRouting: evalRoutingSchema.optional(),
})
.strict()
.superRefine((config, ctx) => {
if (config.zeroLatencyOptimizationsEnabled === true) return;
.passthrough()
.transform((config) => {
// Backward-compat shim: combos stored prior to v3.8.33 may carry zero-latency
// feature flags (fallbackCompressionMode !== "off", hedging === true, or
// predictiveTtftMs > 0) without the accompanying zeroLatencyOptimizationsEnabled
// gate that the new schema requires. Auto-promote the flag when any such feature
// is enabled but the gate is unset/false, so stored combos continue to round-trip
// through PUT /api/combos/{id} without returning 400. This replaces the prior
// superRefine that hard-rejected these payloads (see issue #4382).
if (config.zeroLatencyOptimizationsEnabled === true) return config;
const addZeroLatencyIssue = (path: string[]) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"zeroLatencyOptimizationsEnabled must be true to enable zero-latency combo features",
path,
});
};
const hasZeroLatencyFeature =
config.hedging === true ||
(typeof config.predictiveTtftMs === "number" && config.predictiveTtftMs > 0) ||
(!!config.fallbackCompressionMode && config.fallbackCompressionMode !== "off");
if (config.hedging === true) {
addZeroLatencyIssue(["hedging"]);
}
if (typeof config.predictiveTtftMs === "number" && config.predictiveTtftMs > 0) {
addZeroLatencyIssue(["predictiveTtftMs"]);
}
if (config.fallbackCompressionMode && config.fallbackCompressionMode !== "off") {
addZeroLatencyIssue(["fallbackCompressionMode"]);
if (hasZeroLatencyFeature) {
return { ...config, zeroLatencyOptimizationsEnabled: true };
}
return config;
});
export const comboNameSchema = z

View File

@@ -213,9 +213,15 @@ test("combo config schema accepts explicit zero-latency opt-in controls", () =>
assert.equal(parsed.config.predictiveTtftMs, 1800);
});
test("combo config schema rejects enabled zero-latency subfeatures without opt-in", () => {
test("combo config schema auto-promotes the zero-latency gate for legacy configs without opt-in", () => {
// Pre-3.8.33 stored combos carry zero-latency subfeatures (hedging /
// fallbackCompressionMode / predictiveTtftMs) WITHOUT the
// zeroLatencyOptimizationsEnabled gate the current schema expects. Rather than
// rejecting these on the first GUI edit (the #4382 400), the schema now
// auto-promotes the gate to true so the stored config round-trips through
// PUT /api/combos/{id}. See #4774 (closes #4382 followup).
const result = createComboSchema.safeParse({
name: "zero-latency-noop",
name: "zero-latency-legacy",
models: ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"],
config: {
hedging: true,
@@ -224,11 +230,47 @@ test("combo config schema rejects enabled zero-latency subfeatures without opt-i
},
});
assert.equal(result.success, false);
assert.deepEqual(
result.error.issues.map((issue) => issue.path.join(".")),
["config.hedging", "config.predictiveTtftMs", "config.fallbackCompressionMode"]
);
assert.equal(result.success, true);
assert.equal(result.data.config.zeroLatencyOptimizationsEnabled, true);
// The enabled subfeatures are preserved verbatim.
assert.equal(result.data.config.hedging, true);
assert.equal(result.data.config.fallbackCompressionMode, "lite");
assert.equal(result.data.config.predictiveTtftMs, 1800);
});
test("combo config schema leaves the zero-latency gate untouched when no subfeature is enabled", () => {
// A plain config with no zero-latency subfeature must NOT be auto-promoted —
// the gate stays at its default (false) so we don't silently flip optimizations on.
const result = createComboSchema.safeParse({
name: "no-zero-latency",
models: ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"],
config: {
fallbackCompressionMode: "off",
},
});
assert.equal(result.success, true);
assert.notEqual(result.data.config.zeroLatencyOptimizationsEnabled, true);
});
test("combo config schema no longer rejects v3.8.31-era removed config keys (#4382 round-trip)", () => {
// These keys were dropped from the schema after v3.8.31 but still live in
// stored combo JSON. The schema switched from .strict() (which 400'd on these)
// to .passthrough() so legacy configs survive an edit+save; the server route
// (stripLegacyComboConfigKeys) and migration 103 then scrub them on write.
const result = createComboSchema.safeParse({
name: "legacy-removed-keys",
models: ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"],
config: {
queueDepth: 4,
fallbackDelayMs: 200,
maxComboDepth: 3,
shadowRouting: { enabled: false },
resetAwareEnabled: true,
},
});
assert.equal(result.success, true);
});
test("combo config schema allows zero-latency tuning fields when subfeatures stay disabled", () => {