refactor: remove residual normalization adapters (#122771)

This commit is contained in:
Peter Steinberger
2026-08-12 11:51:45 -07:00
committed by GitHub
parent 8bbc0a9bc7
commit 9da43d67e1
14 changed files with 57 additions and 98 deletions
@@ -1,3 +1,4 @@
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
CodexAppServerApprovalPolicySource,
CodexAppServerCommandSource,
@@ -53,7 +54,6 @@ import {
normalizeCodexServiceTier,
normalizeHeaders,
normalizePositiveNumber,
normalizeStringList,
readBooleanEnv,
readNonEmptyString,
readNumberEnv,
@@ -120,7 +120,7 @@ export function resolveCodexAppServerRuntimeOptions(
}
const args = resolveArgs(config.args, env.OPENCLAW_CODEX_APP_SERVER_ARGS);
const headers = normalizeHeaders(config.headers);
const clearEnv = normalizeStringList(config.clearEnv);
const clearEnv = normalizeTrimmedStringList(config.clearEnv);
const authToken = normalizeCodexAppServerSecretInput({
value: config.authToken,
path: "plugins.entries.codex.config.appServer.authToken",
@@ -4,7 +4,6 @@ import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-i
import {
asOptionalRecord as readRecord,
normalizeOptionalString as readNonEmptyString,
normalizeTrimmedStringList,
parseBooleanValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { OpenClawExecAsk, OpenClawExecSecurity } from "./config-contracts.js";
@@ -69,10 +68,6 @@ export function normalizeCodexAppServerSecretInput(params: {
return normalizeResolvedSecretInputString(params);
}
export function normalizeStringList(value: unknown): string[] {
return normalizeTrimmedStringList(value);
}
export function readBooleanEnv(value: string | undefined): boolean | undefined {
return parseBooleanValue(value);
}
@@ -10,11 +10,7 @@ import {
normalizeOptionalTrimmedStringList,
normalizeTrimmedStringList,
} from "@openclaw/normalization-core/string-normalization";
import {
buildModelCatalogMergeKey,
buildModelCatalogRef,
normalizeModelCatalogProviderId,
} from "./model-catalog-refs.js";
import { buildModelCatalogMergeKey, buildModelCatalogRef } from "./model-catalog-refs.js";
import {
MODEL_CATALOG_APIS,
MODEL_CATALOG_THINKING_LEVELS,
@@ -39,6 +35,7 @@ import {
type ModelCatalogVercelGatewayRouting,
type NormalizedModelCatalogRow,
} from "./model-catalog-types.js";
import { normalizeProviderId } from "./provider-id.js";
// Normalizes raw provider model catalogs into stable rows for lookup and merging.
@@ -83,7 +80,7 @@ function normalizeSafeRecordKey(value: unknown): string {
function normalizeOwnedProviderSet(providers: ReadonlySet<string>): ReadonlySet<string> {
const normalized = new Set<string>();
for (const provider of providers) {
const providerId = normalizeModelCatalogProviderId(provider);
const providerId = normalizeProviderId(provider);
if (providerId) {
normalized.add(providerId);
}
@@ -552,7 +549,7 @@ function normalizeModelCatalogProviders(
}
const providers: Record<string, ModelCatalogProvider> = {};
for (const [rawProviderId, rawProvider] of Object.entries(value)) {
const providerId = normalizeModelCatalogProviderId(rawProviderId);
const providerId = normalizeProviderId(rawProviderId);
if (!providerId || !ownedProviders.has(providerId)) {
continue;
}
@@ -573,13 +570,11 @@ function normalizeModelCatalogAliases(
}
const aliases: Record<string, ModelCatalogAlias> = {};
for (const [rawAlias, rawTarget] of Object.entries(value)) {
const alias = normalizeModelCatalogProviderId(rawAlias);
const alias = normalizeProviderId(rawAlias);
if (!alias || !isRecord(rawTarget)) {
continue;
}
const provider = normalizeModelCatalogProviderId(
normalizeOptionalString(rawTarget.provider) ?? "",
);
const provider = normalizeProviderId(normalizeOptionalString(rawTarget.provider) ?? "");
if (!provider || !ownedProviders.has(provider)) {
continue;
}
@@ -603,7 +598,7 @@ function normalizeModelCatalogSuppressions(value: unknown): ModelCatalogSuppress
if (!isRecord(entry)) {
continue;
}
const provider = normalizeModelCatalogProviderId(normalizeOptionalString(entry.provider) ?? "");
const provider = normalizeProviderId(normalizeOptionalString(entry.provider) ?? "");
const model = normalizeOptionalString(entry.model) ?? "";
if (!provider || !model) {
continue;
@@ -642,7 +637,7 @@ function normalizeModelCatalogDiscovery(
}
const discovery: Record<string, ModelCatalogDiscovery> = {};
for (const [rawProviderId, rawMode] of Object.entries(value)) {
const providerId = normalizeModelCatalogProviderId(rawProviderId);
const providerId = normalizeProviderId(rawProviderId);
const mode = normalizeOptionalString(rawMode) ?? "";
if (providerId && ownedProviders.has(providerId) && MODEL_CATALOG_DISCOVERY_MODES.has(mode)) {
discovery[providerId] = mode as ModelCatalogDiscovery;
@@ -681,7 +676,7 @@ export function normalizeModelCatalogProviderRows(params: {
providerCatalog: ModelCatalogProvider;
source: ModelCatalogSource;
}): NormalizedModelCatalogRow[] {
const provider = normalizeModelCatalogProviderId(params.provider);
const provider = normalizeProviderId(params.provider);
if (!provider || !Array.isArray(params.providerCatalog.models)) {
return [];
}
@@ -1,5 +1,8 @@
// Model Catalog Core module implements model catalog refs behavior.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeProviderId } from "./provider-id.js";
export { normalizeProviderId as normalizeModelCatalogProviderId } from "./provider-id.js";
// Stable model catalog ref and merge-key builders.
@@ -43,14 +46,9 @@ export function isCloudModelRef(modelRef: string | undefined): boolean {
return source?.source === "cloud" && parseModelSourceSuffix(source.base) === undefined;
}
/** Normalize provider ids for catalog refs. */
export function normalizeModelCatalogProviderId(provider: string): string {
return normalizeLowercaseStringOrEmpty(provider);
}
/** Build a provider/model catalog reference. */
export function buildModelCatalogRef(provider: string, modelId: string): string {
return `${normalizeModelCatalogProviderId(provider)}/${modelId}`;
return `${normalizeProviderId(provider)}/${modelId}`;
}
/** Parse a strict provider/model reference without normalizing either segment. */
@@ -72,12 +70,12 @@ export function parseModelCatalogRef(value: string): ModelCatalogRef | null {
return null;
}
return {
provider: normalizeModelCatalogProviderId(parsed.provider),
provider: normalizeProviderId(parsed.provider),
modelId: parsed.model,
};
}
/** Build a case-insensitive merge key for provider/model rows. */
export function buildModelCatalogMergeKey(provider: string, modelId: string): string {
return `${normalizeModelCatalogProviderId(provider)}::${normalizeLowercaseStringOrEmpty(modelId)}`;
return `${normalizeProviderId(provider)}::${normalizeLowercaseStringOrEmpty(modelId)}`;
}
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
filterStringEntries,
normalizeAtHashSlug,
normalizeCsvOrLooseStringList,
normalizeHyphenSlug,
normalizeOptionalTrimmedStringList,
normalizeSortedUniqueStringEntries,
@@ -82,6 +83,15 @@ describe("normalization-core/string-normalization", () => {
expect(normalizeOptionalTrimmedStringList(["", 42])).toBeUndefined();
});
it.each([
{ value: " first, second, , first ", expected: ["first", "second", "first"] },
{ value: [" first ", 42, "", " ", 7], expected: ["first", "42", "7"] },
{ value: null, expected: [] },
{ value: { value: "first" }, expected: [] },
])("normalizes CSV or loose string-list input", ({ value, expected }) => {
expect(normalizeCsvOrLooseStringList(value)).toEqual(expected);
});
it("normalizes sorted unique trimmed string lists", () => {
expect(normalizeSortedUniqueTrimmedStringList([" b ", "a", "b", "", "a"])).toEqual(["a", "b"]);
expect(normalizeSortedUniqueTrimmedStringList(["z", 1, " a "] as unknown[])).toEqual([
@@ -99,10 +99,6 @@ function isBareSentDeliveryStatus(value: unknown): boolean {
return normalizeStatus(value) === SENT_DELIVERY_STATUS;
}
function parseJsonRecord(value: string): Record<string, unknown> | undefined {
return safeParseJsonRecord(value);
}
function recordHasDeliveredMessageId(record: Record<string, unknown>): boolean {
const hasDeliveredId = (value: unknown) => {
const normalized = normalizeStatus(value);
@@ -151,7 +147,7 @@ function deliveryEnvelopeHasCreatedConversationId(value: unknown, depth = 0): bo
}
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
const parsed = safeParseJsonRecord(record.text);
if (parsed && deliveryEnvelopeHasCreatedConversationId(parsed, depth + 1)) {
return true;
}
@@ -183,7 +179,7 @@ function deliveryEnvelopeIndicatesOk(value: unknown, depth = 0): boolean {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
const parsed = safeParseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesOk(parsed, depth + 1)) {
return true;
}
@@ -218,7 +214,7 @@ function deliveryEnvelopeIndicatesNonDelivery(value: unknown, depth = 0): boolea
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
const parsed = safeParseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesNonDelivery(parsed, depth + 1)) {
return true;
}
@@ -263,7 +259,7 @@ function deliveryEnvelopeIndicatesNoOp(value: unknown, depth = 0): boolean {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
const parsed = safeParseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesNoOp(parsed, depth + 1)) {
return true;
}
@@ -314,7 +310,7 @@ function deliveryEnvelopeIndicatesSuccessfulBroadcast(value: unknown, depth = 0)
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
const parsed = safeParseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesSuccessfulBroadcast(parsed, depth + 1)) {
return true;
}
@@ -348,7 +344,7 @@ function deliveryEnvelopeIndicatesDryRun(value: unknown, depth = 0): boolean {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
const parsed = safeParseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesDryRun(parsed, depth + 1)) {
return true;
}
@@ -363,7 +359,7 @@ function deliveryEnvelopeIndicatesDryRun(value: unknown, depth = 0): boolean {
if (item && typeof item === "object" && !Array.isArray(item)) {
const text = (item as Record<string, unknown>).text;
if (typeof text === "string") {
const parsed = parseJsonRecord(text);
const parsed = safeParseJsonRecord(text);
if (parsed && deliveryEnvelopeIndicatesDryRun(parsed, depth + 1)) {
return true;
}
@@ -403,7 +399,7 @@ function deliveryEnvelopeIndicatesDelivered(
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
const parsed = safeParseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesDelivered(parsed, depth + 1, requireReceipt)) {
return true;
}
@@ -421,7 +417,7 @@ function deliveryEnvelopeIndicatesDelivered(
if (item && typeof item === "object" && !Array.isArray(item)) {
const text = (item as Record<string, unknown>).text;
if (typeof text === "string") {
const parsed = parseJsonRecord(text);
const parsed = safeParseJsonRecord(text);
if (parsed && deliveryEnvelopeIndicatesDelivered(parsed, depth + 1, requireReceipt)) {
return true;
}
@@ -455,7 +451,7 @@ function deliveryEnvelopeIndicatesSessionsSendAccepted(value: unknown, depth = 0
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
const parsed = safeParseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesSessionsSendAccepted(parsed, depth + 1)) {
return true;
}
+5 -7
View File
@@ -6,6 +6,7 @@
import {
clampTimerTimeoutMs,
MAX_TIMER_TIMEOUT_MS,
resolveOptionalIntegerOption,
} from "@openclaw/normalization-core/number-coercion";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -14,11 +15,8 @@ export const DEFAULT_AGENT_TIMEOUT_MS = DEFAULT_AGENT_TIMEOUT_SECONDS * 1000;
const NO_TIMEOUT_MS = MAX_TIMER_TIMEOUT_MS;
const NO_TIMEOUT_SECONDS = Math.floor(NO_TIMEOUT_MS / 1000);
const normalizeNumber = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : undefined;
function resolveAgentTimeoutSeconds(cfg?: OpenClawConfig): number {
const raw = normalizeNumber(cfg?.agents?.defaults?.timeoutSeconds);
const raw = resolveOptionalIntegerOption(cfg?.agents?.defaults?.timeoutSeconds);
// Config 0 uses the same unlimited-run sentinel as per-run overrides. The
// LLM idle watchdog still enforces liveness under that sentinel.
if (raw === 0) {
@@ -34,10 +32,10 @@ export function resolveAgentTimeoutMs(opts: {
overrideSeconds?: number | null;
minMs?: number;
}): number {
const minMs = Math.max(normalizeNumber(opts.minMs) ?? 1, 1);
const minMs = Math.max(resolveOptionalIntegerOption(opts.minMs) ?? 1, 1);
const clampTimeoutMs = (valueMs: number) => clampTimerTimeoutMs(valueMs, minMs) ?? minMs;
const defaultMs = clampTimeoutMs(resolveAgentTimeoutSeconds(opts.cfg) * 1000);
const overrideMs = normalizeNumber(opts.overrideMs);
const overrideMs = resolveOptionalIntegerOption(opts.overrideMs);
if (overrideMs !== undefined) {
if (overrideMs === 0) {
return NO_TIMEOUT_MS;
@@ -47,7 +45,7 @@ export function resolveAgentTimeoutMs(opts: {
}
return clampTimeoutMs(overrideMs);
}
const overrideSeconds = normalizeNumber(opts.overrideSeconds);
const overrideSeconds = resolveOptionalIntegerOption(opts.overrideSeconds);
if (overrideSeconds !== undefined) {
if (overrideSeconds === 0) {
return NO_TIMEOUT_MS;
@@ -30,13 +30,9 @@ function normalizeToolHistoryType(value: unknown): string | undefined {
return normalized ? normalized.replace(/_/g, "") : undefined;
}
function parseJsonRecord(value: string): Record<string, unknown> | undefined {
return safeParseJsonRecord(value);
}
function readMaybeJsonRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value === "string") {
return parseJsonRecord(value);
return safeParseJsonRecord(value);
}
return readRecord(value);
}
+5 -11
View File
@@ -8,6 +8,7 @@ import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeCsvOrLooseStringList } from "@openclaw/normalization-core/string-normalization";
import { listAgentIds, resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
@@ -66,13 +67,6 @@ function isAbsolutePathValue(value: string): boolean {
);
}
function parseCsv(value: string): string[] {
return value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function parseOptionalPositiveInt(value: string, max: number): number | undefined {
const trimmed = value.trim();
if (!trimmed) {
@@ -216,7 +210,7 @@ function assertNoCancel<T>(value: T | symbol, message: string): T {
const AUTH_PROFILE_ID_PATTERN = /^[A-Za-z0-9:_-]{1,128}$/;
function validateEnvNameCsv(value: string): string | undefined {
const entries = parseCsv(value);
const entries = normalizeCsvOrLooseStringList(value);
for (const entry of entries) {
if (!isValidEnvSecretRefId(entry)) {
return `Invalid env name: ${entry}`;
@@ -237,7 +231,7 @@ async function promptEnvNameCsv(params: {
}),
"Secrets configure cancelled.",
);
return parseCsv(raw ?? "");
return normalizeCsvOrLooseStringList(raw ?? "");
}
async function promptOptionalPositiveInt(params: {
@@ -599,7 +593,7 @@ async function promptExecProvider(
message: "Trusted dirs (comma-separated absolute paths, blank for none)",
initialValue: base?.trustedDirs?.join(",") ?? "",
validate: (value) => {
const entries = parseCsv(value ?? "");
const entries = normalizeCsvOrLooseStringList(value ?? "");
for (const entry of entries) {
if (!isAbsolutePathValue(entry)) {
return `Trusted dir must be absolute: ${entry}`;
@@ -612,7 +606,7 @@ async function promptExecProvider(
);
const args = await parseArgsInput(normalizeStringifiedOptionalString(argsRaw) ?? "");
const trustedDirs = parseCsv(trustedDirsRaw ?? "");
const trustedDirs = normalizeCsvOrLooseStringList(trustedDirsRaw ?? "");
return {
source: "exec",
+3 -11
View File
@@ -1,6 +1,5 @@
// Persists managed task-flow records through the OpenClaw SQLite state database.
import type { DatabaseSync } from "node:sqlite";
import { safeParseJson } from "@openclaw/normalization-core";
import type { Insertable, Selectable } from "kysely";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { normalizeSqliteNumber } from "../infra/sqlite-number.js";
@@ -18,7 +17,7 @@ import {
type TaskFlowRecord,
type TaskFlowSyncMode,
} from "./task-flow-registry.types.js";
import { parseDeliveryContextJson } from "./task-registry.sqlite.shared.js";
import { parseDeliveryContextJson, parseSqliteJsonValue } from "./task-registry.sqlite.shared.js";
import { parseTaskNotifyPolicy } from "./task-registry.types.js";
type FlowRunsTable = OpenClawStateKyselyDatabase["flow_runs"];
@@ -42,13 +41,6 @@ function serializeJson(value: unknown): string | null {
return value === undefined ? null : JSON.stringify(value);
}
function parseJsonValue(raw: string | null): JsonValue | undefined {
if (!raw?.trim()) {
return undefined;
}
return safeParseJson(raw) as JsonValue | undefined;
}
function rowToSyncMode(row: FlowRegistryRow): TaskFlowSyncMode {
// Older single_task rows did not persist sync_mode; preserve their mirrored semantics.
const syncMode = parseOptionalTaskFlowSyncMode(row.sync_mode);
@@ -62,8 +54,8 @@ function rowToFlowRecord(row: FlowRegistryRow): TaskFlowRecord {
const endedAt = normalizeSqliteNumber(row.ended_at);
const cancelRequestedAt = normalizeSqliteNumber(row.cancel_requested_at);
const requesterOrigin = parseDeliveryContextJson(row.requester_origin_json);
const stateJson = parseJsonValue(row.state_json);
const waitJson = parseJsonValue(row.wait_json);
const stateJson = parseSqliteJsonValue<JsonValue>(row.state_json);
const waitJson = parseSqliteJsonValue<JsonValue>(row.wait_json);
return {
flowId: row.flow_id,
syncMode: rowToSyncMode(row),
+1 -1
View File
@@ -5,7 +5,7 @@ import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Persisted JSON columns are typed by the receiving field.
function parseSqliteJsonValue<T>(raw: string | null): T | undefined {
export function parseSqliteJsonValue<T>(raw: string | null): T | undefined {
if (!raw?.trim()) {
return undefined;
}
+2 -10
View File
@@ -1,6 +1,5 @@
// Persists task registry records and events through the OpenClaw SQLite state database.
import type { DatabaseSync } from "node:sqlite";
import { safeParseJson } from "@openclaw/normalization-core";
import type { Insertable, Selectable } from "kysely";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { assertSqliteTableIntegrity } from "../infra/sqlite-integrity.js";
@@ -13,7 +12,7 @@ import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { parseDeliveryContextJson } from "./task-registry.sqlite.shared.js";
import { parseDeliveryContextJson, parseSqliteJsonValue } from "./task-registry.sqlite.shared.js";
import type { TaskRegistryStoreSnapshot } from "./task-registry.store.types.js";
import {
parseOptionalTaskTerminalOutcome,
@@ -91,13 +90,6 @@ function serializeJson(value: unknown): string | null {
return value === undefined ? null : (JSON.stringify(value) ?? null);
}
function parseJsonValue(raw: string | null): JsonValue | undefined {
if (!raw?.trim()) {
return undefined;
}
return safeParseJson(raw) as JsonValue | undefined;
}
function rowToTaskRecord(row: TaskRegistryRow): TaskRecord {
const startedAt = normalizeSqliteNumber(row.started_at);
const endedAt = normalizeSqliteNumber(row.ended_at);
@@ -106,7 +98,7 @@ function rowToTaskRecord(row: TaskRegistryRow): TaskRecord {
const toolUseCount = normalizeSqliteNumber(row.tool_use_count);
const scopeKind = parseTaskScopeKind(row.scope_kind);
const terminalOutcome = parseOptionalTaskTerminalOutcome(row.terminal_outcome);
const detail = parseJsonValue(row.detail_json);
const detail = parseSqliteJsonValue<JsonValue>(row.detail_json);
// System tasks intentionally have no requester session; ownerKey is the lookup anchor.
const requesterSessionKey =
scopeKind === "system" ? "" : row.requester_session_key?.trim() || row.owner_key;
-7
View File
@@ -535,13 +535,6 @@ export function resolveEffectiveModelFallbacks(
return resolveModelPrimary(entryModel) ? [] : resolveModelFallbacks(defaultModel);
}
export function parseFallbackList(value: string): string[] {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
type ConfiguredModelOption = {
value: string;
label: string;
+3 -3
View File
@@ -1,4 +1,5 @@
// Control UI view renders agents panels overview screen content.
import { normalizeCsvOrLooseStringList } from "@openclaw/normalization-core/string-normalization";
import { html, nothing } from "lit";
import type {
AgentIdentityResult,
@@ -13,7 +14,6 @@ import { t } from "../../i18n/index.ts";
import {
buildModelOptions,
normalizeModelValue,
parseFallbackList,
resolveAgentConfig,
resolveAgentRuntimeLabel,
resolveAgentTextAvatar,
@@ -141,7 +141,7 @@ export function renderAgentOverview(params: {
const input = e.target as HTMLInputElement;
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const parsed = parseFallbackList(input.value);
const parsed = normalizeCsvOrLooseStringList(input.value);
if (parsed.length > 0) {
onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]);
input.value = "";
@@ -357,7 +357,7 @@ export function renderAgentOverview(params: {
@keydown=${handleChipKeydown}
@blur=${(e: Event) => {
const input = e.target as HTMLInputElement;
const parsed = parseFallbackList(input.value);
const parsed = normalizeCsvOrLooseStringList(input.value);
if (parsed.length > 0) {
onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]);
input.value = "";