fix(ui): block an auto-router save on any dry-run rejection, message or not

The gate read the verdict's error as well as its valid flag, so a rejection that
carried no message fell through to the write and came back as the raw 400 the
dry-run exists to replace. valid is derived from error server side today, which
is why nothing had hit it, but the two arrive as independent fields and the UI
should not depend on that derivation.

Both forms now call one dryRunRejection, which gates on valid alone and falls
back to its own message when the backend sent none. A transport failure still
fails open as valid, leaving the write gate authoritative.

Reported by Bugbot.
This commit is contained in:
Tin Chi Lo 2026-08-27 08:40:43 -07:00
parent eb79ba4349
commit 5c6d2a8627
4 changed files with 41 additions and 4 deletions

View file

@ -41,6 +41,7 @@ import {
getPlanModeTierError,
getSemanticConfigError,
getTierLabelsError,
dryRunRejection,
} from "./build_complexity_router_config";
import { activeTierName, activeTierRows, getCustomTierRowsError, resolveComplexityDefaultModel } from "./tier_rows";
import { tierRowLabel } from "./complexity_router_tiers";
@ -405,9 +406,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
complexityRouterConfigPayload as unknown as Record<string, unknown>,
requiresTeamScope ? form.getValues("team_id") : undefined,
);
if (!serverVerdict.valid && serverVerdict.error) {
const dryRunError = dryRunRejection(serverVerdict);
if (dryRunError) {
setShowValidationErrors(true);
toast.fromError(serverVerdict.error);
toast.fromError(dryRunError);
return;
}

View file

@ -10,6 +10,7 @@ import {
getTierLabelsError,
hydrateTierLabels,
BuildComplexityRouterConfigParams,
dryRunRejection,
} from "./build_complexity_router_config";
import { CUSTOM_TIER_RESTRICTIONS, activeTierRows } from "./tier_rows";
@ -958,3 +959,24 @@ describe("hydrateCustomTierSet", () => {
expect(hydrated?.fallback_tier_id).toBe(hydrated?.tiers[0].id);
});
});
describe("dryRunRejection", () => {
it("blocks the save on a rejection whose message is missing, which the write would return as a raw 400", () => {
// The verdict's two fields arrive independently, so a rejection carrying no message must still
// stop the save rather than fall through to the write endpoint.
expect(dryRunRejection({ valid: false })).toBe("The proxy rejected this auto-router configuration");
expect(dryRunRejection({ valid: false, error: null })).toBe("The proxy rejected this auto-router configuration");
expect(dryRunRejection({ valid: false, error: " " })).toBe("The proxy rejected this auto-router configuration");
});
it("surfaces the backend's own message when it sent one", () => {
expect(dryRunRejection({ valid: false, error: "session_affinity cannot be combined with tier_definitions" })).toBe(
"session_affinity cannot be combined with tier_definitions",
);
});
it("lets a valid verdict through, including the fail-open one a transport failure returns", () => {
expect(dryRunRejection({ valid: true })).toBeNull();
expect(dryRunRejection({ valid: true, error: null })).toBeNull();
});
});

View file

@ -124,6 +124,17 @@ export interface BuildComplexityRouterConfigParams {
tierModelParams?: TierModelParamsByTier;
}
/**
* The message to surface when the dry-run rejects a save, or null to let it through.
*
* Gated on `valid` alone. The verdict's `valid` is derived from `error` server side today, but the
* two arrive as independent fields, so reading `error` as the gate would let a rejection whose
* message is missing or blank through to the write and back as a raw 400. A transport failure fails
* open as `{valid: true}`, which this passes, leaving the write gate authoritative.
*/
export const dryRunRejection = (verdict: { valid: boolean; error?: string | null }): string | null =>
verdict.valid ? null : verdict.error?.trim() || "The proxy rejected this auto-router configuration";
export interface TierDefinitionPayload {
name: string;
description?: string;

View file

@ -36,6 +36,7 @@ import {
hydrateCustomTierSet,
hydratePlanModeMinTier,
hydrateTierLabels,
dryRunRejection,
} from "../add_model/build_complexity_router_config";
import { KeywordTierRule } from "../add_model/KeywordTierRules";
import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching";
@ -562,9 +563,10 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
{ keywordTierRules, escalationKeywords, semanticMatchingEnabled, embeddingModel, matchThreshold },
);
const serverVerdict = await validateAutoRouterConfig(accessToken, updatedConfig, modelData?.model_info?.team_id);
if (!serverVerdict.valid && serverVerdict.error) {
const dryRunError = dryRunRejection(serverVerdict);
if (dryRunError) {
setShowValidationErrors(true);
toast.fromError(serverVerdict.error);
toast.fromError(dryRunError);
return;
}