refactor: dedupe agent lowercase helpers

This commit is contained in:
Peter Steinberger
2026-04-07 12:26:16 +01:00
parent f2fa096f14
commit 50265c8b1f
8 changed files with 55 additions and 36 deletions

View File

@@ -6,6 +6,7 @@ import { loadConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { computeBackoff, type BackoffPolicy } from "../infra/backoff.js";
import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { lookupCachedContextTokens, MODEL_CONTEXT_TOKEN_CACHE } from "./context-cache.js";
import { CONTEXT_WINDOW_RUNTIME_STATE } from "./context-runtime-state.js";
@@ -92,10 +93,7 @@ function loadModelsConfigRuntime() {
}
function isLikelyOpenClawCliProcess(argv: string[] = process.argv): boolean {
const entryBasename = path
.basename(argv[1] ?? "")
.trim()
.toLowerCase();
const entryBasename = normalizeLowercaseStringOrEmpty(path.basename(argv[1] ?? ""));
return (
entryBasename === "openclaw" ||
entryBasename === "openclaw.mjs" ||
@@ -275,9 +273,9 @@ function resolveConfiguredModelParams(
if (!models) {
return undefined;
}
const key = `${provider}/${model}`.trim().toLowerCase();
const key = normalizeLowercaseStringOrEmpty(`${provider}/${model}`);
for (const [rawKey, entry] of Object.entries(models)) {
if (rawKey.trim().toLowerCase() === key) {
if (normalizeLowercaseStringOrEmpty(rawKey) === key) {
const params = (entry as AgentModelEntry | undefined)?.params;
return params && typeof params === "object" ? params : undefined;
}
@@ -360,7 +358,9 @@ function resolveConfiguredProviderContextTokens(
}
// 1. Exact match (case-insensitive, no alias expansion).
const exactResult = findContextTokens((id) => id.trim().toLowerCase() === provider.toLowerCase());
const exactResult = findContextTokens(
(id) => normalizeLowercaseStringOrEmpty(id) === normalizeLowercaseStringOrEmpty(provider),
);
if (exactResult !== undefined) {
return exactResult;
}
@@ -374,7 +374,7 @@ function isAnthropic1MModel(provider: string, model: string): boolean {
if (provider !== "anthropic") {
return false;
}
const normalized = model.trim().toLowerCase();
const normalized = normalizeLowercaseStringOrEmpty(model);
const modelId = normalized.includes("/")
? (normalized.split("/").at(-1) ?? normalized)
: normalized;

View File

@@ -1,6 +1,7 @@
import { type OpenClawConfig, loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { normalizeProviderId } from "./provider-id.js";
@@ -164,11 +165,12 @@ export async function loadModelCatalog(params?: {
if (supplemental.length > 0) {
const seen = new Set(
models.map(
(entry) => `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`,
(entry) =>
`${normalizeLowercaseStringOrEmpty(entry.provider)}::${normalizeLowercaseStringOrEmpty(entry.id)}`,
),
);
for (const entry of supplemental) {
const key = `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`;
const key = `${normalizeLowercaseStringOrEmpty(entry.provider)}::${normalizeLowercaseStringOrEmpty(entry.id)}`;
if (seen.has(key)) {
continue;
}
@@ -226,10 +228,10 @@ export function findModelInCatalog(
modelId: string,
): ModelCatalogEntry | undefined {
const normalizedProvider = normalizeProviderId(provider);
const normalizedModelId = modelId.toLowerCase().trim();
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
return catalog.find(
(entry) =>
normalizeProviderId(entry.provider) === normalizedProvider &&
entry.id.toLowerCase() === normalizedModelId,
normalizeLowercaseStringOrEmpty(entry.id) === normalizedModelId,
);
}

View File

@@ -1,17 +1,22 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../../shared/string-coerce.js";
type AnthropicCacheRetentionFamily =
| "anthropic-direct"
| "anthropic-bedrock"
| "custom-anthropic-api";
export function isAnthropicModelRef(modelId: string): boolean {
return modelId.trim().toLowerCase().startsWith("anthropic/");
return normalizeLowercaseStringOrEmpty(modelId).startsWith("anthropic/");
}
/** Matches Application Inference Profile ARNs across all AWS partitions with Bedrock. */
const BEDROCK_APP_INFERENCE_PROFILE_ARN_RE = /^arn:aws(-cn|-us-gov)?:bedrock:/;
export function isAnthropicBedrockModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
const normalized = normalizeLowercaseStringOrEmpty(modelId);
// Direct Anthropic Claude model IDs and regional inference profiles
// e.g. "anthropic.claude-sonnet-4-6", "us.anthropic.claude-sonnet-4-6", "global.anthropic.claude-opus-4-6-v1"
@@ -41,7 +46,9 @@ export function isAnthropicBedrockModel(modelId: string): boolean {
}
export function isOpenRouterAnthropicModelRef(provider: string, modelId: string): boolean {
return provider.trim().toLowerCase() === "openrouter" && isAnthropicModelRef(modelId);
return (
normalizeOptionalLowercaseString(provider) === "openrouter" && isAnthropicModelRef(modelId)
);
}
export function isAnthropicFamilyCacheTtlEligible(params: {
@@ -49,7 +56,7 @@ export function isAnthropicFamilyCacheTtlEligible(params: {
modelApi?: string;
modelId: string;
}): boolean {
const normalizedProvider = params.provider.trim().toLowerCase();
const normalizedProvider = normalizeOptionalLowercaseString(params.provider);
if (normalizedProvider === "anthropic" || normalizedProvider === "anthropic-vertex") {
return true;
}
@@ -65,7 +72,7 @@ export function resolveAnthropicCacheRetentionFamily(params: {
modelId?: string;
hasExplicitCacheConfig: boolean;
}): AnthropicCacheRetentionFamily | undefined {
const normalizedProvider = params.provider.trim().toLowerCase();
const normalizedProvider = normalizeOptionalLowercaseString(params.provider);
if (normalizedProvider === "anthropic" || normalizedProvider === "anthropic-vertex") {
return "anthropic-direct";
}

View File

@@ -1,6 +1,7 @@
import type { Api } from "@mariozechner/pi-ai";
import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.js";
import { normalizeGoogleApiBaseUrl } from "../../infra/google-api-base-url.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js";
import {
attachModelProviderRequestTransport,
@@ -68,12 +69,12 @@ function isLegacyFoundryVisionModelCandidate(params: {
modelId?: string;
modelName?: string;
}): boolean {
if (params.provider?.trim().toLowerCase() !== "microsoft-foundry") {
if (normalizeOptionalLowercaseString(params.provider) !== "microsoft-foundry") {
return false;
}
const normalizedCandidates = [params.modelId, params.modelName]
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim().toLowerCase())
.map((value) => normalizeOptionalLowercaseString(value))
.filter(Boolean);
return normalizedCandidates.some(
(candidate) =>

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { resolveAnthropicCacheRetentionFamily } from "./anthropic-family-cache-semantics.js";
type CacheRetention = "none" | "short" | "long";
@@ -9,7 +10,7 @@ export function isGooglePromptCacheEligible(params: {
if (params.modelApi !== "google-generative-ai") {
return false;
}
const normalizedModelId = params.modelId?.trim().toLowerCase() ?? "";
const normalizedModelId = normalizeLowercaseStringOrEmpty(params.modelId);
return normalizedModelId.startsWith("gemini-2.5") || normalizedModelId.startsWith("gemini-3");
}

View File

@@ -1,3 +1,7 @@
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import type { RuntimeVersionEnv } from "../version.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { normalizeProviderId } from "./provider-id.js";
@@ -129,7 +133,7 @@ function formatOpenClawUserAgent(version: string): string {
function tryParseHostname(value: string): string | undefined {
try {
return new URL(value).hostname.toLowerCase();
return normalizeOptionalLowercaseString(new URL(value).hostname);
} catch {
return undefined;
}
@@ -140,11 +144,10 @@ function isSchemelessHostnameCandidate(value: string): boolean {
}
function resolveUrlHostname(value: unknown): string | undefined {
if (typeof value !== "string" || !value.trim()) {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return undefined;
}
const trimmed = value.trim();
const parsedHostname = tryParseHostname(trimmed);
if (parsedHostname) {
return parsedHostname;
@@ -156,7 +159,7 @@ function resolveUrlHostname(value: unknown): string | undefined {
}
function normalizeComparableBaseUrl(value: string): string | undefined {
const trimmed = value.trim();
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return undefined;
}
@@ -172,7 +175,7 @@ function normalizeComparableBaseUrl(value: string): string | undefined {
}
url.hash = "";
url.search = "";
return url.toString().replace(/\/+$/, "").toLowerCase();
return normalizeOptionalLowercaseString(url.toString().replace(/\/+$/, ""));
} catch {
return undefined;
}
@@ -465,7 +468,7 @@ export function resolveProviderRequestPolicy(
const policy = resolveProviderAttributionPolicy(provider, env);
const endpointResolution = resolveProviderEndpoint(input.baseUrl);
const endpointClass = endpointResolution.endpointClass;
const api = input.api?.trim().toLowerCase();
const api = normalizeOptionalLowercaseString(input.api);
const usesConfiguredBaseUrl = endpointClass !== "default";
const usesKnownNativeOpenAIEndpoint =
endpointClass === "openai-public" ||
@@ -535,8 +538,8 @@ export function resolveProviderRequestCapabilities(
): ProviderRequestCapabilities {
const policy = resolveProviderRequestPolicy(input, env);
const provider = policy.provider;
const api = input.api?.trim().toLowerCase();
const normalizedModelId = input.modelId?.trim().toLowerCase();
const api = normalizeOptionalLowercaseString(input.api);
const normalizedModelId = normalizeOptionalLowercaseString(input.modelId);
const endpointClass = policy.endpointClass;
const isKnownNativeEndpoint =
endpointClass === "anthropic-public" ||

View File

@@ -5,6 +5,10 @@ import { resolveChannelApprovalCapability } from "../channels/plugins/approvals.
import { getChannelPlugin } from "../channels/plugins/index.js";
import type { MemoryCitationsMode } from "../config/types.memory.js";
import { buildMemoryPromptSection } from "../plugins/memory-state.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../shared/string-coerce.js";
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
import type { ResolvedTimeFormat } from "./date-time.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
@@ -47,7 +51,7 @@ function normalizeContextFilePath(pathValue: string): string {
function getContextFileBasename(pathValue: string): string {
const normalizedPath = normalizeContextFilePath(pathValue);
return (normalizedPath.split("/").pop() ?? normalizedPath).toLowerCase();
return normalizeLowercaseStringOrEmpty(normalizedPath.split("/").pop() ?? normalizedPath);
}
function isDynamicContextFile(pathValue: string): boolean {
@@ -297,7 +301,7 @@ function buildExecApprovalPromptGuidance(params: {
runtimeChannel?: string;
inlineButtonsEnabled?: boolean;
}) {
const runtimeChannel = params.runtimeChannel?.trim().toLowerCase();
const runtimeChannel = normalizeOptionalLowercaseString(params.runtimeChannel);
const usesNativeApprovalUi =
runtimeChannel === "webchat" ||
params.inlineButtonsEnabled === true ||
@@ -368,7 +372,7 @@ export function buildAgentSystemPrompt(params: {
// Preserve caller casing while deduping tool names by lowercase.
const canonicalByNormalized = new Map<string, string>();
for (const name of canonicalToolNames) {
const normalized = name.toLowerCase();
const normalized = normalizeLowercaseStringOrEmpty(name);
if (!canonicalByNormalized.has(normalized)) {
canonicalByNormalized.set(normalized, name);
}
@@ -376,7 +380,7 @@ export function buildAgentSystemPrompt(params: {
const resolveToolName = (normalized: string) =>
canonicalByNormalized.get(normalized) ?? normalized;
const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase());
const normalizedTools = canonicalToolNames.map((tool) => normalizeLowercaseStringOrEmpty(tool));
const availableTools = new Set(normalizedTools);
const hasSessionsSpawn = availableTools.has("sessions_spawn");
const hasUpdatePlanTool = availableTools.has("update_plan");
@@ -430,10 +434,10 @@ export function buildAgentSystemPrompt(params: {
? normalizeStructuredPromptSection(params.heartbeatPrompt)
: undefined;
const runtimeInfo = params.runtimeInfo;
const runtimeChannel = runtimeInfo?.channel?.trim().toLowerCase();
const runtimeChannel = normalizeOptionalLowercaseString(runtimeInfo?.channel);
const runtimeCapabilities = runtimeInfo?.capabilities ?? [];
const runtimeCapabilitiesLower = new Set(
runtimeCapabilities.map((cap) => String(cap).trim().toLowerCase()).filter(Boolean),
runtimeCapabilities.map((cap) => normalizeLowercaseStringOrEmpty(String(cap))).filter(Boolean),
);
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
const messageChannelOptions = listDeliverableMessageChannels().join("|");

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
CORE_TOOL_GROUPS,
resolveCoreToolProfilePolicy,
@@ -17,7 +18,7 @@ const TOOL_NAME_ALIASES: Record<string, string> = {
export const TOOL_GROUPS: Record<string, string[]> = { ...CORE_TOOL_GROUPS };
export function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
const normalized = normalizeLowercaseStringOrEmpty(name);
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}