fix(ui): require object JSON for routing_strategy_args and render cleared args as unset

This commit is contained in:
Tin Chi Lo 2026-08-06 14:15:15 -07:00
parent a29bf27913
commit c816a6aac6
4 changed files with 65 additions and 6 deletions

View file

@ -6,7 +6,7 @@ import TextArea from "antd/es/input/TextArea";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Team } from "../key_team_helpers/key_list";
import CacheControlSettings from "./cache_control_settings";
import { ROUTING_STRATEGY_OPTIONS } from "./routing_strategy_options";
import { formItemValidateJSONObject, ROUTING_STRATEGY_OPTIONS } from "./routing_strategy_options";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import { Tag } from "../tag_management/types";
import { formItemValidateJSON } from "../../utils/textUtils";
@ -197,7 +197,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
label="Routing Strategy Args"
name="routing_strategy_args"
className="mb-4"
rules={[{ validator: formItemValidateJSON }]}
rules={[{ validator: formItemValidateJSONObject }]}
help="Optional JSON args for the selected strategy, e.g. latency window TTL."
>
<TextArea rows={2} placeholder='{"ttl": 3600}' />

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { formItemValidateJSONObject, hasRoutingStrategyArgs, routingStrategyLabel } from "./routing_strategy_options";
describe("formItemValidateJSONObject", () => {
it("accepts empty input and JSON objects", async () => {
await expect(formItemValidateJSONObject(undefined, "")).resolves.toBeUndefined();
await expect(formItemValidateJSONObject(undefined, '{"ttl": 3600}')).resolves.toBeUndefined();
await expect(formItemValidateJSONObject(undefined, "{}")).resolves.toBeUndefined();
});
it("rejects JSON that is not an object", async () => {
await expect(formItemValidateJSONObject(undefined, "[1, 2]")).rejects.toMatch(/JSON object/);
await expect(formItemValidateJSONObject(undefined, "3600")).rejects.toMatch(/JSON object/);
await expect(formItemValidateJSONObject(undefined, "null")).rejects.toMatch(/JSON object/);
});
it("rejects invalid JSON", async () => {
await expect(formItemValidateJSONObject(undefined, "{ttl:")).rejects.toMatch(/valid JSON/);
});
});
describe("hasRoutingStrategyArgs", () => {
it("treats empty and missing objects as unset", () => {
expect(hasRoutingStrategyArgs(undefined)).toBe(false);
expect(hasRoutingStrategyArgs(null)).toBe(false);
expect(hasRoutingStrategyArgs({})).toBe(false);
expect(hasRoutingStrategyArgs({ ttl: 3600 })).toBe(true);
});
});
describe("routingStrategyLabel", () => {
it("labels known strategies and defaults the rest", () => {
expect(routingStrategyLabel("cost-based-routing")).toContain("Cost-Based");
expect(routingStrategyLabel(undefined)).toBe("Inherit router default");
expect(routingStrategyLabel("")).toBe("Inherit router default");
});
});

View file

@ -11,3 +11,20 @@ export const routingStrategyLabel = (value: string | undefined | null): string =
const match = ROUTING_STRATEGY_OPTIONS.find((o) => o.value === value);
return match ? match.label : "Inherit router default";
};
export const hasRoutingStrategyArgs = (args: object | undefined | null): boolean => Object.keys(args ?? {}).length > 0;
export const formItemValidateJSONObject = (_: unknown, value: string) => {
if (!value) {
return Promise.resolve();
}
try {
const parsed = JSON.parse(value);
if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
return Promise.reject('Must be a JSON object, e.g. {"ttl": 3600}');
}
return Promise.resolve();
} catch (error) {
return Promise.reject("Please enter valid JSON");
}
};

View file

@ -18,7 +18,12 @@ import {
Button as TremorButton,
} from "@tremor/react";
import { Button, Form, Input, Modal, Select, Tooltip } from "antd";
import { ROUTING_STRATEGY_OPTIONS, routingStrategyLabel } from "./add_model/routing_strategy_options";
import {
formItemValidateJSONObject,
hasRoutingStrategyArgs,
ROUTING_STRATEGY_OPTIONS,
routingStrategyLabel,
} from "./add_model/routing_strategy_options";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@ -819,7 +824,7 @@ export default function ModelInfoView({
tags: Array.isArray(localModelData.litellm_params?.tags) ? localModelData.litellm_params.tags : [],
health_check_model: isWildcardModel ? localModelData.model_info?.health_check_model : null,
routing_strategy: localModelData.model_info?.routing_strategy || "",
routing_strategy_args: localModelData.model_info?.routing_strategy_args
routing_strategy_args: hasRoutingStrategyArgs(localModelData.model_info?.routing_strategy_args)
? JSON.stringify(localModelData.model_info.routing_strategy_args)
: "",
litellm_credential_name: localModelData.litellm_params?.litellm_credential_name || "",
@ -1115,13 +1120,13 @@ export default function ModelInfoView({
<Form.Item
name="routing_strategy_args"
className="mb-0"
rules={[{ validator: formItemValidateJSON }]}
rules={[{ validator: formItemValidateJSONObject }]}
>
<Input placeholder='{"ttl": 3600}' />
</Form.Item>
) : (
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
{localModelData.model_info?.routing_strategy_args
{hasRoutingStrategyArgs(localModelData.model_info?.routing_strategy_args)
? JSON.stringify(localModelData.model_info.routing_strategy_args)
: "Not Set"}
</div>