diff --git a/src/agents/context.ts b/src/agents/context.ts index 3404c90093..176e0ef20b 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -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; diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 92e8f2e7db..0161596190 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -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, ); } diff --git a/src/agents/pi-embedded-runner/anthropic-family-cache-semantics.ts b/src/agents/pi-embedded-runner/anthropic-family-cache-semantics.ts index 2e5d38d242..686babfef0 100644 --- a/src/agents/pi-embedded-runner/anthropic-family-cache-semantics.ts +++ b/src/agents/pi-embedded-runner/anthropic-family-cache-semantics.ts @@ -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"; } diff --git a/src/agents/pi-embedded-runner/model.inline-provider.ts b/src/agents/pi-embedded-runner/model.inline-provider.ts index 1f30f423a6..db698c525b 100644 --- a/src/agents/pi-embedded-runner/model.inline-provider.ts +++ b/src/agents/pi-embedded-runner/model.inline-provider.ts @@ -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) => diff --git a/src/agents/pi-embedded-runner/prompt-cache-retention.ts b/src/agents/pi-embedded-runner/prompt-cache-retention.ts index f967bcfd58..5b5c0d5357 100644 --- a/src/agents/pi-embedded-runner/prompt-cache-retention.ts +++ b/src/agents/pi-embedded-runner/prompt-cache-retention.ts @@ -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"); } diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index e17d81e7d8..892bbf6e3b 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -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" || diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 13f3cd67aa..cf41a70bce 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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(); 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("|"); diff --git a/src/agents/tool-policy-shared.ts b/src/agents/tool-policy-shared.ts index e28c623de4..cb811a32c3 100644 --- a/src/agents/tool-policy-shared.ts +++ b/src/agents/tool-policy-shared.ts @@ -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 = { export const TOOL_GROUPS: Record = { ...CORE_TOOL_GROUPS }; export function normalizeToolName(name: string) { - const normalized = name.trim().toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(name); return TOOL_NAME_ALIASES[normalized] ?? normalized; }